Skip to content
Open
Show file tree
Hide file tree
Changes from 18 commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
cf5507f
feat: add Paystack payment integration
MarvelNwachukwu Apr 4, 2026
acacbb9
fix: address code review issues in Paystack integration
MarvelNwachukwu Apr 4, 2026
72e0641
fix: rollback payment lock on any post-lock failure in webhook
MarvelNwachukwu Apr 4, 2026
3409a1f
Merge branch 'main' into feat/paystack-upstream
MarvelNwachukwu Apr 4, 2026
a24f8bb
Merge branch 'main' into feat/paystack-upstream
MarvelNwachukwu Apr 15, 2026
441e7e0
fix(paystack): address review feedback
MarvelNwachukwu Apr 23, 2026
ba3b9d7
fix(paystack): drop credential.key from setup props and validate teamId
MarvelNwachukwu May 10, 2026
08aad53
fix(paystack): make credential install atomic and drop string-sentinel
MarvelNwachukwu May 10, 2026
a5e9baa
fix(paystack): show AppNotInstalledMessage when credentialId is missing
MarvelNwachukwu May 10, 2026
d4030cb
fix(paystack): add fetch timeouts and check response.ok before parsing
MarvelNwachukwu May 10, 2026
d609985
fix(paystack): atomic idempotency lock in verify; validate reference
MarvelNwachukwu May 10, 2026
e34ff5f
fix(paystack): silence success sentinel in outer catch; ack unknown refs
MarvelNwachukwu May 10, 2026
c9f5b01
fix(paystack): log deletePayment failures; guard Intl.NumberFormat
MarvelNwachukwu May 10, 2026
3695889
fix(paystack): drop manual edits to autogenerated app-store files
MarvelNwachukwu May 10, 2026
7aeec84
chore(paystack): pick up routing-forms entry from app-store generator
MarvelNwachukwu May 10, 2026
fd2ca9a
chore: skip biome on .d.ts files in lint-staged
MarvelNwachukwu May 10, 2026
ed2e7ee
fix(paystack): add ambient declaration for @paystack/inline-js
MarvelNwachukwu May 10, 2026
cf7ce0b
Merge remote-tracking branch 'upstream/main' into feat/paystack-upstream
MarvelNwachukwu May 10, 2026
6f9c4c5
fix(paystack): drop role="presentation" from setup inputs
MarvelNwachukwu May 11, 2026
68411fe
fix(paystack): migrate PaymentService to ErrorWithCode
MarvelNwachukwu May 11, 2026
822f86d
fix(paystack): simplify PaymentService and tighten typing
MarvelNwachukwu May 11, 2026
b4df242
fix(paystack): validate payment.data via zod wrapper; drop unsafe cast
MarvelNwachukwu May 11, 2026
688a6e2
fix(paystack): encode reference param when calling verify endpoint
MarvelNwachukwu May 11, 2026
6fc211c
fix(paystack): tighten teamId parsing; templatize brand in i18n string
MarvelNwachukwu May 11, 2026
4a6db23
chore(paystack): swap custom inline-js.d.ts for @types/paystack__inli…
MarvelNwachukwu May 11, 2026
4f3ed11
docs(paystack): expand HttpCode(200) success-sentinel rationale in ve…
MarvelNwachukwu May 11, 2026
b2caa6a
fix(paystack): fail closed when credential identifier is missing
MarvelNwachukwu May 11, 2026
a6217d2
fix(paystack): fire-and-forget verify call so a stalled endpoint can'…
MarvelNwachukwu May 11, 2026
bd25254
chore(paystack): format currency in the viewer's locale
MarvelNwachukwu May 11, 2026
3931c84
Merge branch 'main' into feat/paystack-upstream
MarvelNwachukwu May 27, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions apps/web/app/(use-page-wrapper)/payment/[uid]/PaymentPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,13 @@ const BtcpayPaymentComponent = dynamic(
}
);

const PaystackPaymentComponent = dynamic(
() => import("@calcom/app-store/paystack/components/PaystackPaymentComponent"),
{
ssr: false,
}
);

const PaymentPage: FC<PaymentPageProps> = (props) => {
const { t, i18n } = useLocale();
const [is24h, setIs24h] = useState(isBrowserLocale24h());
Expand Down Expand Up @@ -171,6 +178,18 @@ const PaymentPage: FC<PaymentPageProps> = (props) => {
{props.payment.appId === "btcpayserver" && !props.payment.success && (
<BtcpayPaymentComponent payment={props.payment} paymentPageProps={props} />
)}
{props.payment.appId === "paystack" && !props.payment.success && (
<PaystackPaymentComponent
payment={props.payment}
clientId={
(props.payment.data as unknown as { publicKey: string }).publicKey ?? ""
}
bookingUid={props.booking.uid}
bookingTitle={eventName}
amount={props.payment.amount}
currency={props.payment.currency}
/>
)}
{props.payment.refunded && (
<div className="mt-4 text-center text-default dark:text-gray-300">{t("refunded")}</div>
)}
Expand Down
1 change: 1 addition & 0 deletions apps/web/components/apps/AppSetupPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 }) => {
Expand Down
134 changes: 134 additions & 0 deletions apps/web/components/apps/paystack/Setup.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
import { useRouter } from "next/navigation";
import { useState } from "react";
import { Toaster } from "sonner";

import AppNotInstalledMessage from "@calcom/app-store/_components/AppNotInstalledMessage";
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 <div className="absolute z-50 flex h-screen w-full items-center bg-gray-200" />;
}

