diff --git a/apps/web/app/(use-page-wrapper)/payment/[uid]/PaymentPage.tsx b/apps/web/app/(use-page-wrapper)/payment/[uid]/PaymentPage.tsx index 4d931831230a8a..652f7a45f0b3ee 100644 --- a/apps/web/app/(use-page-wrapper)/payment/[uid]/PaymentPage.tsx +++ b/apps/web/app/(use-page-wrapper)/payment/[uid]/PaymentPage.tsx @@ -60,6 +60,16 @@ const BtcpayPaymentComponent = dynamic( } ); +const PaystackPaymentComponent = dynamic( + () => + import("@calcom/web/components/apps/paystack/PaystackPaymentComponent").then( + (m) => m.PaystackPaymentComponent + ), + { + ssr: false, + } +); + const PaymentPage: FC = (props) => { const { t, i18n } = useLocale(); const [is24h, setIs24h] = useState(isBrowserLocale24h()); @@ -171,6 +181,15 @@ const PaymentPage: FC = (props) => { {props.payment.appId === "btcpayserver" && !props.payment.success && ( )} + {props.payment.appId === "paystack" && !props.payment.success && ( + + )} {props.payment.refunded && (
{t("refunded")}
)} diff --git a/apps/web/components/apps/AppSetupPage.tsx b/apps/web/components/apps/AppSetupPage.tsx index 88acf8c89bbb76..7d52d74d86d55b 100644 --- a/apps/web/components/apps/AppSetupPage.tsx +++ b/apps/web/components/apps/AppSetupPage.tsx @@ -15,6 +15,7 @@ export const AppSetupMap = { paypal: dynamic(() => import("@calcom/web/components/apps/paypal/Setup")), hitpay: dynamic(() => import("@calcom/web/components/apps/hitpay/Setup")), btcpayserver: dynamic(() => import("@calcom/web/components/apps/btcpayserver/Setup")), + paystack: dynamic(() => import("@calcom/web/components/apps/paystack/Setup")), }; export const AppSetupPage = (props: { slug: string }) => { diff --git a/apps/web/components/apps/paystack/PaystackPaymentComponent.tsx b/apps/web/components/apps/paystack/PaystackPaymentComponent.tsx new file mode 100644 index 00000000000000..44740d4e86f050 --- /dev/null +++ b/apps/web/components/apps/paystack/PaystackPaymentComponent.tsx @@ -0,0 +1,51 @@ +"use client"; + +import dynamic from "next/dynamic"; +import z from "zod"; + +import { useLocale } from "@calcom/lib/hooks/useLocale"; + +const PaystackInlineComponent = dynamic( + () => import("@calcom/app-store/paystack/components/PaystackPaymentComponent"), + { ssr: false } +); + +const PaystackPaymentDataSchema = z.object({ + access_code: z.string().min(1), + authorization_url: z.string().url(), + publicKey: z.string().min(1), + reference: z.string().min(1), +}); + +type Props = { + payment: { data: unknown }; + bookingUid: string; + bookingTitle: string; + amount: number; + currency: string; +}; + +export const PaystackPaymentComponent = ({ + payment, + bookingUid, + bookingTitle, + amount, + currency, +}: Props) => { + const { t } = useLocale(); + const parsed = PaystackPaymentDataSchema.safeParse(payment.data); + + if (!parsed.success) { + return

{t("payment_failed_try_again")}

; + } + + return ( + + ); +}; diff --git a/apps/web/components/apps/paystack/Setup.tsx b/apps/web/components/apps/paystack/Setup.tsx new file mode 100644 index 00000000000000..50935a7da5aace --- /dev/null +++ b/apps/web/components/apps/paystack/Setup.tsx @@ -0,0 +1,133 @@ +import { useRouter } from "next/navigation"; +import { useState } from "react"; +import { Toaster } from "sonner"; + +import AppNotInstalledMessage from "@calcom/app-store/_components/AppNotInstalledMessage"; +import { APP_NAME } from "@calcom/lib/constants"; +import { useLocale } from "@calcom/lib/hooks/useLocale"; +import { trpc } from "@calcom/trpc/react"; +import { Button } from "@calcom/ui/components/button"; +import { TextField } from "@calcom/ui/components/form"; +import { showToast } from "@calcom/ui/components/toast"; + +export default function PaystackSetup() { + const [newPublicKey, setNewPublicKey] = useState(""); + const [newSecretKey, setNewSecretKey] = useState(""); + const router = useRouter(); + const { t } = useLocale(); + + const integrations = trpc.viewer.apps.integrations.useQuery({ + variant: "payment", + appId: "paystack", + }); + + const [paystackCredentials] = integrations.data?.items || []; + const credentialId = paystackCredentials?.userCredentialIds?.[0]; + + const showContent = + !!integrations.data && integrations.isSuccess && typeof credentialId === "number" && credentialId > 0; + + const saveKeysMutation = trpc.viewer.apps.updateAppCredentials.useMutation({ + onSuccess: () => { + showToast(t("keys_have_been_saved"), "success"); + router.push("/event-types"); + }, + onError: (error) => { + showToast(error.message, "error"); + }, + }); + + if (integrations.isPending) { + return
; + } + + return ( +
+ {showContent ? ( +
+
+
+ Paystack +

Paystack

+
+ +
{ + e.preventDefault(); + if (typeof credentialId !== "number") return; + saveKeysMutation.mutate({ + credentialId, + key: { + public_key: newPublicKey, + secret_key: newSecretKey, + }, + }); + }}> + setNewPublicKey(e.target.value)} + className="mb-6" + placeholder="pk_test_xxxxxxxxx" + /> + + setNewSecretKey(e.target.value)} + placeholder="sk_test_xxxxxxxxx" + /> + +
+ +
+ + +
+

{t("getting_started")}

+

+ {t("paystack_getting_started_description", { appName: APP_NAME })}{" "} + + {t("paystack_dashboard")} + + . +

+ +

{t("paystack_webhook_setup")}

+

+ {t("paystack_webhook_setup_description")} +

+ + {typeof window !== "undefined" ? window.location.origin : "https://your-cal.com"} + /api/integrations/paystack/webhook + +
+
+
+ ) : ( + + )} + + +
+ ); +} diff --git a/lint-staged.config.mjs b/lint-staged.config.mjs index 1db8ae8d3bf889..909606cc081c02 100644 --- a/lint-staged.config.mjs +++ b/lint-staged.config.mjs @@ -1,7 +1,11 @@ const quotePath = (file) => `"${file.replace(/"/g, '\\"')}"`; export default { - "(apps|packages|companion)/**/*.{js,ts,jsx,tsx}": (files) => - `biome lint --reporter summary --config-path=biome-staged.json ${files.map(quotePath).join(" ")}`, + "(apps|packages|companion)/**/*.{js,ts,jsx,tsx}": (files) => { + // biome.json ignores **/*.d.ts; passing them in errors the run. + const lintable = files.filter((f) => !f.endsWith(".d.ts")); + if (lintable.length === 0) return []; + return `biome lint --reporter summary --config-path=biome-staged.json ${lintable.map(quotePath).join(" ")}`; + }, "packages/prisma/schema.prisma": ["prisma format"], }; diff --git a/packages/app-store/_pages/setup/_getServerSideProps.tsx b/packages/app-store/_pages/setup/_getServerSideProps.tsx index 8004f5c19ed4a9..31442cb75c8811 100644 --- a/packages/app-store/_pages/setup/_getServerSideProps.tsx +++ b/packages/app-store/_pages/setup/_getServerSideProps.tsx @@ -6,6 +6,7 @@ export const AppSetupPageMap = { stripe: import("../../stripepayment/pages/setup/_getServerSideProps"), hitpay: import("../../hitpay/pages/setup/_getServerSideProps"), btcpayserver: import("../../btcpayserver/pages/setup/_getServerSideProps"), + paystack: import("../../paystack/pages/setup/_getServerSideProps"), }; export const getServerSideProps = async (ctx: GetServerSidePropsContext) => { diff --git a/packages/app-store/apps.browser.generated.tsx b/packages/app-store/apps.browser.generated.tsx index cf6fef19fff46f..92b1828c43c69a 100644 --- a/packages/app-store/apps.browser.generated.tsx +++ b/packages/app-store/apps.browser.generated.tsx @@ -35,6 +35,7 @@ export const EventTypeAddonMap = { metapixel: dynamic(() => import("./metapixel/components/EventTypeAppCardInterface")), "mock-payment-app": dynamic(() => import("./mock-payment-app/components/EventTypeAppCardInterface")), paypal: dynamic(() => import("./paypal/components/EventTypeAppCardInterface")), + paystack: dynamic(() => import("./paystack/components/EventTypeAppCardInterface")), "pipedrive-crm": dynamic(() => import("./pipedrive-crm/components/EventTypeAppCardInterface")), plausible: dynamic(() => import("./plausible/components/EventTypeAppCardInterface")), posthog: dynamic(() => import("./posthog/components/EventTypeAppCardInterface")), @@ -64,6 +65,7 @@ export const EventTypeSettingsMap = { hitpay: dynamic(() => import("./hitpay/components/EventTypeAppSettingsInterface")), metapixel: dynamic(() => import("./metapixel/components/EventTypeAppSettingsInterface")), paypal: dynamic(() => import("./paypal/components/EventTypeAppSettingsInterface")), + paystack: dynamic(() => import("./paystack/components/EventTypeAppSettingsInterface")), plausible: dynamic(() => import("./plausible/components/EventTypeAppSettingsInterface")), qr_code: dynamic(() => import("./qr_code/components/EventTypeAppSettingsInterface")), stripepayment: dynamic(() => import("./stripepayment/components/EventTypeAppSettingsInterface")), diff --git a/packages/app-store/apps.keys-schemas.generated.ts b/packages/app-store/apps.keys-schemas.generated.ts index 43e1a41e4b7f45..5648ddadc50824 100644 --- a/packages/app-store/apps.keys-schemas.generated.ts +++ b/packages/app-store/apps.keys-schemas.generated.ts @@ -32,6 +32,7 @@ import { appKeysSchema as nextcloudtalk_zod_ts } from "./nextcloudtalk/zod"; import { appKeysSchema as office365calendar_zod_ts } from "./office365calendar/zod"; import { appKeysSchema as office365video_zod_ts } from "./office365video/zod"; import { appKeysSchema as paypal_zod_ts } from "./paypal/zod"; +import { appKeysSchema as paystack_zod_ts } from "./paystack/zod"; import { appKeysSchema as pipedrive_crm_zod_ts } from "./pipedrive-crm/zod"; import { appKeysSchema as plausible_zod_ts } from "./plausible/zod"; import { appKeysSchema as posthog_zod_ts } from "./posthog/zod"; @@ -83,6 +84,7 @@ export const appKeysSchemas = { office365calendar: office365calendar_zod_ts, office365video: office365video_zod_ts, paypal: paypal_zod_ts, + paystack: paystack_zod_ts, "pipedrive-crm": pipedrive_crm_zod_ts, plausible: plausible_zod_ts, posthog: posthog_zod_ts, diff --git a/packages/app-store/apps.metadata.generated.ts b/packages/app-store/apps.metadata.generated.ts index 019771b8fb2fa5..ad557b526749c1 100644 --- a/packages/app-store/apps.metadata.generated.ts +++ b/packages/app-store/apps.metadata.generated.ts @@ -69,6 +69,7 @@ import nextcloudtalk_config_json from "./nextcloudtalk/config.json"; import { metadata as office365calendar__metadata_ts } from "./office365calendar/_metadata"; import office365video_config_json from "./office365video/config.json"; import paypal_config_json from "./paypal/config.json"; +import paystack_config_json from "./paystack/config.json"; import ping_config_json from "./ping/config.json"; import pipedream_config_json from "./pipedream/config.json"; import pipedrive_crm_config_json from "./pipedrive-crm/config.json"; @@ -181,6 +182,7 @@ export const appStoreMetadata = { office365calendar: office365calendar__metadata_ts, office365video: office365video_config_json, paypal: paypal_config_json, + paystack: paystack_config_json, ping: ping_config_json, pipedream: pipedream_config_json, "pipedrive-crm": pipedrive_crm_config_json, diff --git a/packages/app-store/apps.schemas.generated.ts b/packages/app-store/apps.schemas.generated.ts index 4085408dbeb7d0..ef0dae1c8309e6 100644 --- a/packages/app-store/apps.schemas.generated.ts +++ b/packages/app-store/apps.schemas.generated.ts @@ -32,6 +32,7 @@ import { appDataSchema as nextcloudtalk_zod_ts } from "./nextcloudtalk/zod"; import { appDataSchema as office365calendar_zod_ts } from "./office365calendar/zod"; import { appDataSchema as office365video_zod_ts } from "./office365video/zod"; import { appDataSchema as paypal_zod_ts } from "./paypal/zod"; +import { appDataSchema as paystack_zod_ts } from "./paystack/zod"; import { appDataSchema as pipedrive_crm_zod_ts } from "./pipedrive-crm/zod"; import { appDataSchema as plausible_zod_ts } from "./plausible/zod"; import { appDataSchema as posthog_zod_ts } from "./posthog/zod"; @@ -83,6 +84,7 @@ export const appDataSchemas = { office365calendar: office365calendar_zod_ts, office365video: office365video_zod_ts, paypal: paypal_zod_ts, + paystack: paystack_zod_ts, "pipedrive-crm": pipedrive_crm_zod_ts, plausible: plausible_zod_ts, posthog: posthog_zod_ts, diff --git a/packages/app-store/apps.server.generated.ts b/packages/app-store/apps.server.generated.ts index d5c6fa9f95039b..4a4bb93355af58 100644 --- a/packages/app-store/apps.server.generated.ts +++ b/packages/app-store/apps.server.generated.ts @@ -51,6 +51,7 @@ export const apiHandlers = { office365calendar: import("./office365calendar/api"), office365video: import("./office365video/api"), paypal: import("./paypal/api"), + paystack: import("./paystack/api"), ping: import("./ping/api"), "pipedrive-crm": import("./pipedrive-crm/api"), plausible: import("./plausible/api"), diff --git a/packages/app-store/payment.services.generated.ts b/packages/app-store/payment.services.generated.ts index 2e42d632580810..6e7e82fdaedb14 100644 --- a/packages/app-store/payment.services.generated.ts +++ b/packages/app-store/payment.services.generated.ts @@ -8,5 +8,6 @@ export const PaymentServiceMap = { hitpay: import("./hitpay/lib/PaymentService"), "mock-payment-app": import("./mock-payment-app/lib/PaymentService"), paypal: import("./paypal/lib/PaymentService"), + paystack: import("./paystack/lib/PaymentService"), stripepayment: import("./stripepayment/lib/PaymentService"), }; diff --git a/packages/app-store/paystack/_metadata.ts b/packages/app-store/paystack/_metadata.ts new file mode 100644 index 00000000000000..d783e59bfb1467 --- /dev/null +++ b/packages/app-store/paystack/_metadata.ts @@ -0,0 +1,22 @@ +import type { AppMeta } from "@calcom/types/App"; + +import _package from "./package.json"; + +export const metadata = { + name: "Paystack", + description: _package.description, + installed: true, + type: "paystack_payment", + variant: "payment", + logo: "icon.svg", + publisher: "Cal.com", + url: "https://paystack.com", + categories: ["payment"], + slug: "paystack", + title: "Paystack", + email: "support@cal.com", + dirName: "paystack", + isOAuth: true, +} as AppMeta; + +export default metadata; diff --git a/packages/app-store/paystack/api/add.ts b/packages/app-store/paystack/api/add.ts new file mode 100644 index 00000000000000..2dc657b3539b70 --- /dev/null +++ b/packages/app-store/paystack/api/add.ts @@ -0,0 +1,55 @@ +import { throwIfNotHaveAdminAccessToTeam } from "@calcom/app-store/_utils/throwIfNotHaveAdminAccessToTeam"; +import { getServerErrorFromUnknown } from "@calcom/lib/server/getServerErrorFromUnknown"; +import prisma from "@calcom/prisma"; +import type { NextApiRequest, NextApiResponse } from "next"; +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 { teamId } = req.query; + const teamIdNumber = teamId ? Number(teamId) : null; + + if (teamIdNumber !== null && Number.isNaN(teamIdNumber)) { + return res.status(400).json({ message: "Invalid teamId" }); + } + + await throwIfNotHaveAdminAccessToTeam({ teamId: teamIdNumber, userId: req.session.user.id }); + + const appType = config.type; + const ownerFilter = teamIdNumber ? { teamId: teamIdNumber } : { userId: req.session.user.id }; + + try { + const created = await prisma.$transaction(async (tx) => { + const alreadyInstalled = await tx.credential.findFirst({ + where: { type: appType, ...ownerFilter }, + select: { id: true }, + }); + if (alreadyInstalled) { + return null; + } + return tx.credential.create({ + data: { + type: appType, + key: {}, + appId: "paystack", + ...ownerFilter, + }, + select: { id: true }, + }); + }); + + if (!created) { + return res.status(409).json({ message: "Already installed" }); + } + } catch (error: unknown) { + const httpError = getServerErrorFromUnknown(error); + return res.status(httpError.statusCode).json({ message: httpError.message }); + } + + return res + .status(201) + .json({ url: `/apps/paystack/setup${teamIdNumber ? `?teamId=${teamIdNumber}` : ""}` }); +} diff --git a/packages/app-store/paystack/api/index.ts b/packages/app-store/paystack/api/index.ts new file mode 100644 index 00000000000000..214855db1a31c9 --- /dev/null +++ b/packages/app-store/paystack/api/index.ts @@ -0,0 +1,3 @@ +export { default as add } from "./add"; +export { default as webhook } from "./webhook"; +export { default as verify } from "./verify"; diff --git a/packages/app-store/paystack/api/verify.ts b/packages/app-store/paystack/api/verify.ts new file mode 100644 index 00000000000000..4aa135de54fcde --- /dev/null +++ b/packages/app-store/paystack/api/verify.ts @@ -0,0 +1,145 @@ +import type { NextApiRequest, NextApiResponse } from "next"; + +import { handlePaymentSuccess } from "@calcom/app-store/_utils/payments/handlePaymentSuccess"; +import { IS_PRODUCTION } from "@calcom/lib/constants"; +import { HttpError as HttpCode } from "@calcom/lib/http-error"; +import logger from "@calcom/lib/logger"; +import { safeStringify } from "@calcom/lib/safeStringify"; +import { getServerErrorFromUnknown } from "@calcom/lib/server/getServerErrorFromUnknown"; +import { distributedTracing } from "@calcom/lib/tracing/factory"; +import prisma from "@calcom/prisma"; + +import { appKeysSchema } from "../zod"; +import { PaystackClient } from "../lib/PaystackClient"; + +const log = logger.getSubLogger({ prefix: ["[paystackVerify]"] }); + +export default async function handler(req: NextApiRequest, res: NextApiResponse) { + try { + if (req.method !== "GET") { + throw new HttpCode({ statusCode: 405, message: "Method Not Allowed" }); + } + + const { reference } = req.query; + if (typeof reference !== "string" || !reference) { + throw new HttpCode({ statusCode: 400, message: "Missing reference parameter" }); + } + + const payment = await prisma.payment.findFirst({ + where: { externalId: reference }, + select: { + id: true, + bookingId: true, + success: true, + booking: { + select: { + eventType: { + select: { metadata: true }, + }, + userId: true, + }, + }, + }, + }); + + if (!payment?.bookingId) { + throw new HttpCode({ statusCode: 404, message: "Payment not found" }); + } + + // Already processed + if (payment.success) { + res.status(200).json({ status: "success", message: "Payment already confirmed" }); + return; + } + + // Find credential. Fail closed if we have neither a specific credentialId nor a + // concrete booking userId — otherwise a `{ userId: null }` fallback could match a + // credential where userId IS NULL and silently use the wrong row. + const metadata = payment.booking?.eventType?.metadata as Record | null; + const paystackAppData = (metadata?.apps as Record | undefined)?.paystack as + | { credentialId?: number } + | undefined; + + let credentialQuery: { id: number } | { userId: number; appId: "paystack" }; + if (paystackAppData?.credentialId) { + credentialQuery = { id: paystackAppData.credentialId }; + } else if (payment.booking?.userId) { + credentialQuery = { userId: payment.booking.userId, appId: "paystack" }; + } else { + throw new HttpCode({ statusCode: 500, message: "Cannot resolve payment credentials" }); + } + + const credential = await prisma.credential.findFirst({ + where: credentialQuery, + select: { key: true }, + }); + + if (!credential) { + throw new HttpCode({ statusCode: 500, message: "Missing payment credentials" }); + } + + const parsedKeys = appKeysSchema.safeParse(credential.key); + if (!parsedKeys.success) { + throw new HttpCode({ statusCode: 500, message: "Malformed credentials" }); + } + + // Verify with Paystack before claiming the idempotency lock + const client = new PaystackClient(parsedKeys.data.secret_key); + const verification = await client.verifyTransaction(reference); + + if (verification.status !== "success") { + res.status(200).json({ status: verification.status, message: "Payment not yet successful" }); + return; + } + + // Atomic idempotency lock: only one of webhook/verify can flip success false → true. + // Without this, a webhook hitting at the same moment as the client redirect would let + // both paths invoke handlePaymentSuccess and duplicate calendar events, BOOKING_PAID + // webhooks, workflow runs, and confirmation emails. + const claimed = await prisma.payment.updateMany({ + where: { id: payment.id, success: false }, + data: { success: true }, + }); + + if (claimed.count === 0) { + res.status(200).json({ status: "success", message: "Payment already confirmed" }); + return; + } + + // Confirm booking — roll back the idempotency lock on failure so retries can re-process. + // + // handlePaymentSuccess uses an exception as its success signal: on a successful + // confirmation it throws `new HttpCode({ statusCode: 200, message: ... })` rather + // than returning. That means a 200-class HttpCode here is success — we leave + // `payment.success: true` in place and swallow it. Anything else is a genuine + // failure: undo the lock so a retry can re-run the confirmation, then rethrow + // for the outer handler. + try { + const traceContext = distributedTracing.createTrace("paystack_verify", { + meta: { reference, bookingId: payment.bookingId }, + }); + + await handlePaymentSuccess({ + paymentId: payment.id, + bookingId: payment.bookingId, + appSlug: "paystack", + traceContext, + }); + } catch (processingError) { + const isSuccessSentinel = processingError instanceof HttpCode && processingError.statusCode < 400; + if (!isSuccessSentinel) { + await prisma.payment.update({ where: { id: payment.id }, data: { success: false } }); + throw processingError; + } + } + + res.status(200).json({ status: "success", message: "Payment confirmed" }); + } catch (_err) { + const err = getServerErrorFromUnknown(_err); + log.error(`Verify Error: ${err.message}`, safeStringify(err)); + res.status(err.statusCode).json({ + message: err.message, + stack: IS_PRODUCTION ? undefined : err.cause?.stack, + }); + } +} diff --git a/packages/app-store/paystack/api/webhook.ts b/packages/app-store/paystack/api/webhook.ts new file mode 100644 index 00000000000000..c2e32b49c27ffb --- /dev/null +++ b/packages/app-store/paystack/api/webhook.ts @@ -0,0 +1,189 @@ +import { handlePaymentSuccess } from "@calcom/app-store/_utils/payments/handlePaymentSuccess"; +import { IS_PRODUCTION } from "@calcom/lib/constants"; +import { HttpError as HttpCode } from "@calcom/lib/http-error"; +import logger from "@calcom/lib/logger"; +import { safeStringify } from "@calcom/lib/safeStringify"; +import { getServerErrorFromUnknown } from "@calcom/lib/server/getServerErrorFromUnknown"; +import { distributedTracing } from "@calcom/lib/tracing/factory"; +import prisma from "@calcom/prisma"; +import { buffer } from "micro"; +import type { NextApiRequest, NextApiResponse } from "next"; +import { PaystackClient } from "../lib/PaystackClient"; +import { verifyWebhookSignature } from "../lib/verifyWebhookSignature"; +import { appKeysSchema } from "../zod"; + +const log = logger.getSubLogger({ prefix: ["[paystackWebhook]"] }); + +export const config = { + api: { + bodyParser: false, + }, +}; + +export default async function handler(req: NextApiRequest, res: NextApiResponse) { + try { + if (req.method !== "POST") { + throw new HttpCode({ statusCode: 405, message: "Method Not Allowed" }); + } + + const requestBuffer = await buffer(req); + const bodyString = requestBuffer.toString(); + + // Parse body to get the reference (needed to find the credential for signature verification) + let parsedBody: { event: string; data: { reference: string } }; + try { + parsedBody = JSON.parse(bodyString); + } catch { + throw new HttpCode({ statusCode: 400, message: "Invalid JSON body" }); + } + + if (!parsedBody?.data?.reference) { + throw new HttpCode({ statusCode: 400, message: "Missing reference in payload" }); + } + + const reference = parsedBody.data.reference; + + // Look up payment by reference to find the credential + const payment = await prisma.payment.findFirst({ + where: { externalId: reference }, + select: { + id: true, + bookingId: true, + success: true, + booking: { + select: { + eventType: { + select: { + metadata: true, + }, + }, + userId: true, + }, + }, + }, + }); + + if (!payment?.bookingId) { + // Unknown reference (e.g. test events from the Paystack dashboard, or a + // payment that was deleted on our side). Ack with 200 so Paystack stops + // retrying, and skip the noisy error log. + log.warn("Webhook for unknown payment reference; acknowledging", { reference }); + res.status(200).json({ message: "Unknown reference, acknowledged" }); + return; + } + + // Find the credential to verify the signature. Fail closed if we have neither a + // specific credentialId nor a concrete booking userId — otherwise a + // `{ userId: null }` fallback could match a credential where userId IS NULL and + // we'd verify the signature against the wrong secret. + const metadata = payment.booking?.eventType?.metadata as Record | null; + const paystackAppData = (metadata?.apps as Record | undefined)?.paystack as + | { credentialId?: number } + | undefined; + + let credentialQuery: { id: number } | { userId: number; appId: "paystack" }; + if (paystackAppData?.credentialId) { + credentialQuery = { id: paystackAppData.credentialId }; + } else if (payment.booking?.userId) { + credentialQuery = { userId: payment.booking.userId, appId: "paystack" }; + } else { + log.error("Cannot resolve Paystack credentials for webhook"); + throw new HttpCode({ statusCode: 500, message: "Cannot resolve payment credentials" }); + } + + const credential = await prisma.credential.findFirst({ + where: credentialQuery, + select: { key: true }, + }); + + if (!credential) { + log.error("Paystack credentials not found"); + throw new HttpCode({ statusCode: 500, message: "Missing payment credentials" }); + } + + const parsedKeys = appKeysSchema.safeParse(credential.key); + if (!parsedKeys.success) { + throw new HttpCode({ statusCode: 500, message: "Malformed credentials" }); + } + + // Verify webhook signature + const signature = req.headers["x-paystack-signature"] as string | undefined; + if (!signature || !verifyWebhookSignature(bodyString, signature, parsedKeys.data.secret_key)) { + log.error("Invalid Paystack webhook signature"); + throw new HttpCode({ statusCode: 401, message: "Invalid signature" }); + } + + // Only handle charge.success events + if (parsedBody.event !== "charge.success") { + res.status(200).json({ message: `Unhandled event type: ${parsedBody.event}` }); + return; + } + + // Atomic idempotency: only proceed if we can flip success from false to true + const updated = await prisma.payment.updateMany({ + where: { id: payment.id, success: false }, + data: { success: true }, + }); + + if (updated.count === 0) { + // Another request already processed this payment + res.status(200).json({ message: "Payment already processed" }); + return; + } + + // Everything after the lock must rollback on failure so retries can re-process + try { + // Re-verify with Paystack API (belt and suspenders) + const client = new PaystackClient(parsedKeys.data.secret_key); + const verification = await client.verifyTransaction(reference); + + if (verification.status !== "success") { + log.error("Paystack verification failed", { reference, status: verification.status }); + throw new HttpCode({ statusCode: 400, message: "Payment verification failed" }); + } + + // Confirm the booking + const traceContext = distributedTracing.createTrace("paystack_webhook", { + meta: { reference, bookingId: payment.bookingId }, + }); + + await handlePaymentSuccess({ + paymentId: payment.id, + bookingId: payment.bookingId, + appSlug: "paystack", + traceContext, + }); + } catch (processingError) { + // handlePaymentSuccess signals success by throwing HttpCode(200). Treat that as a + // successful confirmation (the booking is already finalized), don't roll back the + // idempotency lock, and don't re-throw — re-throwing would surface the success + // sentinel in the outer catch as a "Webhook Error" log line on every happy path. + const isSuccessSentinel = processingError instanceof HttpCode && processingError.statusCode < 400; + if (isSuccessSentinel) { + // Fall through to the 200 response below. + } else { + await prisma.payment.update({ + where: { id: payment.id }, + data: { success: false }, + }); + throw processingError; + } + } + } catch (_err) { + const err = getServerErrorFromUnknown(_err); + log.error(`Webhook Error: ${err.message}`, safeStringify(err)); + // Avoid sending a body on 204 (RFC 7230 §3.3.3 forbids it). For any other status, + // include the error message so observability tooling can see the failure. + if (err.statusCode === 204) { + res.status(204).end(); + } else { + res.status(err.statusCode).send({ + message: err.message, + stack: IS_PRODUCTION ? undefined : err.cause?.stack, + }); + } + return; + } + + res.status(200).json({ received: true }); +} diff --git a/packages/app-store/paystack/components/EventTypeAppCardInterface.tsx b/packages/app-store/paystack/components/EventTypeAppCardInterface.tsx new file mode 100644 index 00000000000000..1aa66f6c2cd8c5 --- /dev/null +++ b/packages/app-store/paystack/components/EventTypeAppCardInterface.tsx @@ -0,0 +1,59 @@ +import { usePathname, useSearchParams } from "next/navigation"; +import { useState, useMemo } 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 type { appDataSchema } from "../zod"; +import EventTypeAppSettingsInterface from "./EventTypeAppSettingsInterface"; + +const EventTypeAppCard: EventTypeAppCardComponent = function EventTypeAppCard({ + app, + eventType, + eventTypeFormMetadata, + onAppInstallSuccess, +}) { + const searchParams = useSearchParams(); + const pathname = usePathname(); + const asPath = useMemo( + () => `${pathname}${searchParams ? `?${searchParams.toString()}` : ""}`, + [pathname, searchParams] + ); + const { getAppData, setAppData, disabled } = useAppContextWithSchema(); + const [requirePayment, setRequirePayment] = useState(getAppData("enabled")); + const otherPaymentAppEnabled = checkForMultiplePaymentApps(eventTypeFormMetadata); + const { t } = useLocale(); + + const shouldDisableSwitch = !requirePayment && otherPaymentAppEnabled; + + return ( + { + setRequirePayment(enabled); + setAppData("enabled", enabled); + }} + description={<>{t("paystack_app_description")}} + disableSwitch={shouldDisableSwitch} + switchTooltip={shouldDisableSwitch ? t("other_payment_app_enabled") : undefined}> + <> + + + + ); +}; + +export default EventTypeAppCard; diff --git a/packages/app-store/paystack/components/EventTypeAppSettingsInterface.tsx b/packages/app-store/paystack/components/EventTypeAppSettingsInterface.tsx new file mode 100644 index 00000000000000..d7b85f504c03ce --- /dev/null +++ b/packages/app-store/paystack/components/EventTypeAppSettingsInterface.tsx @@ -0,0 +1,170 @@ +import * as RadioGroup from "@radix-ui/react-radio-group"; +import { useState, useEffect } from "react"; + +import type { EventTypeAppSettingsComponent } from "@calcom/app-store/types"; +import { + convertToSmallestCurrencyUnit, + convertFromSmallestToPresentableCurrencyUnit, +} from "@calcom/lib/currencyConversions"; +import { useLocale } from "@calcom/lib/hooks/useLocale"; +import { RefundPolicy } from "@calcom/lib/payment/types"; +import classNames from "@calcom/ui/classNames"; +import { Alert } from "@calcom/ui/components/alert"; +import { Select } from "@calcom/ui/components/form"; +import { TextField } from "@calcom/ui/components/form"; +import { RadioField } from "@calcom/ui/components/radio"; + +import { currencyOptions, currencySymbols, isAcceptedCurrencyCode } from "../lib/currencyOptions"; + +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 requirePayment = getAppData("enabled"); + + const { t } = useLocale(); + const recurringEventDefined = eventType.recurringEvent?.count !== undefined; + + const getCurrencySymbol = (curr: string) => + isAcceptedCurrencyCode(curr) ? currencySymbols[curr] : ""; + + useEffect(() => { + if (requirePayment) { + if (!getAppData("currency")) { + setAppData("currency", currencyOptions[0].value); + } + if (!getAppData("paymentOption")) { + setAppData("paymentOption", "ON_BOOKING"); + } + } + if (!getAppData("refundPolicy")) { + setAppData("refundPolicy", RefundPolicy.NEVER); + } + }, [requirePayment, getAppData, setAppData]); + + const dayTypeOptions = [ + { value: 0, label: t("business_days") }, + { value: 1, label: t("calendar_days") }, + ]; + + const getSelectedDayType = () => + dayTypeOptions.find((opt) => opt.value === (getAppData("refundCountCalendarDays") === true ? 1 : 0)); + + return ( + <> + {recurringEventDefined && ( + + )} + {!recurringEventDefined && requirePayment && ( + <> +
+ {getCurrencySymbol(selectedCurrency.value)}} + addOnSuffix={currency.toUpperCase()} + addOnClassname="h-[38px]" + step="0.01" + min="0.5" + type="number" + required + placeholder="Price" + disabled={disabled} + onChange={(e) => { + setAppData("price", convertToSmallestCurrencyUnit(Number(e.target.value), currency)); + }} + value={price > 0 ? convertFromSmallestToPresentableCurrencyUnit(price, currency) : undefined} + /> +
+
+ + setAppData("refundCountCalendarDays", option?.value === 1)} + value={getSelectedDayType()} + defaultValue={getSelectedDayType()} + /> +  {t("before")} +
+
+ + + + )} + + ); +}; + +export default EventTypeAppSettingsInterface; diff --git a/packages/app-store/paystack/components/PaystackPaymentComponent.tsx b/packages/app-store/paystack/components/PaystackPaymentComponent.tsx new file mode 100644 index 00000000000000..6ef3381392584d --- /dev/null +++ b/packages/app-store/paystack/components/PaystackPaymentComponent.tsx @@ -0,0 +1,116 @@ +"use client"; + +import { convertFromSmallestToPresentableCurrencyUnit } from "@calcom/lib/currencyConversions"; +import { useLocale } from "@calcom/lib/hooks/useLocale"; +import { Button } from "@calcom/ui/components/button"; +import { useState } from "react"; + +interface PaystackPaymentData { + access_code: string; + authorization_url: string; + publicKey: string; + reference: string; +} + +interface PaystackPaymentComponentProps { + payment: { data: PaystackPaymentData }; + bookingUid: string; + bookingTitle: string; + amount: number; + currency: string; +} + +export default function PaystackPaymentComponent({ + payment, + bookingUid, + bookingTitle, + amount, + currency, +}: PaystackPaymentComponentProps) { + const [status, setStatus] = useState<"idle" | "loading" | "success" | "error">("idle"); + const [errorMessage, setErrorMessage] = useState(""); + const { t, i18n } = useLocale(); + + const paymentData = payment.data; + + const presentableAmount = convertFromSmallestToPresentableCurrencyUnit(amount, currency); + let formattedAmount: string; + try { + formattedAmount = new Intl.NumberFormat(i18n.language || "en", { + style: "currency", + currency: currency.toUpperCase(), + }).format(presentableAmount); + } catch { + // Intl.NumberFormat throws RangeError for unknown ISO codes. Stale event-type metadata + // could persist a currency that's no longer in our list — fall back to a plain number. + formattedAmount = `${presentableAmount} ${currency.toUpperCase()}`; + } + + const handlePayment = async () => { + setStatus("loading"); + setErrorMessage(""); + + try { + const PaystackPop = (await import("@paystack/inline-js")).default; + const popup = new PaystackPop(); + + popup.resumeTransaction(paymentData.access_code, { + onSuccess: () => { + setStatus("success"); + + // Backup verification — fire-and-forget so a slow/hung endpoint can't block + // the redirect. The webhook is the source of truth; this just nudges + // server-side reconciliation along. + const params = new URLSearchParams({ reference: paymentData.reference }); + fetch(`/api/integrations/paystack/verify?${params.toString()}`).catch(() => { + /* webhook will handle it */ + }); + + // replace() keeps the payment step out of browser history + setTimeout(() => { + window.location.replace(`/booking/${bookingUid}`); + }, 2000); + }, + onCancel: () => { + setStatus("idle"); + }, + onError: () => { + setStatus("error"); + setErrorMessage(t("payment_failed_try_again")); + }, + }); + } catch { + setStatus("error"); + setErrorMessage(t("payment_failed_try_again")); + } + }; + + if (status === "success") { + return ( +
+
{t("payment_successful")}
+

{t("redirecting_to_booking_confirmation")}

+
+ ); + } + + return ( +
+

{bookingTitle}

+

{formattedAmount}

+ + {errorMessage &&

{errorMessage}

} + + + + {status === "idle" &&

{t("paystack_payment_prompt")}

} +
+ ); +} diff --git a/packages/app-store/paystack/config.json b/packages/app-store/paystack/config.json new file mode 100644 index 00000000000000..a0428d445850c0 --- /dev/null +++ b/packages/app-store/paystack/config.json @@ -0,0 +1,20 @@ +{ + "/*": "Don't modify slug - If required, do it using cli edit command", + "name": "Paystack", + "slug": "paystack", + "type": "paystack_payment", + "logo": "icon.svg", + "url": "https://paystack.com", + "variant": "payment", + "categories": ["payment"], + "publisher": "Cal.com", + "email": "support@cal.com", + "description": "Accept payments via Paystack for your Cal.com bookings", + "extendsFeature": "EventType", + "isTemplate": false, + "__createdUsingCli": true, + "imageSrc": "icon.svg", + "__template": "event-type-app-card", + "dirName": "paystack", + "isOAuth": true +} diff --git a/packages/app-store/paystack/index.ts b/packages/app-store/paystack/index.ts new file mode 100644 index 00000000000000..e2e9d7b029c031 --- /dev/null +++ b/packages/app-store/paystack/index.ts @@ -0,0 +1,2 @@ +export * as api from "./api"; +export * as lib from "./lib"; diff --git a/packages/app-store/paystack/lib/PaymentService.ts b/packages/app-store/paystack/lib/PaymentService.ts new file mode 100644 index 00000000000000..51091e0b683de9 --- /dev/null +++ b/packages/app-store/paystack/lib/PaymentService.ts @@ -0,0 +1,216 @@ +import { v4 as uuidv4 } from "uuid"; + +import { WEBAPP_URL } from "@calcom/lib/constants"; +import { ErrorCode } from "@calcom/lib/errorCodes"; +import { ErrorWithCode } from "@calcom/lib/errors"; +import logger from "@calcom/lib/logger"; +import { safeStringify } from "@calcom/lib/safeStringify"; +import prisma from "@calcom/prisma"; +import type { Booking, Payment, Prisma, PaymentOption } from "@calcom/prisma/client"; +import type { CalendarEvent } from "@calcom/types/Calendar"; +import type { IAbstractPaymentService } from "@calcom/types/PaymentService"; + +import { appKeysSchema } from "../zod"; +import { PaystackClient } from "./PaystackClient"; + +const log = logger.getSubLogger({ prefix: ["[paystackPaymentService]"] }); + +class PaystackPaymentService implements IAbstractPaymentService { + private client: PaystackClient | null; + private credentials: { public_key: string; secret_key: string } | null; + + constructor(credentials: { key: Prisma.JsonValue }) { + const parsed = appKeysSchema.safeParse(credentials.key); + if (parsed.success) { + this.credentials = parsed.data; + this.client = new PaystackClient(parsed.data.secret_key); + } else { + this.credentials = null; + this.client = null; + } + } + + async create( + payment: Pick, + bookingId: Booking["id"], + _userId: Booking["userId"], + _username: string | null, + _bookerName: string | null, + _paymentOption: PaymentOption, + bookerEmail: string, + _bookerPhoneNumber?: string | null, + eventTitle?: string, + _bookingTitle?: string + ): Promise { + const booking = await prisma.booking.findUnique({ + select: { uid: true, title: true }, + where: { id: bookingId }, + }); + + if (!booking) { + throw new ErrorWithCode(ErrorCode.BookingNotFound, "Booking not found"); + } + + if (!this.client || !this.credentials) { + throw new ErrorWithCode(ErrorCode.MissingPaymentCredential, "Paystack credentials not configured"); + } + + const uid = uuidv4(); + const reference = `cal_${bookingId}_${uid.replace(/-/g, "")}`; + + const paystackResponse = await this.client.initializeTransaction({ + email: bookerEmail, + amount: payment.amount, + currency: payment.currency.toUpperCase(), + reference, + callback_url: `${WEBAPP_URL}/api/integrations/paystack/verify`, + metadata: { + bookingId, + eventTitle: eventTitle || booking.title, + }, + }); + + const paymentData = await prisma.payment.create({ + data: { + uid, + app: { + connect: { + slug: "paystack", + }, + }, + booking: { + connect: { + id: bookingId, + }, + }, + amount: payment.amount, + externalId: reference, + currency: payment.currency, + data: { + access_code: paystackResponse.access_code, + authorization_url: paystackResponse.authorization_url, + publicKey: this.credentials.public_key, + reference, + } satisfies Prisma.InputJsonObject, + fee: 0, + refunded: false, + success: false, + paymentOption: "ON_BOOKING", + }, + }); + + return paymentData; + } + + async collectCard( + _payment: Pick, + _bookingId: Booking["id"], + _paymentOption: PaymentOption, + _bookerEmail: string, + _bookerPhoneNumber?: string | null + ): Promise { + throw new ErrorWithCode( + ErrorCode.BadRequest, + "Paystack does not support card hold. Only ON_BOOKING payment is available." + ); + } + + async chargeCard( + _payment: Pick, + _bookingId?: Booking["id"] + ): Promise { + throw new ErrorWithCode( + ErrorCode.BadRequest, + "Paystack does not support card hold. Only ON_BOOKING payment is available." + ); + } + + async update( + paymentId: Payment["id"], + data: Partial + ): Promise { + return await prisma.payment.update({ + where: { id: paymentId }, + data, + }); + } + + async refund(paymentId: Payment["id"]): Promise { + const payment = await prisma.payment.findUnique({ where: { id: paymentId } }); + + if (!payment) { + return null; + } + + if (payment.refunded) { + return payment; + } + + if (!payment.success) { + throw new ErrorWithCode( + ErrorCode.BadRequest, + "Cannot refund a Paystack payment that did not succeed" + ); + } + + if (!this.client) { + throw new ErrorWithCode(ErrorCode.MissingPaymentCredential, "Paystack credentials not configured"); + } + + await this.client.createRefund({ + transaction: payment.externalId, + }); + + return await prisma.payment.update({ + where: { id: paymentId }, + data: { refunded: true }, + }); + } + + async getPaymentPaidStatus(): Promise { + return "paid"; + } + + async getPaymentDetails(): Promise { + throw new ErrorWithCode(ErrorCode.InternalServerError, "Method not implemented."); + } + + async afterPayment( + _event: CalendarEvent, + _booking: { + user: { email: string | null; name: string | null; timeZone: string } | null; + id: number; + startTime: { toISOString: () => string }; + uid: string; + }, + _paymentData: Payment + ): Promise { + // No post-payment actions needed for Paystack + return Promise.resolve(); + } + + async deletePayment(paymentId: Payment["id"]): Promise { + try { + await prisma.payment.delete({ + where: { id: paymentId }, + }); + return true; + } catch (error) { + log.error(`deletePayment failed for payment ${paymentId}`, safeStringify(error)); + return false; + } + } + + isSetupAlready(): boolean { + return !!(this.credentials?.public_key && this.credentials?.secret_key); + } +} + +/** + * Factory function that creates a Paystack Payment service instance. + * Exported instead of the class to prevent internal types from leaking + * into the emitted .d.ts file. + */ +export function BuildPaymentService(credentials: { key: Prisma.JsonValue }): IAbstractPaymentService { + return new PaystackPaymentService(credentials); +} diff --git a/packages/app-store/paystack/lib/PaystackClient.ts b/packages/app-store/paystack/lib/PaystackClient.ts new file mode 100644 index 00000000000000..126c0298896c14 --- /dev/null +++ b/packages/app-store/paystack/lib/PaystackClient.ts @@ -0,0 +1,109 @@ +export const PAYSTACK_BASE_URL = "https://api.paystack.co"; + +const REQUEST_TIMEOUT_MS = 15_000; + +type PaystackEnvelope = { + status?: boolean; + message?: string; + data?: T; +}; + +export class PaystackClient { + private secretKey: string; + + constructor(secretKey: string) { + this.secretKey = secretKey; + } + + private async request( + path: string, + init: { method: "GET" | "POST"; body?: unknown } + ): Promise> { + const headers: Record = { + Authorization: `Bearer ${this.secretKey}`, + }; + if (init.body !== undefined) { + headers["Content-Type"] = "application/json"; + } + + let response: Response; + try { + response = await fetch(`${PAYSTACK_BASE_URL}${path}`, { + method: init.method, + headers, + body: init.body !== undefined ? JSON.stringify(init.body) : undefined, + signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), + }); + } catch (err) { + const reason = err instanceof Error ? err.message : "network error"; + throw new Error(`Paystack API error: ${reason}`); + } + + let json: PaystackEnvelope | undefined; + try { + json = (await response.json()) as PaystackEnvelope; + } catch { + json = undefined; + } + + if (!response.ok || !json?.status) { + const message = json?.message || `HTTP ${response.status} ${response.statusText}`.trim(); + throw new Error(`Paystack API error: ${message}`); + } + + return json; + } + + async initializeTransaction(params: { + email: string; + amount: number; + currency: string; + reference: string; + callback_url: string; + metadata?: Record; + }): Promise<{ + authorization_url: string; + access_code: string; + reference: string; + }> { + const json = await this.request<{ + authorization_url: string; + access_code: string; + reference: string; + }>("/transaction/initialize", { method: "POST", body: params }); + + if (!json.data) { + throw new Error("Paystack API error: missing transaction data"); + } + return json.data; + } + + async verifyTransaction(reference: string): Promise<{ + status: string; + amount: number; + currency: string; + reference: string; + paid_at: string | null; + }> { + const json = await this.request<{ + status: string; + amount: number; + currency: string; + reference: string; + paid_at: string | null; + }>(`/transaction/verify/${reference}`, { method: "GET" }); + + if (!json.data) { + throw new Error("Paystack API error: missing verification data"); + } + return json.data; + } + + async createRefund(params: { + transaction: string; + amount?: number; + }): Promise<{ status: boolean; data: unknown }> { + const json = await this.request("/refund", { method: "POST", body: params }); + return { status: !!json.status, data: json.data }; + } +} diff --git a/packages/app-store/paystack/lib/__tests__/PaystackClient.test.ts b/packages/app-store/paystack/lib/__tests__/PaystackClient.test.ts new file mode 100644 index 00000000000000..9235d8ac9e2ade --- /dev/null +++ b/packages/app-store/paystack/lib/__tests__/PaystackClient.test.ts @@ -0,0 +1,171 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { PAYSTACK_BASE_URL, PaystackClient } from "../PaystackClient"; + +const CALLBACK_URL = "https://example.com/payment/callback"; + +describe("PaystackClient", () => { + let client: PaystackClient; + + beforeEach(() => { + client = new PaystackClient("sk_test_xxxxx"); + }); + + describe("initializeTransaction", () => { + it("sends correct params and returns parsed response", async () => { + const mockResponse = { + status: true, + message: "Authorization URL created", + data: { + authorization_url: "https://checkout.paystack.com/abc123", + access_code: "abc123", + reference: "cal_42_ref123", + }, + }; + + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue({ + ok: true, + json: () => Promise.resolve(mockResponse), + }) + ); + + const result = await client.initializeTransaction({ + email: "test@example.com", + amount: 500000, + currency: "NGN", + reference: "cal_42_ref123", + callback_url: CALLBACK_URL, + metadata: { bookingId: 42 }, + }); + + expect(fetch).toHaveBeenCalledWith( + `${PAYSTACK_BASE_URL}/transaction/initialize`, + expect.objectContaining({ + method: "POST", + headers: { + Authorization: "Bearer sk_test_xxxxx", + "Content-Type": "application/json", + }, + body: JSON.stringify({ + email: "test@example.com", + amount: 500000, + currency: "NGN", + reference: "cal_42_ref123", + callback_url: CALLBACK_URL, + metadata: { bookingId: 42 }, + }), + signal: expect.any(AbortSignal), + }) + ); + + expect(result).toEqual({ + authorization_url: "https://checkout.paystack.com/abc123", + access_code: "abc123", + reference: "cal_42_ref123", + }); + }); + + it("throws on API error response", async () => { + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue({ + ok: false, + status: 400, + json: () => Promise.resolve({ status: false, message: "Invalid amount" }), + }) + ); + + await expect( + client.initializeTransaction({ + email: "test@example.com", + amount: 0, + currency: "NGN", + reference: "cal_42_ref123", + callback_url: CALLBACK_URL, + }) + ).rejects.toThrow("Paystack API error: Invalid amount"); + }); + }); + + describe("verifyTransaction", () => { + it("returns parsed verification result", async () => { + const mockResponse = { + status: true, + data: { + status: "success", + amount: 500000, + currency: "NGN", + reference: "cal_42_ref123", + paid_at: "2026-04-04T12:00:00.000Z", + }, + }; + + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue({ + ok: true, + json: () => Promise.resolve(mockResponse), + }) + ); + + const result = await client.verifyTransaction("cal_42_ref123"); + + expect(fetch).toHaveBeenCalledWith( + `${PAYSTACK_BASE_URL}/transaction/verify/cal_42_ref123`, + expect.objectContaining({ + method: "GET", + headers: { + Authorization: "Bearer sk_test_xxxxx", + }, + signal: expect.any(AbortSignal), + }) + ); + + expect(result).toEqual({ + status: "success", + amount: 500000, + currency: "NGN", + reference: "cal_42_ref123", + paid_at: "2026-04-04T12:00:00.000Z", + }); + }); + }); + + describe("createRefund", () => { + it("sends refund request with transaction reference", async () => { + const mockResponse = { + status: true, + data: { + status: "pending", + transaction: { reference: "cal_42_ref123" }, + }, + }; + + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue({ + ok: true, + json: () => Promise.resolve(mockResponse), + }) + ); + + const result = await client.createRefund({ transaction: "cal_42_ref123" }); + + expect(fetch).toHaveBeenCalledWith( + `${PAYSTACK_BASE_URL}/refund`, + expect.objectContaining({ + method: "POST", + headers: { + Authorization: "Bearer sk_test_xxxxx", + "Content-Type": "application/json", + }, + body: JSON.stringify({ transaction: "cal_42_ref123" }), + signal: expect.any(AbortSignal), + }) + ); + + expect(result.status).toBe(true); + }); + }); +}); diff --git a/packages/app-store/paystack/lib/__tests__/verifyWebhookSignature.test.ts b/packages/app-store/paystack/lib/__tests__/verifyWebhookSignature.test.ts new file mode 100644 index 00000000000000..1936684e7cf83e --- /dev/null +++ b/packages/app-store/paystack/lib/__tests__/verifyWebhookSignature.test.ts @@ -0,0 +1,28 @@ +import crypto from "crypto"; + +import { describe, it, expect } from "vitest"; + +import { verifyWebhookSignature } from "../verifyWebhookSignature"; + +describe("verifyWebhookSignature", () => { + const secretKey = "sk_test_secretkey123"; + const body = JSON.stringify({ event: "charge.success", data: { reference: "ref123" } }); + const validSignature = crypto.createHmac("sha512", secretKey).update(body).digest("hex"); + + it("returns true for valid signature", () => { + expect(verifyWebhookSignature(body, validSignature, secretKey)).toBe(true); + }); + + it("returns false for tampered body", () => { + const tamperedBody = JSON.stringify({ event: "charge.success", data: { reference: "TAMPERED" } }); + expect(verifyWebhookSignature(tamperedBody, validSignature, secretKey)).toBe(false); + }); + + it("returns false for wrong secret key", () => { + expect(verifyWebhookSignature(body, validSignature, "wrong_secret")).toBe(false); + }); + + it("returns false for empty signature", () => { + expect(verifyWebhookSignature(body, "", secretKey)).toBe(false); + }); +}); diff --git a/packages/app-store/paystack/lib/currencyOptions.ts b/packages/app-store/paystack/lib/currencyOptions.ts new file mode 100644 index 00000000000000..02825261a7f23e --- /dev/null +++ b/packages/app-store/paystack/lib/currencyOptions.ts @@ -0,0 +1,19 @@ +export const currencyOptions = [ + { label: "Nigerian naira (NGN)", value: "ngn" }, + { label: "Ghanaian cedi (GHS)", value: "ghs" }, + { label: "South African rand (ZAR)", value: "zar" }, + { label: "Kenyan shilling (KES)", value: "kes" }, + { label: "United States dollar (USD)", value: "usd" }, +]; + +export const currencySymbols: Record = { + ngn: "₦", + ghs: "GH₵", + zar: "R", + kes: "KSh", + usd: "$", +}; + +export const isAcceptedCurrencyCode = (code: string): code is keyof typeof currencySymbols => { + return code in currencySymbols; +}; diff --git a/packages/app-store/paystack/lib/verifyWebhookSignature.ts b/packages/app-store/paystack/lib/verifyWebhookSignature.ts new file mode 100644 index 00000000000000..8c3d24043f2e49 --- /dev/null +++ b/packages/app-store/paystack/lib/verifyWebhookSignature.ts @@ -0,0 +1,13 @@ +import crypto from "crypto"; + +export function verifyWebhookSignature(body: string, signature: string, secretKey: string): boolean { + if (!signature) return false; + + const hash = crypto.createHmac("sha512", secretKey).update(body).digest("hex"); + + try { + return crypto.timingSafeEqual(Buffer.from(hash), Buffer.from(signature)); + } catch { + return false; + } +} diff --git a/packages/app-store/paystack/package.json b/packages/app-store/paystack/package.json new file mode 100644 index 00000000000000..5e47eaee351ae7 --- /dev/null +++ b/packages/app-store/paystack/package.json @@ -0,0 +1,22 @@ +{ + "$schema": "https://json.schemastore.org/package.json", + "private": true, + "name": "@calcom/paystack", + "version": "0.0.0", + "main": "./index.ts", + "scripts": { + "test": "TZ=UTC vitest run" + }, + "dependencies": { + "@calcom/lib": "workspace:*", + "@calcom/prisma": "workspace:*", + "@paystack/inline-js": "^2.0.0", + "uuid": "^9.0.0" + }, + "devDependencies": { + "@calcom/types": "workspace:*", + "@types/paystack__inline-js": "^1.0.2", + "@types/uuid": "^9.0.0" + }, + "description": "Paystack payment integration for Cal.com" +} diff --git a/packages/app-store/paystack/pages/setup/_getServerSideProps.ts b/packages/app-store/paystack/pages/setup/_getServerSideProps.ts new file mode 100644 index 00000000000000..445404b06d78a7 --- /dev/null +++ b/packages/app-store/paystack/pages/setup/_getServerSideProps.ts @@ -0,0 +1,44 @@ +import type { GetServerSidePropsContext } from "next"; + +import { throwIfNotHaveAdminAccessToTeam } from "@calcom/app-store/_utils/throwIfNotHaveAdminAccessToTeam"; +import { getServerSession } from "@calcom/features/auth/lib/getServerSession"; +import prisma from "@calcom/prisma"; + +export const getServerSideProps = async (ctx: GetServerSidePropsContext) => { + const notFound = { notFound: true } as const; + + if (typeof ctx.params?.slug !== "string") return notFound; + + const { req, query } = ctx; + const session = await getServerSession({ req }); + + if (!session?.user?.id) { + return { redirect: { permanent: false, destination: "/auth/login" } } as const; + } + + const rawTeamId = Array.isArray(query.teamId) ? query.teamId[0] : query.teamId; + const teamId = rawTeamId === undefined ? null : Number(rawTeamId); + + if (teamId !== null && !Number.isInteger(teamId)) { + return notFound; + } + + await throwIfNotHaveAdminAccessToTeam({ teamId, userId: session.user.id }); + const installForObject = teamId !== null ? { teamId } : { userId: session.user.id }; + + const credential = await prisma.credential.findFirst({ + where: { + type: "paystack_payment", + ...installForObject, + }, + select: { + id: true, + }, + }); + + return { + props: { + credentialId: credential?.id ?? null, + }, + }; +}; diff --git a/packages/app-store/paystack/static/icon.svg b/packages/app-store/paystack/static/icon.svg new file mode 100644 index 00000000000000..b1556dad70f44c --- /dev/null +++ b/packages/app-store/paystack/static/icon.svg @@ -0,0 +1 @@ + diff --git a/packages/app-store/paystack/zod.ts b/packages/app-store/paystack/zod.ts new file mode 100644 index 00000000000000..aa4bca386fc15e --- /dev/null +++ b/packages/app-store/paystack/zod.ts @@ -0,0 +1,20 @@ +import { z } from "zod"; + +import { eventTypeAppCardZod } from "@calcom/app-store/eventTypeAppCardZod"; +import { RefundPolicy } from "@calcom/lib/payment/types"; + +export const appDataSchema = eventTypeAppCardZod.merge( + z.object({ + price: z.number(), + currency: z.string(), + paymentOption: z.literal("ON_BOOKING").optional(), + refundPolicy: z.nativeEnum(RefundPolicy).optional(), + refundDaysCount: z.number().optional(), + refundCountCalendarDays: z.boolean().optional(), + }) +); + +export const appKeysSchema = z.object({ + public_key: z.string().min(1), + secret_key: z.string().min(1), +}); diff --git a/packages/i18n/locales/en/common.json b/packages/i18n/locales/en/common.json index ce7460a76d96e3..e02fd4318208a9 100644 --- a/packages/i18n/locales/en/common.json +++ b/packages/i18n/locales/en/common.json @@ -3735,6 +3735,17 @@ "resume_onboarding": "Resume onboarding", "resume_onboarding_description": "Continue with your organization onboarding process", "payment_successful": "Payment successful!", + "payment_failed_try_again": "Payment failed. Please try again.", + "redirecting_to_booking_confirmation": "Redirecting to your booking confirmation...", + "pay_with_paystack": "Pay with Paystack", + "paystack_payment_prompt": "You will be prompted to enter your payment details", + "paystack_app_description": "Accept payments via Paystack for your events", + "paystack_public_key": "Public Key", + "paystack_secret_key": "Secret Key", + "paystack_getting_started_description": "To use Paystack with {{appName}}, you need a Paystack account. Get your API keys from your", + "paystack_dashboard": "Paystack Dashboard", + "paystack_webhook_setup": "Webhook Setup", + "paystack_webhook_setup_description": "Add this webhook URL in your Paystack dashboard under Settings > API Keys & Webhooks:", "handover_onboarding_page_title": "Handover onboarding", "handover_onboarding_page_description": "Handover onboarding to the user", "organization_already_exists_with_this_slug": "Organization already exists with this slug", diff --git a/scripts/seed-app-store.ts b/scripts/seed-app-store.ts index 45b1f36ed95b43..26d581647b59ef 100644 --- a/scripts/seed-app-store.ts +++ b/scripts/seed-app-store.ts @@ -235,6 +235,9 @@ export default async function main() { }); } + // Paystack doesn't require env vars at seed time — keys are entered via Setup UI + await createApp("paystack", "paystack", ["payment"], "paystack_payment"); + if (process.env.CLOSECOM_CLIENT_ID && process.env.CLOSECOM_CLIENT_SECRET) { await createApp("closecom", "closecom", ["crm"], "closecom_crm", { client_id: process.env.CLOSECOM_CLIENT_ID, diff --git a/yarn.lock b/yarn.lock index a0da7e4f615f73..390ac957809491 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2845,6 +2845,20 @@ __metadata: languageName: unknown linkType: soft +"@calcom/paystack@workspace:packages/app-store/paystack": + version: 0.0.0-use.local + resolution: "@calcom/paystack@workspace:packages/app-store/paystack" + dependencies: + "@calcom/lib": "workspace:*" + "@calcom/prisma": "workspace:*" + "@calcom/types": "workspace:*" + "@paystack/inline-js": "npm:^2.0.0" + "@types/paystack__inline-js": "npm:^1.0.2" + "@types/uuid": "npm:^9.0.0" + uuid: "npm:^9.0.0" + languageName: unknown + linkType: soft + "@calcom/ping@workspace:packages/app-store/ping": version: 0.0.0-use.local resolution: "@calcom/ping@workspace:packages/app-store/ping" @@ -10592,6 +10606,13 @@ __metadata: languageName: node linkType: hard +"@paystack/inline-js@npm:^2.0.0": + version: 2.22.8 + resolution: "@paystack/inline-js@npm:2.22.8" + checksum: 10/925a9e1d5d90fcde4d8316493afef2c988d5d8f503bc5c439669aa345348cf383fa083c3e2c4f83ecad45d0bd943977e050464ce85042f06a2117da02fa13cdd + languageName: node + linkType: hard + "@pkgjs/parseargs@npm:^0.11.0": version: 0.11.0 resolution: "@pkgjs/parseargs@npm:0.11.0" @@ -16808,6 +16829,13 @@ __metadata: languageName: node linkType: hard +"@types/paystack__inline-js@npm:^1.0.2": + version: 1.0.2 + resolution: "@types/paystack__inline-js@npm:1.0.2" + checksum: 10/d70d701567f6c358603bf5c5185997d25ea908df44a0ddbf5b6d6fdede666301e46077ad58e123fc257ae96be5fb6a22c6c95c677cb4b8a74816b3d9ef501909 + languageName: node + linkType: hard + "@types/pg-pool@npm:2.0.6": version: 2.0.6 resolution: "@types/pg-pool@npm:2.0.6" @@ -17137,7 +17165,7 @@ __metadata: languageName: node linkType: hard -"@types/uuid@npm:^9.0.1": +"@types/uuid@npm:^9.0.0, @types/uuid@npm:^9.0.1": version: 9.0.8 resolution: "@types/uuid@npm:9.0.8" checksum: 10/b8c60b7ba8250356b5088302583d1704a4e1a13558d143c549c408bf8920535602ffc12394ede77f8a8083511b023704bc66d1345792714002bfa261b17c5275