Skip to content

Commit cf5507f

Browse files
feat: add Paystack payment integration
Add Paystack as a payment provider for Cal.com, enabling hosts to collect payments via Paystack when attendees book events. Paystack is widely used across Africa and supports NGN, GHS, ZAR, KES, and USD currencies. Key components: - PaymentService implementing IAbstractPaymentService (create, refund, update) - PaystackClient REST wrapper for Paystack API (initialize, verify, refund) - Webhook endpoint with HMAC-SHA512 signature verification - Verify endpoint for callback-based payment confirmation - Event type app card UI for configuring price, currency, and refund policy - Setup page for entering Paystack API keys (public + secret) - PaystackPaymentComponent using Paystack inline popup checkout - App install flow via add.ts with isOAuth: true for proper setup routing Follows existing payment app patterns (HitPay, Stripe) for: - App store registration (config.json, _metadata.ts, zod schemas) - Server-side props for setup page authentication - Generated file registration (payment.services, apps.metadata, etc.) - Seed script entry for local development
1 parent c7ee77e commit cf5507f

39 files changed

Lines changed: 2186 additions & 752 deletions

apps/web/app/(use-page-wrapper)/payment/[uid]/PaymentPage.tsx

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,13 @@ const BtcpayPaymentComponent = dynamic(
5656
}
5757
);
5858

