Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 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
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,16 @@ const BtcpayPaymentComponent = dynamic(
}
);

const PaystackPaymentComponent = dynamic(
() =>
import("@calcom/web/components/apps/paystack/PaystackPaymentComponent").then(
(m) => m.PaystackPaymentComponent
),
{
ssr: false,
}
);

const PaymentPage: FC<PaymentPageProps> = (props) => {
const { t, i18n } = useLocale();
const [is24h, setIs24h] = useState(isBrowserLocale24h());
Expand Down Expand Up @@ -171,6 +181,15 @@ 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}
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
51 changes: 51 additions & 0 deletions apps/web/components/apps/paystack/PaystackPaymentComponent.tsx
Original file line number Diff line number Diff line change
@@ -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 <p className="mt-3 text-center">{t("payment_failed_try_again")}</p>;
}

return (
<PaystackInlineComponent
payment={{ data: parsed.data }}
bookingUid={bookingUid}
bookingTitle={bookingTitle}
amount={amount}
currency={currency}
/>
);
};
133 changes: 133 additions & 0 deletions apps/web/components/apps/paystack/Setup.tsx
Original file line number Diff line number Diff line change
@@ -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 <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)}
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"
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", { appName: APP_NAME })}{" "}
<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>
);
}
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;
Loading
Loading