feat: add Paystack payment integration#29296
Conversation
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
- Webhook: atomic idempotency via updateMany WHERE success=false
- Webhook: rollback success flag if Paystack API verification fails
- EventTypeSettings: guard parseInt("") returning NaN on cleared input
- PaymentComponent: use convertFromSmallestToPresentableCurrencyUnit for zero-decimal currencies
- Add handler: validate teamId is numeric before access check
- Add handler: consolidate owner filter, return 409 for duplicate installs
Wraps verification + handlePaymentSuccess in try/catch that resets success=false on failure, so webhook retries can re-process the payment instead of it being stuck as "already processed".
- webhook: skip payment.success rollback when handlePaymentSuccess signals success via HttpCode(200) sentinel, preventing idempotency state from being reset after successful processing - add: return 201 Created for credential install - payment component: use location.replace() so the payment step is not left in browser history - PaystackClient: export PAYSTACK_BASE_URL so tests can reference the constant instead of duplicating the literal - tests: use PAYSTACK_BASE_URL and a generic example.com callback_url instead of hard-coded api.paystack.co and cal.com strings
- Remove `key: true` from credential select; only id is used and the project rule forbids exposing credential.key. - Guard against `string[]`/`NaN` teamId in the setup _getServerSideProps, mirroring the validation already done in api/add.ts so an invalid value cannot bypass the team-admin check. Addresses [privy/coderabbitai] review on packages/app-store/paystack/pages/setup/_getServerSideProps.ts.
Wrap the duplicate-credential check and create in a single
prisma.$transaction so a concurrent install cannot slip a duplicate
credential through the read/write gap. Replace the brittle
Error("Already installed")/error.message catch with a direct 409
response — the catch now only handles unexpected errors via
getServerErrorFromUnknown.
Addresses [privy/coderabbitai] (string-sentinel) and cubic-dev-ai
(non-atomic findFirst→create) review feedback on
packages/app-store/paystack/api/add.ts.
The previous `[-1]` fallback evaluated truthy under `!!credentialId`, so the form rendered for un-installed users and submitting it triggered a `BAD_REQUEST: Could not find credential -1` toast from updateAppCredentials. Treat a missing/invalid credentialId as "not installed" and short-circuit the form submit defensively. Addresses [privy/coderabbitai] review on apps/web/components/apps/paystack/Setup.tsx.
- Extract a private `request` helper in PaystackClient so the three endpoints share identical request/error handling. - Bound every call to Paystack with `AbortSignal.timeout(15s)` so a slow/hung Paystack response cannot stall webhook/verify/refund threads on whatever the undici default is. - Wrap `response.json()` in a try/catch so non-JSON error bodies (HTML 502/504s, empty gateway-timeout bodies) surface the HTTP status instead of throwing an opaque `SyntaxError`. Tests are decoupled from the new `signal` argument via `expect.objectContaining` and `expect.any(AbortSignal)`. Addresses [privy/coderabbitai] review on packages/app-store/paystack/lib/PaystackClient.ts.
Two related fixes on the verify callback endpoint:
- Race condition: Paystack posts the webhook the moment the charge
succeeds, so a client redirect into /verify and the webhook regularly
observe `success: false` simultaneously and both invoke
handlePaymentSuccess — duplicating calendar events, BOOKING_PAID
webhooks, workflow runs, and confirmation emails. Replace the
read-then-call pattern with the same atomic
`updateMany({ where: { success: false } })` lock the webhook uses,
with rollback on real failure and pass-through of the
HttpCode(200) success sentinel.
- Type safety: `req.query.reference` is `string | string[] | undefined`.
The previous `as string` cast silently produced an array if the URL
contained `?reference=a&reference=b`, which then crashed the Prisma
query with an opaque error instead of returning 400. Validate
explicitly with `typeof === "string"`.
Addresses [privy/coderabbitai] (race + reference type) review on
packages/app-store/paystack/api/verify.ts.
Two webhook fixes that come up together because they share the
outer-catch path:
- Unknown reference: previously threw HttpCode(204) which the outer
catch then sent with a body, violating RFC 7230 §3.3.3 (a 204 cannot
carry a message body). Worse, Paystack treats non-2xx as a delivery
failure and retries indefinitely. Respond 200 directly with a warn
log instead — Paystack stops retrying and we don't pollute the
error feed with test-dashboard events.
- Success sentinel re-throw: handlePaymentSuccess signals success by
throwing HttpCode(200). Re-throwing it from the inner catch made it
bubble into the outer catch on every happy path, logging
"Webhook Error: Booking … was paid and confirmed." and rendering
the success `res.status(200).json({ received: true })` unreachable.
Treat the sentinel as success — don't roll back, don't re-throw,
fall through to the 200 response.
- Defensive guard so a real 204 thrown from elsewhere never sends a
body either.
Addresses [privy/coderabbitai] review on
packages/app-store/paystack/api/webhook.ts.
- PaymentService.deletePayment was swallowing every Prisma error into `return false`, so DB outages and FK violations were invisible. Log via the standard logger before returning false. - PaystackPaymentComponent: Intl.NumberFormat throws RangeError on unknown ISO codes — a stale event-type currency would crash the page. Wrap in try/catch and fall back to a plain numeric format. - Drop the redundant `payment.data as unknown as PaystackPaymentData` cast; the prop type already narrows it. Addresses [privy/coderabbitai] review on PaymentService.ts and PaystackPaymentComponent.tsx.
Restore every app-store *.generated.ts(x) file to the format produced by `yarn app-store:build`, then re-insert the paystack entries alphabetically. The previous diff was a mix of legitimate paystack additions and locally-divergent formatting (single quotes, missing trailing newlines, inlined ternary, quoted keys) that would have been flipped back the next time another contributor ran the generator. After this commit the diff against upstream/main is exactly the paystack additions in apps.browser, apps.keys-schemas, apps.schemas, apps.metadata, apps.server, and payment.services — and nothing else. Addresses [privy/coderabbitai] review on packages/app-store/video.adapters.generated.ts (and the related generated files).
Running `yarn install` triggered the app-store-cli postinstall and added the routing-forms entry that the previous regeneration missed. No paystack-specific code changed.
The (apps|packages|companion)/**/*.{js,ts,jsx,tsx} glob in
lint-staged.config.mjs catches *.d.ts files, but biome.json ignores
them via "!!**/*.d.ts". biome then errors with "No files were
processed in the specified paths", failing the pre-commit hook for
any commit that touches a .d.ts file.
Filter .d.ts out of the file list before invoking biome, and skip the
command entirely if nothing remains.
@paystack/inline-js ships JavaScript without types, so importing it
from PaystackPaymentComponent triggered TS7016 ("Could not find a
declaration file for module") under apps/web's strict
type-check:ci. An inline `declare module` inside the .tsx couldn't
augment it (TS2665: "untyped module ... cannot be augmented") — only
an ambient .d.ts works.
Add a focused declaration covering the small surface area we use
(`resumeTransaction(accessCode, { onSuccess, onCancel, onError })`)
and register it in apps/web/tsconfig.json — same pattern routing-forms
already uses for `react-awesome-query-builder`.
Verified locally: vitest 8/8 green, packages/app-store tsc exits 0,
apps/web tsc no longer reports paystack errors (the remaining
`trpc.viewer` warnings are pre-existing and unrelated to paystack).
# Conflicts: # lint-staged.config.mjs
|
Welcome to Cal.diy, @MarvelNwachukwu! Thanks for opening this pull request. A few things to keep in mind:
A maintainer will review your PR soon. Thanks for contributing! |
|
ℹ️ Recent review info⚙️ Run configurationConfiguration used: Organization UI Review profile: CHILL Plan: Pro Run ID: 📒 Files selected for processing (3)
🚧 Files skipped from review as they are similar to previous changes (3)
📝 WalkthroughWalkthroughThis pull request adds complete Paystack payment provider support to Cal.com. The integration includes a Paystack API client (initialize/verify/refund), a PaystackPaymentService, Next.js API routes for credential install, webhook and manual verification with signature checks and idempotency, client checkout and PaymentPage integration, event-type settings and setup UI with credential management, app-store metadata/registration, and unit tests. 🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches🧪 Generate unit tests (beta)
Tip 💬 Introducing Slack Agent: The best way for teams to turn conversations into code.Slack Agent is built on CodeRabbit's deep understanding of your code, so your team can collaborate across the entire SDLC without losing context.
Built for teams:
One agent for your entire SDLC. Right inside Slack. Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Actionable comments posted: 5
🧹 Nitpick comments (9)
packages/app-store/paystack/api/verify.ts (1)
103-121: Success sentinel pattern is intentional—verified.The code correctly implements the established
handlePaymentSuccessAPI contract. The function explicitly throwsHttpCode(200)on success (handlePaymentSuccess.ts:217), and the sentinel check correctly avoids rolling back the idempotency lock forstatusCode < 400. This pattern is documented and consistently used across payment handlers (e.g., paystack webhook.ts has detailed comment explaining the behavior).The implementation is correct. If desired as a future improvement, consider whether
handlePaymentSuccesscould return a status tuple instead of throwing exceptions for success signaling, though the current pattern is functional and intentional.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@packages/app-store/paystack/api/verify.ts` around lines 103 - 121, This try/catch is intentional and correct: leave the logic in verify.ts unchanged (the try block calling handlePaymentSuccess and the catch checking for HttpCode sentinel), but add a concise clarifying comment above the catch referencing handlePaymentSuccess and HttpCode to document that handlePaymentSuccess throws HttpCode(200) as a success sentinel so maintainers understand why success does not trigger a rollback of payment.success.packages/app-store/paystack/lib/PaymentService.ts (4)
129-164: ⚡ Quick winSimplify
refund()to avoid redundantfindUniqueround trips.When the payment is already refunded or never succeeded, the current code re-fetches the full row with another
findUnique. Since you already loaded the row, just expand the initialselect(or drop it) and return that record directly. Also note: returning a non-refunded payment whenpayment.success === falsemasks an attempted refund of a never-paid record — consider throwingErrorWithCodefor that case so the caller can surface it rather than silently noop.♻️ Proposed change
async refund(paymentId: Payment["id"]): Promise<Payment | null> { - const payment = await prisma.payment.findUnique({ - where: { id: paymentId }, - select: { - id: true, - success: true, - refunded: true, - externalId: true, - }, - }); + const payment = await prisma.payment.findUnique({ where: { id: paymentId } }); if (!payment) { return null; } - if (payment.refunded) { - return await prisma.payment.findUnique({ where: { id: paymentId } }); - } - if (!payment.success) { - return await prisma.payment.findUnique({ where: { id: paymentId } }); - } + if (payment.refunded) return payment; + if (!payment.success) { + throw new ErrorWithCode("PAYMENT_NOT_SUCCESSFUL", "Cannot refund a payment that did not succeed"); + } ... }🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@packages/app-store/paystack/lib/PaymentService.ts` around lines 129 - 164, The refund() method does redundant DB round-trips and silently no-ops on non-successful payments; change the initial prisma.payment.findUnique to select the full record (or remove select) so you can return the loaded payment directly when payment.refunded is true, and when payment.success is false throw an ErrorWithCode (instead of returning the record) so callers can handle the invalid refund attempt; keep the existing this.client.createRefund call and prisma.payment.update(refunded: true) path unchanged, just eliminate the extra findUnique calls.
86-91: 💤 Low valueAvoid the
as unknown as Prisma.InputJsonValuedouble cast.Double-casting via
unknowndefeats the type system in the same spirit the guideline forbidsas any. The object you're storing is already a plain JSON-serializable record; the cast is only needed becausedatais typed as the broaderPrisma.JsonValue. Either type the literal explicitly or usePrisma.JsonObjectdirectly so the value is structurally assignable without a cast.♻️ Proposed change
- data: { - access_code: paystackResponse.access_code, - authorization_url: paystackResponse.authorization_url, - publicKey: this.credentials.public_key, - reference, - } as unknown as Prisma.InputJsonValue, + data: { + access_code: paystackResponse.access_code, + authorization_url: paystackResponse.authorization_url, + publicKey: this.credentials.public_key, + reference, + } satisfies Prisma.InputJsonObject,As per coding guidelines: "Never use
as any- use proper type-safe solutions instead" — the same principle applies toas unknown as Tescape hatches.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@packages/app-store/paystack/lib/PaymentService.ts` around lines 86 - 91, The object assigned to the data property in PaymentService.ts is being double-cast with "as unknown as Prisma.InputJsonValue"; remove the unsafe double cast and make the value structurally typed: either annotate the literal as Prisma.JsonObject (e.g. set data: <Prisma.JsonObject>{ access_code: ..., authorization_url: ..., publicKey: ..., reference }) or change the target type to Prisma.JsonValue/JsonObject so the plain record is assignable without casting; update the import/use of Prisma types if needed and ensure the data property uses Prisma.JsonObject instead of forcing the unknown cast.
55-56: 💤 Low valueReference suffix has only ~32 bits of entropy; consider widening it.
uid.slice(0, 8)keeps just 8 hex chars (~32 bits). ThebookingIdprefix scopes uniqueness to a single booking, but if the same booking has many payment retries you can hit collisions — and Paystack rejects duplicate references. Using the full uuid (orslice(0, 16)) costs nothing and removes the risk.♻️ Proposed change
- const reference = `cal_${bookingId}_${uid.slice(0, 8)}`; + const reference = `cal_${bookingId}_${uid.replace(/-/g, "")}`;🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@packages/app-store/paystack/lib/PaymentService.ts` around lines 55 - 56, The generated reference `const reference = \`cal_${bookingId}_${uid.slice(0, 8)}\`` uses only 8 hex chars (~32 bits) which risks collisions; update the logic that creates `uid`/`reference` (where `uuidv4()` is called) to use a longer suffix (e.g., `uid.slice(0,16)` or the full `uid`) so `reference` carries sufficient entropy and avoids Paystack duplicate-reference rejections; keep the `bookingId` prefix but replace `uid.slice(0, 8)` with the chosen longer slice or `uid` in the `reference` assignment.
58-68: ⚡ Quick winUse the
WEBAPP_URLconstant instead of readingprocess.env.NEXT_PUBLIC_WEBAPP_URLdirectly.If the env var is unset at runtime, the callback URL becomes
"undefined/api/integrations/paystack/verify", which Paystack will reject or silently store with an invalid URL.@calcom/lib/constantsexportsWEBAPP_URLwith proper fallback handling—the rest of the codebase, including other payment services like PayPal and HitPay, already follow this pattern.♻️ Proposed change
+import { WEBAPP_URL } from "@calcom/lib/constants"; import logger from "@calcom/lib/logger"; ... - callback_url: `${process.env.NEXT_PUBLIC_WEBAPP_URL}/api/integrations/paystack/verify`, + callback_url: `${WEBAPP_URL}/api/integrations/paystack/verify`,🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@packages/app-store/paystack/lib/PaymentService.ts` around lines 58 - 68, In PaymentService where you call this.client.initializeTransaction (the block that builds callback_url), replace direct use of process.env.NEXT_PUBLIC_WEBAPP_URL with the exported WEBAPP_URL constant from `@calcom/lib/constants` and import it at the top of the file; ensure you join WEBAPP_URL and "/api/integrations/paystack/verify" robustly (handle missing/trailing slash) so the callback_url never becomes "undefined/..." or a malformed URL.apps/web/app/(use-page-wrapper)/payment/[uid]/PaymentPage.tsx (2)
63-68: 💤 Low valueImport path inconsistent with sibling payment components.
Other payment components are imported from
@calcom/web/components/apps/.../PaymentComponentwhile this one uses@calcom/app-store/paystack/components/.... Functionally fine, but mixing the two conventions makes maintenance harder. Consider either re-exporting the component underapps/web/components/apps/paystack/(parity withpaypal,hitpay,btcpayserver) or migrating the others over time.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@apps/web/app/`(use-page-wrapper)/payment/[uid]/PaymentPage.tsx around lines 63 - 68, The dynamic import of PaystackPaymentComponent uses an inconsistent package path; update the import for PaystackPaymentComponent (the dynamic(...) call) to match the sibling payment components pattern (i.e., import from your web-facing re-export location used by paypal/hitpay/btcpayserver, e.g., the apps/web components namespace), or add a re-export from the existing `@calcom/app-store/paystack/components/PaystackPaymentComponent` into the web components folder and then change the dynamic import to that re-exporting module so the symbol PaystackPaymentComponent follows the same import convention as the other payment components.
181-192: ⚡ Quick winAvoid the double cast and the silent empty-string fallback for
publicKey.
(props.payment.data as unknown as { publicKey: string }).publicKey ?? ""defeats the type system and, ifpublicKeyis missing or malformed, hands an empty key to@paystack/inline-js, surfacing a confusing client-side failure deep inside the popup. Validate the shape with a small type guard (or zod) and either bail to an error state or assert presence at server-side render time.♻️ Proposed change
- {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.appId === "paystack" && + !props.payment.success && + typeof props.payment.data?.publicKey === "string" && + props.payment.data.publicKey.length > 0 && ( + <PaystackPaymentComponent + payment={props.payment} + clientId={props.payment.data.publicKey} + bookingUid={props.booking.uid} + bookingTitle={eventName} + amount={props.payment.amount} + currency={props.payment.currency} + /> + )}As per coding guidelines: "Never use
as any- use proper type-safe solutions instead" — the same principle applies toas unknown as { ... }escape hatches.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@apps/web/app/`(use-page-wrapper)/payment/[uid]/PaymentPage.tsx around lines 181 - 192, Replace the double-cast extraction of the Paystack publicKey from props.payment.data with a proper runtime type check and explicit error handling: add a small type guard (or zod schema) that validates props.payment.data has a string publicKey, use it in PaymentPage.tsx before rendering PaystackPaymentComponent, and if the guard fails either render an error state / fallback UI or prevent rendering (so you don't pass an empty string into Paystack). Target symbols: props.payment.data, payment.publicKey, and the PaystackPaymentComponent invocation; remove the "(as unknown as { publicKey: string }).publicKey ?? ''" pattern and replace it with the validated value or an early error path.packages/app-store/paystack/package.json (2)
10-18: 💤 Low valueConsider declaring
@calcom/prismaas an explicit dependency.
packages/app-store/paystack/lib/PaymentService.tsimports from@calcom/prismaand@calcom/prisma/client, but the workspace package is not listed underdependencies. While workspace hoisting may resolve this in the monorepo, declaring it explicitly (as other app-store packages do) avoids brittleness if hoisting behavior changes or the package is consumed in isolation.📦 Proposed addition
"dependencies": { "@calcom/lib": "workspace:*", + "@calcom/prisma": "workspace:*", "@paystack/inline-js": "^2.0.0", "uuid": "^9.0.0" },🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@packages/app-store/paystack/package.json` around lines 10 - 18, The package.json is missing an explicit dependency on `@calcom/prisma` even though lib/PaymentService.ts imports from '@calcom/prisma' and '@calcom/prisma/client'; add "@calcom/prisma": "workspace:*" (or the same version strategy used by other app-store packages) to the "dependencies" section so the package declares the runtime dependency explicitly and won't rely on workspace hoisting.
12-12: 💤 Low valueReplace the custom
inline-js.d.tswith the official@types/paystack__inline-jspackage. The DefinitelyTyped package covers thePaystackPopclass andresumeTransactionmethod used in your code, letting you remove both the custominline-js.d.tsfile and the special tsconfig include inapps/web/tsconfig.json, while gaining upstream type maintenance.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@packages/app-store/paystack/package.json` at line 12, Replace the custom declaration file inline-js.d.ts with the official DefinitelyTyped package by adding `@types/paystack__inline-js` as a devDependency (replace or add in packages/app-store/paystack/package.json where "@paystack/inline-js" is declared), remove the custom inline-js.d.ts file, and remove the special tsconfig include entry in apps/web/tsconfig.json that was only there to pick up the custom declarations; ensure code references to PaystackPop and resumeTransaction remain and compile against the new `@types` package, then run install to update lockfiles.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@apps/web/components/apps/paystack/Setup.tsx`:
- Around line 67-77: The TextField for the Paystack public key includes
role="presentation", which hides its interactive semantics from assistive tech;
remove the role attribute from the TextField that renders the public key input
(the element with name="public_key", id="public_key", value={newPublicKey} and
onChange={setNewPublicKey}). Also remove the same role="presentation" from the
secret key input TextField (the corresponding name="secret_key"/id="secret_key"
input that uses newSecretKey and setNewSecretKey) so both inputs retain their
native interactive accessibility.
In `@packages/app-store/paystack/components/PaystackPaymentComponent.tsx`:
- Line 67: In PaystackPaymentComponent replace the direct interpolation of
paymentData.reference in the fetch call with an encoded query parameter: build
the query using URLSearchParams (or encodeURIComponent) so the verify request
uses an encoded reference (paymentData.reference) when calling
`/api/integrations/paystack/verify`, ensuring special characters won’t corrupt
the query string.
In `@packages/app-store/paystack/lib/PaymentService.ts`:
- Around line 47-54: Replace all bare Error throws in PaymentService.ts with
ErrorWithCode: import ErrorWithCode from your shared errors module and change
throws at the locations shown (the "Booking not found" at the top of the file
and the other throws around lines 109, 116, 153, 171) to use ErrorWithCode with
the original message and a stable error code (e.g., "NOT_FOUND" for missing
booking, "PAYSTACK_NOT_CONFIGURED" for missing credentials, or other
site-standard codes); update each throw site in the PaymentService class/methods
accordingly so callers can branch on the provided code.
In `@packages/app-store/paystack/pages/setup/_getServerSideProps.ts`:
- Around line 20-27: The current ternary uses truthiness for teamId which treats
0 as falsy and incorrectly falls back to userId; change the branching to a
strict null check so a parsed 0 is treated as a valid teamId. Update the
creation of installForObject to use "teamId !== null ? { teamId } : { userId:
session.user.id }" and ensure the earlier parsing (rawTeamId -> teamId) and the
call to throwIfNotHaveAdminAccessToTeam({ teamId, userId: session.user.id })
continue to accept 0 as a legitimate team id.
In `@packages/i18n/locales/en/common.json`:
- Line 3745: The string value for the i18n key
"paystack_getting_started_description" contains a hardcoded brand "Cal.com";
update the translation to use the templated brand variable by replacing
"Cal.com" with "{{appName}}" so the key paystack_getting_started_description
reads about obtaining API keys from your {{appName}} account, preserving
surrounding punctuation and spacing.
---
Nitpick comments:
In `@apps/web/app/`(use-page-wrapper)/payment/[uid]/PaymentPage.tsx:
- Around line 63-68: The dynamic import of PaystackPaymentComponent uses an
inconsistent package path; update the import for PaystackPaymentComponent (the
dynamic(...) call) to match the sibling payment components pattern (i.e., import
from your web-facing re-export location used by paypal/hitpay/btcpayserver,
e.g., the apps/web components namespace), or add a re-export from the existing
`@calcom/app-store/paystack/components/PaystackPaymentComponent` into the web
components folder and then change the dynamic import to that re-exporting module
so the symbol PaystackPaymentComponent follows the same import convention as the
other payment components.
- Around line 181-192: Replace the double-cast extraction of the Paystack
publicKey from props.payment.data with a proper runtime type check and explicit
error handling: add a small type guard (or zod schema) that validates
props.payment.data has a string publicKey, use it in PaymentPage.tsx before
rendering PaystackPaymentComponent, and if the guard fails either render an
error state / fallback UI or prevent rendering (so you don't pass an empty
string into Paystack). Target symbols: props.payment.data, payment.publicKey,
and the PaystackPaymentComponent invocation; remove the "(as unknown as {
publicKey: string }).publicKey ?? ''" pattern and replace it with the validated
value or an early error path.
In `@packages/app-store/paystack/api/verify.ts`:
- Around line 103-121: This try/catch is intentional and correct: leave the
logic in verify.ts unchanged (the try block calling handlePaymentSuccess and the
catch checking for HttpCode sentinel), but add a concise clarifying comment
above the catch referencing handlePaymentSuccess and HttpCode to document that
handlePaymentSuccess throws HttpCode(200) as a success sentinel so maintainers
understand why success does not trigger a rollback of payment.success.
In `@packages/app-store/paystack/lib/PaymentService.ts`:
- Around line 129-164: The refund() method does redundant DB round-trips and
silently no-ops on non-successful payments; change the initial
prisma.payment.findUnique to select the full record (or remove select) so you
can return the loaded payment directly when payment.refunded is true, and when
payment.success is false throw an ErrorWithCode (instead of returning the
record) so callers can handle the invalid refund attempt; keep the existing
this.client.createRefund call and prisma.payment.update(refunded: true) path
unchanged, just eliminate the extra findUnique calls.
- Around line 86-91: The object assigned to the data property in
PaymentService.ts is being double-cast with "as unknown as
Prisma.InputJsonValue"; remove the unsafe double cast and make the value
structurally typed: either annotate the literal as Prisma.JsonObject (e.g. set
data: <Prisma.JsonObject>{ access_code: ..., authorization_url: ..., publicKey:
..., reference }) or change the target type to Prisma.JsonValue/JsonObject so
the plain record is assignable without casting; update the import/use of Prisma
types if needed and ensure the data property uses Prisma.JsonObject instead of
forcing the unknown cast.
- Around line 55-56: The generated reference `const reference =
\`cal_${bookingId}_${uid.slice(0, 8)}\`` uses only 8 hex chars (~32 bits) which
risks collisions; update the logic that creates `uid`/`reference` (where
`uuidv4()` is called) to use a longer suffix (e.g., `uid.slice(0,16)` or the
full `uid`) so `reference` carries sufficient entropy and avoids Paystack
duplicate-reference rejections; keep the `bookingId` prefix but replace
`uid.slice(0, 8)` with the chosen longer slice or `uid` in the `reference`
assignment.
- Around line 58-68: In PaymentService where you call
this.client.initializeTransaction (the block that builds callback_url), replace
direct use of process.env.NEXT_PUBLIC_WEBAPP_URL with the exported WEBAPP_URL
constant from `@calcom/lib/constants` and import it at the top of the file; ensure
you join WEBAPP_URL and "/api/integrations/paystack/verify" robustly (handle
missing/trailing slash) so the callback_url never becomes "undefined/..." or a
malformed URL.
In `@packages/app-store/paystack/package.json`:
- Around line 10-18: The package.json is missing an explicit dependency on
`@calcom/prisma` even though lib/PaymentService.ts imports from '@calcom/prisma'
and '@calcom/prisma/client'; add "@calcom/prisma": "workspace:*" (or the same
version strategy used by other app-store packages) to the "dependencies" section
so the package declares the runtime dependency explicitly and won't rely on
workspace hoisting.
- Line 12: Replace the custom declaration file inline-js.d.ts with the official
DefinitelyTyped package by adding `@types/paystack__inline-js` as a devDependency
(replace or add in packages/app-store/paystack/package.json where
"@paystack/inline-js" is declared), remove the custom inline-js.d.ts file, and
remove the special tsconfig include entry in apps/web/tsconfig.json that was
only there to pick up the custom declarations; ensure code references to
PaystackPop and resumeTransaction remain and compile against the new `@types`
package, then run install to update lockfiles.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: CHILL
Plan: Pro
Run ID: d9a57d50-5300-417d-813f-a94e10c70ea4
⛔ Files ignored due to path filters (2)
packages/app-store/paystack/static/icon.svgis excluded by!**/*.svgyarn.lockis excluded by!**/yarn.lock,!**/*.lock
📒 Files selected for processing (34)
apps/web/app/(use-page-wrapper)/payment/[uid]/PaymentPage.tsxapps/web/components/apps/AppSetupPage.tsxapps/web/components/apps/paystack/Setup.tsxapps/web/tsconfig.jsonlint-staged.config.mjspackages/app-store/_pages/setup/_getServerSideProps.tsxpackages/app-store/apps.browser.generated.tsxpackages/app-store/apps.keys-schemas.generated.tspackages/app-store/apps.metadata.generated.tspackages/app-store/apps.schemas.generated.tspackages/app-store/apps.server.generated.tspackages/app-store/payment.services.generated.tspackages/app-store/paystack/_metadata.tspackages/app-store/paystack/api/add.tspackages/app-store/paystack/api/index.tspackages/app-store/paystack/api/verify.tspackages/app-store/paystack/api/webhook.tspackages/app-store/paystack/components/EventTypeAppCardInterface.tsxpackages/app-store/paystack/components/EventTypeAppSettingsInterface.tsxpackages/app-store/paystack/components/PaystackPaymentComponent.tsxpackages/app-store/paystack/config.jsonpackages/app-store/paystack/index.tspackages/app-store/paystack/inline-js.d.tspackages/app-store/paystack/lib/PaymentService.tspackages/app-store/paystack/lib/PaystackClient.tspackages/app-store/paystack/lib/__tests__/PaystackClient.test.tspackages/app-store/paystack/lib/__tests__/verifyWebhookSignature.test.tspackages/app-store/paystack/lib/currencyOptions.tspackages/app-store/paystack/lib/verifyWebhookSignature.tspackages/app-store/paystack/package.jsonpackages/app-store/paystack/pages/setup/_getServerSideProps.tspackages/app-store/paystack/zod.tspackages/i18n/locales/en/common.jsonscripts/seed-app-store.ts
role="presentation" on a focusable form input tells assistive tech to ignore the element's implicit interactive role, which makes the public and secret key fields invisible to screen readers. Per WAI-ARIA, presentation is reserved for non-interactive elements; remove it from both TextFields and let the native input semantics speak for themselves. Addresses [coderabbitai] review on apps/web/components/apps/paystack/Setup.tsx.
Per the project's error-handling convention (services/repositories must throw ErrorWithCode, never bare Error), replace every Error in PaystackPaymentService with the matching ErrorCode: - "Booking not found" → ErrorCode.BookingNotFound - "Paystack credentials …" → ErrorCode.MissingPaymentCredential - "Paystack does not support hold" → ErrorCode.BadRequest - "Method not implemented." → ErrorCode.InternalServerError This lets callers branch on stable codes instead of message strings and ensures the trpc errorConversionMiddleware maps them to the right HTTP statuses. Addresses [coderabbitai] review on packages/app-store/paystack/lib/PaymentService.ts.
Four small cleanups from CodeRabbit's review of PaymentService: - Use WEBAPP_URL from @calcom/lib/constants instead of reading process.env.NEXT_PUBLIC_WEBAPP_URL directly. If the env var were unset the callback URL would become "undefined/api/…" and Paystack would silently store a malformed URL; WEBAPP_URL ships proper fallback handling and matches the PayPal/HitPay convention. - Widen the reference suffix from `uid.slice(0, 8)` (~32 bits of entropy) to the full uuid (`uid.replace(/-/g, "")`, 122 bits). The bookingId prefix already scopes uniqueness, but repeated retries on the same booking could collide and Paystack rejects duplicate references. - Replace the `as unknown as Prisma.InputJsonValue` double-cast with `satisfies Prisma.InputJsonObject`. The object is structurally a JSON record; the cast was only there to bridge the broader JsonValue target type, which `satisfies` does without losing type information. - Simplify refund(): the previous version re-ran findUnique twice on the already-refunded and never-succeeded branches. Now it returns the loaded record for the refunded case, and throws ErrorWithCode on the never-succeeded case so callers can surface the invalid refund attempt instead of getting a silent no-op back. Addresses [coderabbitai] review on PaymentService.ts.
PaymentPage previously extracted the public key with
`(props.payment.data as unknown as { publicKey: string }).publicKey ?? ""`,
a double-cast that silently passed an empty string through to the
Paystack popup if event-type metadata was malformed. Follow the
existing paypal/hitpay/btcpayserver convention instead:
- Add `apps/web/components/apps/paystack/PaystackPaymentComponent.tsx`
as a thin web-side wrapper that validates `payment.data` with a Zod
schema (access_code, authorization_url, publicKey, reference) and
renders `payment_failed_try_again` if invalid.
- Update PaymentPage to import the wrapper namespace and stop
extracting publicKey at the call site.
- Tighten the inner component's prop type to only what it uses
(`{ data: PaystackPaymentData }`, no full Payment row) and drop
the unused `clientId` prop — the inner component already reads
publicKey from payment.data.
Addresses [coderabbitai] review on apps/web/.../PaymentPage.tsx.
Interpolating `paymentData.reference` directly into the verify URL trusts the reference to be URL-safe. Paystack-generated references are normally cal_<bookingId>_<uuid>, but defending against out-of-spec characters costs nothing — build the query string via URLSearchParams so any odd characters round-trip cleanly. Addresses [coderabbitai] review on PaystackPaymentComponent.tsx.
- _getServerSideProps: the previous `teamId ? { teamId } : { userId }`
branching used truthiness, so a parsed `0` (a legitimate team id in
the data model) would silently fall through to user-scoped install
and the team admin check would be bypassed. Switch to
`teamId !== null` branching and replace `Number.isNaN` with
`Number.isInteger` so non-integer values are also rejected at the
same gate.
- i18n: `paystack_getting_started_description` hardcoded "Cal.com",
which breaks downstream deployments that ship with a different
APP_NAME (Cal.diy, white-label installs, etc.). Templatize via
`{{appName}}` and pass `APP_NAME` from the Setup component, matching
the pattern AppPage and the Apple Calendar setup already use.
Addresses [coderabbitai] review on
_getServerSideProps.ts and common.json:3745.
…ne-js
The previous ambient declaration was a stopgap to silence TS7016 for
`@paystack/inline-js`, which ships JS only. DefinitelyTyped publishes
@types/paystack__inline-js (latest 1.0.2) covering PaystackPop and the
resumeTransaction API we use. Replace the in-repo declaration with the
upstream package:
- packages/app-store/paystack/package.json:
- add @types/paystack__inline-js as a devDependency
- add @calcom/prisma as an explicit dependency (PaymentService imports
from @calcom/prisma and @calcom/prisma/client; relying on workspace
hoisting is fragile)
- delete packages/app-store/paystack/inline-js.d.ts
- drop the bespoke include for inline-js.d.ts from apps/web/tsconfig.json
(the routing-forms entry stays as the precedent for legitimate
app-specific declarations)
Verified locally: app-store tsc exits 0; apps/web tsc reports no new
paystack errors (only pre-existing trpc.viewer issues unrelated to
this change).
Addresses [coderabbitai] review on
packages/app-store/paystack/package.json.
…rify The pre-existing one-line comment on the inner catch was easy to skim past, and the contract (handlePaymentSuccess throwing 200 as its success signal) is non-obvious to anyone seeing the code for the first time. Expand the block comment to spell out the contract, why a 2xx HttpCode is treated as success, and what happens on a real failure. No logic change. Addresses [coderabbitai] nitpick on packages/app-store/paystack/api/verify.ts.
There was a problem hiding this comment.
Actionable comments posted: 2
🧹 Nitpick comments (1)
packages/app-store/paystack/components/PaystackPaymentComponent.tsx (1)
38-47: 💤 Low valueConsider using the user's locale instead of hardcoded
"en".
Intl.NumberFormat("en", …)ignores the viewer's locale for grouping and decimal separators (relevant for fr/de/etc., even though the currency code itself stays the same).useLocale()exposes the active locale and could be passed in here.♻️ Example
- const { t } = useLocale(); + const { t, i18n } = useLocale(); ... - formattedAmount = new Intl.NumberFormat("en", { + formattedAmount = new Intl.NumberFormat(i18n.language, { style: "currency", currency: currency.toUpperCase(), }).format(presentableAmount);🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@packages/app-store/paystack/components/PaystackPaymentComponent.tsx` around lines 38 - 47, The currency formatting uses a hardcoded "en" locale; update PaystackPaymentComponent to call useLocale() and pass the returned locale into Intl.NumberFormat instead of "en" (e.g., replace the literal with the locale variable), and keep the existing try/catch fallback for unknown currency codes; also ensure you handle the case where useLocale() may return undefined by defaulting to "en" or navigator.language.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@packages/app-store/paystack/api/verify.ts`:
- Around line 55-72: The credential lookup can fall back to a { userId: null,
appId: "paystack" } query when payment.booking?.userId is missing; change the
credentialQuery construction so the fallback is only used when a concrete userId
exists—if paystackAppData?.credentialId is absent and payment.booking?.userId is
null/undefined, immediately throw the HttpCode error instead of calling
prisma.credential.findFirst. Update the logic around
metadata/paystackAppData/credentialQuery (the variables payment, metadata,
paystackAppData, credentialQuery and the prisma.credential.findFirst call) to
validate presence of userId before building/using the fallback query and retain
the existing error throw (HttpCode) when no valid identifier is available.
In `@packages/app-store/paystack/components/PaystackPaymentComponent.tsx`:
- Around line 57-73: In the popup.resumeTransaction onSuccess handler, don't
await the verification fetch since it can stall the redirect; make the verify
call fire-and-forget (e.g., call
fetch('/api/integrations/paystack/verify?...').catch(() => {}) without awaiting)
and move the setTimeout(window.location.replace(`/booking/${bookingUid}`), 2000)
to run unconditionally (outside the try/catch) so the redirect is scheduled
regardless of verify completion; update references in the onSuccess callback
around the existing setStatus, verify fetch, and setTimeout logic.
---
Nitpick comments:
In `@packages/app-store/paystack/components/PaystackPaymentComponent.tsx`:
- Around line 38-47: The currency formatting uses a hardcoded "en" locale;
update PaystackPaymentComponent to call useLocale() and pass the returned locale
into Intl.NumberFormat instead of "en" (e.g., replace the literal with the
locale variable), and keep the existing try/catch fallback for unknown currency
codes; also ensure you handle the case where useLocale() may return undefined by
defaulting to "en" or navigator.language.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: CHILL
Plan: Pro
Run ID: 446916b0-b13d-4e3e-8236-d38193386264
⛔ Files ignored due to path filters (1)
yarn.lockis excluded by!**/yarn.lock,!**/*.lock
📒 Files selected for processing (9)
apps/web/app/(use-page-wrapper)/payment/[uid]/PaymentPage.tsxapps/web/components/apps/paystack/PaystackPaymentComponent.tsxapps/web/components/apps/paystack/Setup.tsxpackages/app-store/paystack/api/verify.tspackages/app-store/paystack/components/PaystackPaymentComponent.tsxpackages/app-store/paystack/lib/PaymentService.tspackages/app-store/paystack/package.jsonpackages/app-store/paystack/pages/setup/_getServerSideProps.tspackages/i18n/locales/en/common.json
✅ Files skipped from review due to trivial changes (1)
- packages/i18n/locales/en/common.json
🚧 Files skipped from review as they are similar to previous changes (5)
- packages/app-store/paystack/package.json
- apps/web/app/(use-page-wrapper)/payment/[uid]/PaymentPage.tsx
- packages/app-store/paystack/lib/PaymentService.ts
- packages/app-store/paystack/pages/setup/_getServerSideProps.ts
- apps/web/components/apps/paystack/Setup.tsx
Both verify and webhook fall back to
`{ userId: payment.booking?.userId, appId: "paystack" }` when the
event-type metadata doesn't carry a specific credentialId. If
`payment.booking?.userId` is null/undefined the query becomes
`{ userId: null, appId: "paystack" }` and Prisma will literally match
a credential where `userId IS NULL` — silently resolving to an
unintended row. In the webhook path that means verifying the signature
against the wrong secret.
Branch the fallback explicitly: use credentialId when present, fall
back to userId only when it's a real number, and otherwise throw
500 "Cannot resolve payment credentials" so the caller fails closed
instead of guessing.
Same change applied to verify.ts and webhook.ts since they share the
pattern; webhook also logs an error before throwing for observability.
Addresses [coderabbitai] review on packages/app-store/paystack/api/verify.ts.
…t block redirect
`await fetch('/api/integrations/paystack/verify?...')` inside the
inline-js onSuccess handler ran before the setTimeout that schedules
the redirect to `/booking/{uid}`. If the verify endpoint stalled (slow
upstream, transient outage), the user got stuck on the green "Payment
successful" screen indefinitely even though Paystack had confirmed the
charge and the webhook would reconcile the booking shortly.
The verify call is best-effort — the webhook is the source of truth —
so drop the await, attach a no-op .catch for the rejection, and let
the redirect schedule unconditionally. Also drop the now-unnecessary
`async` from onSuccess.
Addresses [coderabbitai] review on
packages/app-store/paystack/components/PaystackPaymentComponent.tsx.
Hardcoding "en" for Intl.NumberFormat formats the amount with English grouping/decimal separators regardless of the user's actual locale, which reads oddly in fr/de/etc. Pull `i18n.language` from useLocale() and fall back to "en" if it's unset. The try/catch around Intl.NumberFormat stays — RangeError is still possible for unknown ISO currency codes. Addresses [coderabbitai] nitpick on packages/app-store/paystack/components/PaystackPaymentComponent.tsx.
Summary
Adds Paystack as a payment provider for Cal.com — Africa's leading payment gateway, supporting NGN/GHS/ZAR/KES/USD, bank transfers, mobile money, and USSD across Nigeria, Ghana, South Africa, Kenya, and more.
This PR replaces the closed #28737. The original could not be reopened (
UNPROCESSABLEfrom the GitHub API) after addressing the review feedback locally and rebasing ontomain.What changed since #28737 was closed
The original PR was closed by @romitg2 on 2026-05-06 with the request: "please reopen with addressing coderabbit comments". Every actionable review comment from coderabbitai and cubic-dev-ai has been addressed in the commits below; commit messages cite which reviewer raised each concern.
Critical / Major correctness fixes
webhook.ts— atomicupdateManyidempotency lock (cubic P1); success sentinel no longer logs as error or rolls back; unknown references ack with 200 instead of204with a body (RFC 7230 §3.3.3) (coderabbitai)verify.ts— same atomic idempotency lock so the verify endpoint and webhook can't both firehandlePaymentSuccessand duplicate calendar events / BOOKING_PAID webhooks / emails; explicittypeof reference === "string"validation (coderabbitai)add.ts— duplicate-credential check + create wrapped inprisma.$transaction; brittleError("Already installed")string-sentinel replaced with a direct 409 (cubic + coderabbitai)Setup.tsx—credentialId = -1fallback was truthy under!!, so the form rendered without an install and submission threwBAD_REQUEST: Could not find credential -1. Now rendersAppNotInstalledMessageand short-circuits the mutation (coderabbitai)_getServerSideProps.ts— dropskey: truefrom the credential select (project rule violation); guardsteamIdagainststring[] | NaN(coderabbitai)API client hardening
PaystackClient.ts— extracted sharedrequesthelper; bounded every call withAbortSignal.timeout(15s)so a slow Paystack response can't stall request threads; checksresponse.okbefore parsing JSON so HTML/empty 502/504 bodies don't throwSyntaxError(coderabbitai)PaymentService.ts—deletePaymentnow logs the underlying error before returning false (coderabbitai)PaystackPaymentComponent.tsx— guardsIntl.NumberFormatagainst unknown ISO codes (RangeError); drops the redundantas unknown as PaystackPaymentDatacast (coderabbitai)Generator hygiene
app-store/*.generated.{ts,tsx}— restored to canonical generator output; the original PR had cosmetic drift (single quotes, missing trailing newlines, inlined ternary) mixed with the legitimate Paystack additions (coderabbitai)inline-js.d.tsfor@paystack/inline-js(ships JS only) and registered it inapps/web/tsconfig.json— same precedentrouting-formsalready uses forreact-awesome-query-builderOther
chore: skip biome on .d.ts files in lint-staged— biome.json ignores**/*.d.tsbut the lint-staged glob included them, breaking the pre-commit hook for any commit that touched a.d.tsupstream/mainto resolve conflicts and pick up routing-forms in the regenerated app-store mapsArchitecture
Follows the established Cal.com payment app patterns used by HitPay and Stripe:
Local verification
vitest run packages/app-store/paystack— 8/8 tests passtsc --noEmit -p packages/app-store/tsconfig.json— exit 0tsc --noEmit -p apps/web/tsconfig.json— no Paystack-introduced errors (the remainingtrpc.viewerwarnings are repo-wide and pre-existing on this branch even with my changes stashed; caused by local@calcom/trpcbuild state, not new code)Test plan
/apps/paystack→ setup page appears