return (
<div className="bg-default flex h-screen">
{showContent ? (
<div className="bg-default border-subtle m-auto max-w-[43em] overflow-auto rounded border pb-10 md:p-10">
<div className="ml-2 ltr:mr-2 rtl:ml-2 md:ml-5">
<div className="invisible md:visible">
<img className="h-11" src="/api/app-store/paystack/icon.svg" alt="Paystack" />
<p className="text-default mt-5 text-lg">Paystack</p>
</div>

<form
autoComplete="off"
className="mt-5"
onSubmit={(e) => {
e.preventDefault();
if (typeof credentialId !== "number") return;
saveKeysMutation.mutate({
credentialId,
key: {
public_key: newPublicKey,
secret_key: newSecretKey,
},
});
}}>
<TextField
label={t("paystack_public_key")}
type="text"
name="public_key"
id="public_key"
value={newPublicKey}
onChange={(e) => setNewPublicKey(e.target.value)}
role="presentation"
className="mb-6"
placeholder="pk_test_xxxxxxxxx"
/>
Comment thread
coderabbitai[bot] marked this conversation as resolved.

<TextField
label={t("paystack_secret_key")}
type="password"
name="secret_key"
id="secret_key"
value={newSecretKey}
autoComplete="new-password"
role="presentation"
onChange={(e) => setNewSecretKey(e.target.value)}
placeholder="sk_test_xxxxxxxxx"
/>

<div className="mt-5 flex flex-row justify-end">
<Button
type="submit"
color="primary"
loading={saveKeysMutation.isPending}
disabled={!newPublicKey || !newSecretKey}>
{t("save")}
</Button>
</div>
</form>

<div className="mt-5">
<p className="text-default font-bold">{t("getting_started")}</p>
<p className="text-default mt-2">
{t("paystack_getting_started_description")}{" "}
<a
className="text-blue-600 underline"
target="_blank"
href="https://dashboard.paystack.com/#/settings/developers"
rel="noreferrer">
{t("paystack_dashboard")}
</a>
.
</p>

<p className="text-default mt-4 font-bold">{t("paystack_webhook_setup")}</p>
<p className="text-default mt-2">
{t("paystack_webhook_setup_description")}
</p>
<code className="bg-subtle mt-2 block rounded p-2 text-sm">
{typeof window !== "undefined" ? window.location.origin : "https://your-cal.com"}
/api/integrations/paystack/webhook
</code>
</div>
</div>
</div>
) : (
<AppNotInstalledMessage appName="paystack" />
)}

<Toaster position="bottom-right" />
</div>
);
}
1 change: 1 addition & 0 deletions apps/web/tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
"include": [
/* Find a way to not require this - App files don't belong here. */
"../../packages/app-store/routing-forms/env.d.ts",
"../../packages/app-store/paystack/inline-js.d.ts",
"next-env.d.ts",
"../../packages/trpc/types/router.d.ts",
"../../packages/types/*.d.ts",
Expand Down
8 changes: 6 additions & 2 deletions lint-staged.config.mjs
Original file line number Diff line number Diff line change
@@ -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"],
};
1 change: 1 addition & 0 deletions packages/app-store/_pages/setup/_getServerSideProps.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand Down
2 changes: 2 additions & 0 deletions packages/app-store/apps.browser.generated.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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")),
Expand Down Expand Up @@ -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")),
Expand Down
2 changes: 2 additions & 0 deletions packages/app-store/apps.keys-schemas.generated.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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,
Expand Down
2 changes: 2 additions & 0 deletions packages/app-store/apps.metadata.generated.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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,
Expand Down
2 changes: 2 additions & 0 deletions packages/app-store/apps.schemas.generated.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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,
Expand Down
1 change: 1 addition & 0 deletions packages/app-store/apps.server.generated.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
Expand Down
1 change: 1 addition & 0 deletions packages/app-store/payment.services.generated.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
};
22 changes: 22 additions & 0 deletions packages/app-store/paystack/_metadata.ts
Original file line number Diff line number Diff line change
@@ -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;
55 changes: 55 additions & 0 deletions packages/app-store/paystack/api/add.ts
Original file line number Diff line number Diff line change
@@ -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}` : ""}` });
}
3 changes: 3 additions & 0 deletions packages/app-store/paystack/api/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export { default as add } from "./add";
export { default as webhook } from "./webhook";
export { default as verify } from "./verify";
Loading
Loading