59+
const PaystackPaymentComponent = dynamic(
60+
() => import("@calcom/app-store/paystack/components/PaystackPaymentComponent"),
61+
{
62+
ssr: false,
63+
}
64+
);
65+
5966
const PaymentPage: FC<PaymentPageProps> = (props) => {
6067
const { t, i18n } = useLocale();
6168
const [is24h, setIs24h] = useState(isBrowserLocale24h());
@@ -174,6 +181,18 @@ const PaymentPage: FC<PaymentPageProps> = (props) => {
174181
{props.payment.appId === "btcpayserver" && !props.payment.success && (
175182
<BtcpayPaymentComponent payment={props.payment} paymentPageProps={props} />
176183
)}
184+
{props.payment.appId === "paystack" && !props.payment.success && (
185+
<PaystackPaymentComponent
186+
payment={props.payment}
187+
clientId={
188+
(props.payment.data as unknown as { publicKey: string }).publicKey ?? ""
189+
}
190+
bookingUid={props.booking.uid}
191+
bookingTitle={eventName}
192+
amount={props.payment.amount}
193+
currency={props.payment.currency}
194+
/>
195+
)}
177196
{props.payment.refunded && (
178197
<div className="text-default mt-4 text-center dark:text-gray-300">{t("refunded")}</div>
179198
)}

apps/web/components/apps/AppSetupPage.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ export const AppSetupMap = {
1515
paypal: dynamic(() => import("@calcom/web/components/apps/paypal/Setup")),
1616
hitpay: dynamic(() => import("@calcom/web/components/apps/hitpay/Setup")),
1717
btcpayserver: dynamic(() => import("@calcom/web/components/apps/btcpayserver/Setup")),
18+
paystack: dynamic(() => import("@calcom/web/components/apps/paystack/Setup")),
1819
};
1920

2021
export const AppSetupPage = (props: { slug: string }) => {
Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
1+
import { useRouter } from "next/navigation";
2+
import { useState } from "react";
3+
import { Toaster } from "sonner";
4+
5+
import AppNotInstalledMessage from "@calcom/app-store/_components/AppNotInstalledMessage";
6+
import { useLocale } from "@calcom/lib/hooks/useLocale";
7+
import { trpc } from "@calcom/trpc/react";
8+
import { Button } from "@calcom/ui/components/button";
9+
import { TextField } from "@calcom/ui/components/form";
10+
import { showToast } from "@calcom/ui/components/toast";
11+
12+
export default function PaystackSetup() {
13+
const [newPublicKey, setNewPublicKey] = useState("");
14+
const [newSecretKey, setNewSecretKey] = useState("");
15+
const router = useRouter();
16+
const { t } = useLocale();
17+
18+
const integrations = trpc.viewer.apps.integrations.useQuery({
19+
variant: "payment",
20+
appId: "paystack",
21+
});
22+
23+
const [paystackCredentials] = integrations.data?.items || [];
24+
const [credentialId] = paystackCredentials?.userCredentialIds || [-1];
25+
26+
const showContent = !!integrations.data && integrations.isSuccess && !!credentialId;
27+
28+
const saveKeysMutation = trpc.viewer.apps.updateAppCredentials.useMutation({
29+
onSuccess: () => {
30+
showToast(t("keys_have_been_saved"), "success");
31+
router.push("/event-types");
32+
},
33+
onError: (error) => {
34+
showToast(error.message, "error");
35+
},
36+
});
37+
38+
if (integrations.isPending) {
39+
return <div className="absolute z-50 flex h-screen w-full items-center bg-gray-200" />;
40+
}
41+
42+
return (
43+
<div className="bg-default flex h-screen">
44+
{showContent ? (
45+
<div className="bg-default border-subtle m-auto max-w-[43em] overflow-auto rounded border pb-10 md:p-10">
46+
<div className="ml-2 ltr:mr-2 rtl:ml-2 md:ml-5">
47+
<div className="invisible md:visible">
48+
<img className="h-11" src="/api/app-store/paystack/icon.svg" alt="Paystack" />
49+
<p className="text-default mt-5 text-lg">Paystack</p>
50+
</div>
51+
52+
<form
53+
autoComplete="off"
54+
className="mt-5"
55+
onSubmit={(e) => {
56+
e.preventDefault();
57+
saveKeysMutation.mutate({
58+
credentialId,
59+
key: {
60+
public_key: newPublicKey,
61+
secret_key: newSecretKey,
62+
},
63+
});
64+
}}>
65+
<TextField
66+
label={t("paystack_public_key")}
67+
type="text"
68+
name="public_key"
69+
id="public_key"
70+
value={newPublicKey}
71+
onChange={(e) => setNewPublicKey(e.target.value)}
72+
role="presentation"
73+
className="mb-6"
74+
placeholder="pk_test_xxxxxxxxx"
75+
/>
76+
77+
<TextField
78+
label={t("paystack_secret_key")}
79+
type="password"
80+
name="secret_key"
81+
id="secret_key"
82+
value={newSecretKey}
83+
autoComplete="new-password"
84+
role="presentation"
85+
onChange={(e) => setNewSecretKey(e.target.value)}
86+
placeholder="sk_test_xxxxxxxxx"
87+
/>
88+
89+
<div className="mt-5 flex flex-row justify-end">
90+
<Button
91+
type="submit"
92+
color="primary"
93+
loading={saveKeysMutation.isPending}
94+
disabled={!newPublicKey || !newSecretKey}>
95+
{t("save")}
96+
</Button>
97+
</div>
98+
</form>
99+
100+
<div className="mt-5">
101+
<p className="text-default font-bold">{t("getting_started")}</p>
102+
<p className="text-default mt-2">
103+
{t("paystack_getting_started_description")}{" "}
104+
<a
105+
className="text-blue-600 underline"
106+
target="_blank"
107+
href="https://dashboard.paystack.com/#/settings/developers"
108+
rel="noreferrer">
109+
{t("paystack_dashboard")}
110+
</a>
111+
.
112+
</p>
113+
114+
<p className="text-default mt-4 font-bold">{t("paystack_webhook_setup")}</p>
115+
<p className="text-default mt-2">
116+
{t("paystack_webhook_setup_description")}
117+
</p>
118+
<code className="bg-subtle mt-2 block rounded p-2 text-sm">
119+
{typeof window !== "undefined" ? window.location.origin : "https://your-cal.com"}
120+
/api/integrations/paystack/webhook
121+
</code>
122+
</div>
123+
</div>
124+
</div>
125+
) : (
126+
<AppNotInstalledMessage appName="paystack" />
127+
)}
128+
129+
<Toaster position="bottom-right" />
130+
</div>
131+
);
132+
}

packages/app-store/_pages/setup/_getServerSideProps.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ export const AppSetupPageMap = {
66
stripe: import("../../stripepayment/pages/setup/_getServerSideProps"),
77
hitpay: import("../../hitpay/pages/setup/_getServerSideProps"),
88
btcpayserver: import("../../btcpayserver/pages/setup/_getServerSideProps"),
9+
paystack: import("../../paystack/pages/setup/_getServerSideProps"),
910
};
1011

1112
export const getServerSideProps = async (ctx: GetServerSidePropsContext) => {

packages/app-store/analytics.services.generated.ts

Lines changed: 3 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,6 @@
22
This file is autogenerated using the command `yarn app-store:build --watch`.
33
Don't modify this file manually.
44
**/
5-
export const AnalyticsServiceMap =
6-
process.env.NEXT_PUBLIC_IS_E2E === "1"
7-
? {}
8-
: {
9-
dub: import("./dub/lib/AnalyticsService"),
10-
};
5+
export const AnalyticsServiceMap = process.env.NEXT_PUBLIC_IS_E2E === '1' ? {} : {
6+
"dub": import("./dub/lib/AnalyticsService"),
7+
};

packages/app-store/apps.browser.generated.tsx

Lines changed: 53 additions & 59 deletions
Original file line numberDiff line numberDiff line change
@@ -2,69 +2,63 @@
22
This file is autogenerated using the command `yarn app-store:build --watch`.
33
Don't modify this file manually.
44
**/
5-
import dynamic from "next/dynamic";
5+
import dynamic from "next/dynamic"
66
export const InstallAppButtonMap = {
7-
exchange2013calendar: dynamic(() => import("./exchange2013calendar/components/InstallAppButton")),
8-
exchange2016calendar: dynamic(() => import("./exchange2016calendar/components/InstallAppButton")),
9-
office365video: dynamic(() => import("./office365video/components/InstallAppButton")),
10-
vital: dynamic(() => import("./vital/components/InstallAppButton")),
7+
"exchange2013calendar": dynamic(() => import("./exchange2013calendar/components/InstallAppButton")),
8+
"exchange2016calendar": dynamic(() => import("./exchange2016calendar/components/InstallAppButton")),
9+
"office365video": dynamic(() => import("./office365video/components/InstallAppButton")),
10+
"vital": dynamic(() => import("./vital/components/InstallAppButton")),
1111
};
1212
export const AppSettingsComponentsMap = {
13-
"general-app-settings": dynamic(
14-
() => import("./templates/general-app-settings/components/AppSettingsInterface")
15-
),
16-
weather_in_your_calendar: dynamic(
17-
() => import("./weather_in_your_calendar/components/AppSettingsInterface")
18-
),
19-
zapier: dynamic(() => import("./zapier/components/AppSettingsInterface")),
13+
"general-app-settings": dynamic(() => import("./templates/general-app-settings/components/AppSettingsInterface")),
14+
"weather_in_your_calendar": dynamic(() => import("./weather_in_your_calendar/components/AppSettingsInterface")),
15+
"zapier": dynamic(() => import("./zapier/components/AppSettingsInterface")),
2016
};
2117
export const EventTypeAddonMap = {
22-
alby: dynamic(() => import("./alby/components/EventTypeAppCardInterface")),
23-
basecamp3: dynamic(() => import("./basecamp3/components/EventTypeAppCardInterface")),
24-
btcpayserver: dynamic(() => import("./btcpayserver/components/EventTypeAppCardInterface")),
25-
closecom: dynamic(() => import("./closecom/components/EventTypeAppCardInterface")),
26-
databuddy: dynamic(() => import("./databuddy/components/EventTypeAppCardInterface")),
27-
fathom: dynamic(() => import("./fathom/components/EventTypeAppCardInterface")),
28-
ga4: dynamic(() => import("./ga4/components/EventTypeAppCardInterface")),
29-
giphy: dynamic(() => import("./giphy/components/EventTypeAppCardInterface")),
30-
gtm: dynamic(() => import("./gtm/components/EventTypeAppCardInterface")),
31-
hitpay: dynamic(() => import("./hitpay/components/EventTypeAppCardInterface")),
32-
hubspot: dynamic(() => import("./hubspot/components/EventTypeAppCardInterface")),
33-
insihts: dynamic(() => import("./insihts/components/EventTypeAppCardInterface")),
34-
matomo: dynamic(() => import("./matomo/components/EventTypeAppCardInterface")),
35-
metapixel: dynamic(() => import("./metapixel/components/EventTypeAppCardInterface")),
36-
"mock-payment-app": dynamic(() => import("./mock-payment-app/components/EventTypeAppCardInterface")),
37-
paypal: dynamic(() => import("./paypal/components/EventTypeAppCardInterface")),
38-
"pipedrive-crm": dynamic(() => import("./pipedrive-crm/components/EventTypeAppCardInterface")),
39-
plausible: dynamic(() => import("./plausible/components/EventTypeAppCardInterface")),
40-
posthog: dynamic(() => import("./posthog/components/EventTypeAppCardInterface")),
41-
qr_code: dynamic(() => import("./qr_code/components/EventTypeAppCardInterface")),
42-
salesforce: dynamic(() => import("./salesforce/components/EventTypeAppCardInterface")),
43-
stripepayment: dynamic(() => import("./stripepayment/components/EventTypeAppCardInterface")),
44-
"booking-pages-tag": dynamic(
45-
() => import("./templates/booking-pages-tag/components/EventTypeAppCardInterface")
46-
),
47-
"event-type-app-card": dynamic(
48-
() => import("./templates/event-type-app-card/components/EventTypeAppCardInterface")
49-
),
50-
twipla: dynamic(() => import("./twipla/components/EventTypeAppCardInterface")),
51-
umami: dynamic(() => import("./umami/components/EventTypeAppCardInterface")),
52-
"zoho-bigin": dynamic(() => import("./zoho-bigin/components/EventTypeAppCardInterface")),
53-
zohocrm: dynamic(() => import("./zohocrm/components/EventTypeAppCardInterface")),
18+
"alby": dynamic(() => import("./alby/components/EventTypeAppCardInterface")),
19+
"basecamp3": dynamic(() => import("./basecamp3/components/EventTypeAppCardInterface")),
20+
"btcpayserver": dynamic(() => import("./btcpayserver/components/EventTypeAppCardInterface")),
21+
"closecom": dynamic(() => import("./closecom/components/EventTypeAppCardInterface")),
22+
"databuddy": dynamic(() => import("./databuddy/components/EventTypeAppCardInterface")),
23+
"fathom": dynamic(() => import("./fathom/components/EventTypeAppCardInterface")),
24+
"ga4": dynamic(() => import("./ga4/components/EventTypeAppCardInterface")),
25+
"giphy": dynamic(() => import("./giphy/components/EventTypeAppCardInterface")),
26+
"gtm": dynamic(() => import("./gtm/components/EventTypeAppCardInterface")),
27+
"hitpay": dynamic(() => import("./hitpay/components/EventTypeAppCardInterface")),
28+
"hubspot": dynamic(() => import("./hubspot/components/EventTypeAppCardInterface")),
29+
"insihts": dynamic(() => import("./insihts/components/EventTypeAppCardInterface")),
30+
"matomo": dynamic(() => import("./matomo/components/EventTypeAppCardInterface")),
31+
"metapixel": dynamic(() => import("./metapixel/components/EventTypeAppCardInterface")),
32+
"mock-payment-app": dynamic(() => import("./mock-payment-app/components/EventTypeAppCardInterface")),
33+
"paypal": dynamic(() => import("./paypal/components/EventTypeAppCardInterface")),
34+
"paystack": dynamic(() => import("./paystack/components/EventTypeAppCardInterface")),
35+
"pipedrive-crm": dynamic(() => import("./pipedrive-crm/components/EventTypeAppCardInterface")),
36+
"plausible": dynamic(() => import("./plausible/components/EventTypeAppCardInterface")),
37+
"posthog": dynamic(() => import("./posthog/components/EventTypeAppCardInterface")),
38+
"qr_code": dynamic(() => import("./qr_code/components/EventTypeAppCardInterface")),
39+
"salesforce": dynamic(() => import("./salesforce/components/EventTypeAppCardInterface")),
40+
"stripepayment": dynamic(() => import("./stripepayment/components/EventTypeAppCardInterface")),
41+
"booking-pages-tag": dynamic(() => import("./templates/booking-pages-tag/components/EventTypeAppCardInterface")),
42+
"event-type-app-card": dynamic(() => import("./templates/event-type-app-card/components/EventTypeAppCardInterface")),
43+
"twipla": dynamic(() => import("./twipla/components/EventTypeAppCardInterface")),
44+
"umami": dynamic(() => import("./umami/components/EventTypeAppCardInterface")),
45+
"zoho-bigin": dynamic(() => import("./zoho-bigin/components/EventTypeAppCardInterface")),
46+
"zohocrm": dynamic(() => import("./zohocrm/components/EventTypeAppCardInterface")),
5447
};
5548
export const EventTypeSettingsMap = {
56-
alby: dynamic(() => import("./alby/components/EventTypeAppSettingsInterface")),
57-
basecamp3: dynamic(() => import("./basecamp3/components/EventTypeAppSettingsInterface")),
58-
btcpayserver: dynamic(() => import("./btcpayserver/components/EventTypeAppSettingsInterface")),
59-
databuddy: dynamic(() => import("./databuddy/components/EventTypeAppSettingsInterface")),
60-
fathom: dynamic(() => import("./fathom/components/EventTypeAppSettingsInterface")),
61-
ga4: dynamic(() => import("./ga4/components/EventTypeAppSettingsInterface")),
62-
giphy: dynamic(() => import("./giphy/components/EventTypeAppSettingsInterface")),
63-
gtm: dynamic(() => import("./gtm/components/EventTypeAppSettingsInterface")),
64-
hitpay: dynamic(() => import("./hitpay/components/EventTypeAppSettingsInterface")),
65-
metapixel: dynamic(() => import("./metapixel/components/EventTypeAppSettingsInterface")),
66-
paypal: dynamic(() => import("./paypal/components/EventTypeAppSettingsInterface")),
67-
plausible: dynamic(() => import("./plausible/components/EventTypeAppSettingsInterface")),
68-
qr_code: dynamic(() => import("./qr_code/components/EventTypeAppSettingsInterface")),
69-
stripepayment: dynamic(() => import("./stripepayment/components/EventTypeAppSettingsInterface")),
70-
};
49+
"alby": dynamic(() => import("./alby/components/EventTypeAppSettingsInterface")),
50+
"basecamp3": dynamic(() => import("./basecamp3/components/EventTypeAppSettingsInterface")),
51+
"btcpayserver": dynamic(() => import("./btcpayserver/components/EventTypeAppSettingsInterface")),
52+
"databuddy": dynamic(() => import("./databuddy/components/EventTypeAppSettingsInterface")),
53+
"fathom": dynamic(() => import("./fathom/components/EventTypeAppSettingsInterface")),
54+
"ga4": dynamic(() => import("./ga4/components/EventTypeAppSettingsInterface")),
55+
"giphy": dynamic(() => import("./giphy/components/EventTypeAppSettingsInterface")),
56+
"gtm": dynamic(() => import("./gtm/components/EventTypeAppSettingsInterface")),
57+
"hitpay": dynamic(() => import("./hitpay/components/EventTypeAppSettingsInterface")),
58+
"metapixel": dynamic(() => import("./metapixel/components/EventTypeAppSettingsInterface")),
59+
"paypal": dynamic(() => import("./paypal/components/EventTypeAppSettingsInterface")),
60+
"paystack": dynamic(() => import("./paystack/components/EventTypeAppSettingsInterface")),
61+
"plausible": dynamic(() => import("./plausible/components/EventTypeAppSettingsInterface")),
62+
"qr_code": dynamic(() => import("./qr_code/components/EventTypeAppSettingsInterface")),
63+
"stripepayment": dynamic(() => import("./stripepayment/components/EventTypeAppSettingsInterface")),
64+
};

0 commit comments

Comments
 (0)