Skip to content

feat: add Paystack payment integration#28737

Closed
MarvelNwachukwu wants to merge 6 commits into
calcom:mainfrom
MarvelNwachukwu:feat/paystack-upstream
Closed

feat: add Paystack payment integration#28737
MarvelNwachukwu wants to merge 6 commits into
calcom:mainfrom
MarvelNwachukwu:feat/paystack-upstream

Conversation

@MarvelNwachukwu
Copy link
Copy Markdown

@MarvelNwachukwu MarvelNwachukwu commented Apr 4, 2026

Summary

Adds Paystack as a payment provider for Cal.com. Paystack is Africa's leading payment gateway, processing payments for 200,000+ businesses across Nigeria, Ghana, South Africa, Kenya, and more.

Why: Cal.com currently supports Stripe, PayPal, and a few crypto payment options — none of which serve the African market well. Paystack fills this gap by providing a payment option that supports local currencies (NGN, GHS, ZAR, KES, USD) and local payment methods (bank transfers, mobile money, USSD) that African users expect.

What's included

  • PaymentService — Implements IAbstractPaymentService with create, refund, update, and deletePayment methods
  • PaystackClient — REST API wrapper for Paystack (initialize transaction, verify, refund)
  • Webhook endpoint — Receives Paystack events with HMAC-SHA512 signature verification for secure payment confirmation
  • Verify endpoint — Callback-based payment verification (Paystack redirects here after payment)
  • Event type configuration UI — App card interface for setting price, currency, refund policy, and payment option
  • Setup page — Authenticated form for entering Paystack API keys (public key + secret key)
  • Payment component — Uses Paystack's inline popup checkout on the /payment/[uid] page
  • App install flow — Uses isOAuth: true pattern (same as HitPay) to route through the accounts step → add.ts → setup page

Architecture

Follows the established Cal.com payment app patterns used by HitPay and Stripe:

packages/app-store/paystack/
├── _metadata.ts                          # App metadata
├── config.json                           # App store config
├── zod.ts                                # Zod schemas (appDataSchema, appKeysSchema)
├── api/
│   ├── add.ts                            # Install handler → redirects to setup
│   ├── verify.ts                         # Payment callback verification
│   └── webhook.ts                        # Webhook with signature verification
├── components/
│   ├── EventTypeAppCardInterface.tsx      # Event type app card
│   ├── EventTypeAppSettingsInterface.tsx  # Price/currency/refund settings
│   └── PaystackPaymentComponent.tsx       # Inline checkout popup
├── lib/
│   ├── PaymentService.ts                 # IAbstractPaymentService implementation
│   ├── PaystackClient.ts                 # Paystack REST API client
│   ├── verifyWebhookSignature.ts         # HMAC-SHA512 verification
│   ├── currencyOptions.ts                # Supported currencies
│   └── __tests__/                        # Unit tests
└── pages/setup/
    └── _getServerSideProps.ts            # Authenticated setup page props

Payment flow

  1. Host installs Paystack app → enters API keys on setup page
  2. Host enables Paystack on event type → sets price, currency, refund policy
  3. Attendee books event → clicks "Pay to book"
  4. Booking created with PENDING status → redirect to /payment/[uid]
  5. Paystack inline popup opens → attendee completes payment
  6. Paystack sends webhook → signature verified → booking confirmed
  7. Alternatively: Paystack redirects to /api/integrations/paystack/verify → payment verified → booking confirmed

Test plan

  • Install Paystack from /apps/paystack → verify setup page appears
  • Enter Paystack test API keys → verify they save correctly
  • Configure event type with Paystack payment (price, currency) → verify app card shows
  • Book event as attendee → verify "Pay to book" button and price display
  • Complete payment via Paystack popup → verify booking is confirmed
  • Test refund flow from bookings page
  • Test webhook endpoint with Paystack test events

Screenshots

CleanShot 2026-04-04 at 21 55 44 CleanShot 2026-04-04 at 21 55 55 CleanShot 2026-04-04 at 21 56 35 CleanShot 2026-04-04 at 21 57 08 CleanShot 2026-04-04 at 22 40 40 CleanShot 2026-04-04 at 22 41 02 CleanShot 2026-04-04 at 22 41 06 CleanShot 2026-04-04 at 22 41 13

@CLAassistant
Copy link
Copy Markdown

CLAassistant commented Apr 4, 2026

CLA assistant check
All committers have signed the CLA.

Copy link
Copy Markdown
Contributor

@cubic-dev-ai cubic-dev-ai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

3 issues found across 24 files

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="packages/app-store/paystack/api/webhook.ts">

<violation number="1" location="packages/app-store/paystack/api/webhook.ts:112">
P1: Webhook idempotency check is non-atomic, allowing concurrent duplicate events to both process payment success and trigger duplicate side effects.</violation>
</file>

<file name="packages/app-store/paystack/components/EventTypeAppSettingsInterface.tsx">

<violation number="1" location="packages/app-store/paystack/components/EventTypeAppSettingsInterface.tsx:146">
P2: Clearing the number input stores NaN in refundDaysCount because parseInt("") is NaN; this can propagate into saved app data and the controlled value prop.</violation>
</file>

<file name="packages/app-store/paystack/components/PaystackPaymentComponent.tsx">

<violation number="1" location="packages/app-store/paystack/components/PaystackPaymentComponent.tsx:43">
P2: Paystack amount display incorrectly divides by 100 for all currencies, causing 100× under-display for zero-decimal currencies (e.g., XOF/RWF).</violation>
</file>

Reply with feedback, questions, or to request a fix. Tag @cubic-dev-ai to re-run a review, or fix all with cubic.

Comment thread packages/app-store/paystack/api/webhook.ts Outdated
Comment thread packages/app-store/paystack/components/EventTypeAppSettingsInterface.tsx Outdated
Comment thread packages/app-store/paystack/components/PaystackPaymentComponent.tsx Outdated
Copy link
Copy Markdown
Contributor

@cubic-dev-ai cubic-dev-ai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

2 issues found across 3 files (changes from recent commits).

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="packages/app-store/paystack/api/add.ts">

<violation number="1" location="packages/app-store/paystack/api/add.ts:15">
P2: Invalid `teamId` query values are coerced to `NaN` and treated as falsy, bypassing team-admin validation and silently falling back to user-scoped install instead of returning a validation error.</violation>

<violation number="2" location="packages/app-store/paystack/api/add.ts:22">
P2: Non-atomic `findFirst`→`create` install flow can create duplicate credentials under concurrent requests.</violation>
</file>

Reply with feedback, questions, or to request a fix. Tag @cubic-dev-ai to re-run a review, or fix all with cubic.

Comment thread packages/app-store/paystack/api/add.ts
Comment thread packages/app-store/paystack/api/add.ts
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
@MarvelNwachukwu MarvelNwachukwu force-pushed the feat/paystack-upstream branch from 420431f to cf5507f Compare April 4, 2026 21:45
@MarvelNwachukwu MarvelNwachukwu changed the title feat: Add Paystack payment integration feat: add Paystack payment integration Apr 4, 2026
- 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
Copy link
Copy Markdown
Contributor

@cubic-dev-ai cubic-dev-ai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

1 issue found across 4 files (changes from recent commits).

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="packages/app-store/paystack/api/webhook.ts">

<violation number="1" location="packages/app-store/paystack/api/webhook.ts:114">
P1: Idempotency lock conflates processing lock with final success state, causing unrecoverable stuck payments when exceptions occur after setting `success: true`.</violation>
</file>

Reply with feedback, questions, or to request a fix. Tag @cubic-dev-ai to re-run a review, or fix all with cubic.

Comment thread packages/app-store/paystack/api/webhook.ts
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".
@MarvelNwachukwu MarvelNwachukwu marked this pull request as ready for review April 4, 2026 22:29
@MarvelNwachukwu MarvelNwachukwu requested a review from a team as a code owner April 4, 2026 22:29
Copy link
Copy Markdown
Contributor

@cubic-dev-ai cubic-dev-ai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

1 issue found across 1 file (changes from recent commits).

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="packages/app-store/paystack/api/webhook.ts">

<violation number="1" location="packages/app-store/paystack/api/webhook.ts:145">
P1: New rollback catch resets `payment.success` even after successful processing (because `handlePaymentSuccess` throws 200 by design), breaking idempotency and enabling duplicate side effects.</violation>
</file>

Reply with feedback, questions, or to request a fix. Tag @cubic-dev-ai to re-run a review, or fix all with cubic.

appSlug: "paystack",
traceContext,
});
} catch (processingError) {
Copy link
Copy Markdown
Contributor

@cubic-dev-ai cubic-dev-ai Bot Apr 4, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: New rollback catch resets payment.success even after successful processing (because handlePaymentSuccess throws 200 by design), breaking idempotency and enabling duplicate side effects.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/app-store/paystack/api/webhook.ts, line 145:

<comment>New rollback catch resets `payment.success` even after successful processing (because `handlePaymentSuccess` throws 200 by design), breaking idempotency and enabling duplicate side effects.</comment>

<file context>
@@ -120,31 +120,36 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
+        appSlug: "paystack",
+        traceContext,
+      });
+    } catch (processingError) {
+      // Rollback so webhook retries can re-process this payment
       await prisma.payment.update({
</file context>
Fix with Cubic

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed in 441e7e0. The rollback catch now checks whether the thrown error is the HttpCode(200) success sentinel that handlePaymentSuccess throws by design; if so, the rollback is skipped so idempotency is preserved. Only genuine failures (statusCode >= 400) reset payment.success for retry.

@Adebesin-Cell
Copy link
Copy Markdown

This would be a great addition 🔥

@github-actions
Copy link
Copy Markdown
Contributor

This PR has been marked as stale due to inactivity. If you're still working on it or need any help, please let us know or update the PR to keep it active.

@github-actions github-actions Bot added the Stale label Apr 13, 2026
@vercel
Copy link
Copy Markdown

vercel Bot commented Apr 15, 2026

@MarvelNwachukwu is attempting to deploy a commit to the cal-diy Team on Vercel.

A member of the Team first needs to authorize it.

@MarvelNwachukwu
Copy link
Copy Markdown
Author

@kemiller @hthetiot Please review

@github-actions github-actions Bot removed the Stale label Apr 16, 2026
Comment thread packages/app-store/paystack/api/add.ts Outdated
return res.status(httpError.statusCode).json({ message: httpError.message });
}

return res.status(200).json({ url: `/apps/paystack/setup${teamIdNumber ? `?teamId=${teamIdNumber}` : ""}` });
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

http status 201 may be

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Changed to 201 in 441e7e0.

return;
}

res.status(200).json({ received: true });
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

http status 202

202 Accepted
The request has been accepted for processing, but the processing has not been completed. The request might or might not be eventually acted upon, and may be disallowed when processing occurs.

But that a personal opinion

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Keeping 200 here to match the existing pattern used by the PayPal webhook (packages/app-store/paypal/api/webhook.ts returns res.status(200).end()). 202 implies processing is still in progress, but by the time this line is reached we've already run handlePaymentSuccess synchronously — the success sentinel is a 200 HttpCode. Happy to revisit if there's a stronger reason to switch.

}

// Redirect to booking confirmation
setTimeout(() => {
Copy link
Copy Markdown
Contributor

@hthetiot hthetiot Apr 17, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

External redirect using location.href, might not be the best way here.
After a payment I would to a location.replace to clear payment step from history.

Edit:
Also why setTimeout is that because gateway payment is slow ?
Delay on server not client and make sure gateway is finished with receiving and replicated.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Switched to window.location.replace in 441e7e0 so the payment step is no longer left in browser history. The setTimeout is there purely for UX: it keeps the "Payment successful" confirmation visible for 2s before redirecting — it's not compensating for gateway timing. Paystack's inline-js onSuccess only fires after their client confirms, and our backup /verify fetch triggers the server-side revalidation just before the delay, so the booking is already being reconciled by the time we redirect.

metadata: { bookingId: 42 },
});

expect(fetch).toHaveBeenCalledWith("https://api.paystack.co/transaction/initialize", {
Copy link
Copy Markdown
Contributor

@hthetiot hthetiot Apr 17, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Use ${PAYSTACK_BASE_URL} may be.
Same bellow.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in 441e7e0: exported PAYSTACK_BASE_URL from PaystackClient.ts and updated all three expect(fetch).toHaveBeenCalledWith assertions in the test to use ${PAYSTACK_BASE_URL}/….

amount: 500000,
currency: "NGN",
reference: "cal_42_ref123",
callback_url: "https://cal.com/payment/callback",
Copy link
Copy Markdown
Contributor

@hthetiot hthetiot Apr 17, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This should not be hard-coded with cal.com I personally think.
Same apply bellow.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good call — replaced in 441e7e0. Added a local CALLBACK_URL = "https://example.com/payment/callback" constant and used it everywhere the test previously had https://cal.com/payment/callback.

@hthetiot
Copy link
Copy Markdown
Contributor

hthetiot commented Apr 17, 2026

@kemiller @hthetiot Please review

I have no authority here. I did a quick review.
I dont see major security issues or bad practices, but I dont use prisma, I am an ES6 person.
This look cool.

Only some http codes and hardcoded cal.com and url that I would fixes.

- 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
@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented Apr 23, 2026

📝 Walkthrough

Walkthrough

This change introduces Paystack as a supported payment service provider within the Cal.com platform. It adds a complete payment integration including API endpoints for transaction initialization, verification, and webhook handling; UI components for payment setup and event-type configuration; a Paystack API client with credential management; a payment service implementation following the platform's abstract payment service interface; autogenerated app-store mappings and metadata; locale strings for user-facing messaging; and database seeding for app registration. Supporting infrastructure includes Zod schemas for validation, tests for the Paystack client, and configuration files defining the app's properties and routing.

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 12.50% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title 'feat: add Paystack payment integration' accurately describes the main purpose of the changeset.
Description check ✅ Passed The pull request description is comprehensive and directly related to the changeset, providing context, architecture, and implementation details for the Paystack integration.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
⚔️ Resolve merge conflicts
  • Resolve merge conflict in branch feat/paystack-upstream

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 17

🧹 Nitpick comments (13)
packages/app-store/crm.apps.generated.ts (1)

6-12: Please avoid manual churn in generated file

These Line 6–12 changes look formatting-only. If this file was not regenerated by the app-store CLI as part of a required update, please revert this diff to keep the PR focused and reduce generated-file noise.

As per coding guidelines, "Never modify *.generated.ts files directly - they're created by app-store-cli".

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/app-store/crm.apps.generated.ts` around lines 6 - 12, The diff shows
formatting-only edits to the generated mapping in crm.apps.generated.ts (entries
like "closecom", "hubspot", "pipedrive-crm", "salesforce", "zoho-bigin",
"zohocrm"); do not edit this generated file by hand—revert these manual changes
and restore the original crm.apps.generated.ts, then if the change is actually
required, re-run the app-store CLI generator that produces crm.apps.generated.ts
so the update is applied correctly rather than editing the file directly.
packages/app-store/calendar.services.generated.ts (1)

1-17: Confirm this file was regenerated, not hand-edited.

Per the file header and repo conventions, this is an autogenerated artifact. Please ensure the diff here is the result of running yarn app-store:build after adding the Paystack app (which shouldn't affect CalendarServiceMap at all), rather than a manual formatting tweak. If it's purely formatting noise unrelated to Paystack, consider reverting to keep the PR diff minimal and avoid drift between the generator output and committed state.

As per coding guidelines: "Never modify *.generated.ts files directly - they're created by app-store-cli".

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/app-store/calendar.services.generated.ts` around lines 1 - 17, The
CalendarServiceMap autogenerated file appears modified—confirm it was
regenerated, not hand-edited: run the app-store generator (yarn app-store:build)
or yarn app-store:build --watch to regenerate CalendarServiceMap and verify the
file header and exported symbol CalendarServiceMap are produced by the tool; if
the only changes are formatting/noise unrelated to adding the Paystack app,
revert this file to the generated output (or remove it from the commit) so
generated artifacts remain unchanged by hand edits and the generator output
matches git.
packages/app-store/paystack/index.ts (1)

1-2: Remove unused barrel file.

This index.ts exports api and lib as namespaces, but the codebase imports directly from subpaths (./paystack/api, ./paystack/lib/PaymentService, etc.). No imports from @calcom/app-store/paystack root exist—all generated registries and consumers use direct imports. This barrel file violates the coding guidelines stating "Never use barrel imports from index.ts files" and "Import directly from source files, not barrel files." Remove the file to prevent introducing dead code.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/app-store/paystack/index.ts` around lines 1 - 2, Remove the barrel
export file index.ts that re-exports api and lib: delete the file exporting
"export * as api from \"./api\";" and "export * as lib from \"./lib\"". Ensure
no codebase imports from the package root (e.g., `@calcom/app-store/paystack`)
remain—if any import exists, update them to direct subpath imports such as
"./api" or "./lib/PaymentService". Commit the deletion and run project-wide
import checks/tsc to confirm no breakage.
packages/app-store/paystack/lib/__tests__/PaystackClient.test.ts (2)

6-11: Restore fetch between tests to avoid leaking the stub to other suites.

vi.stubGlobal("fetch", …) is not automatically reverted. Add vi.unstubAllGlobals() in an afterEach (or set unstubGlobals: true in the vitest config) so a stubbed fetch from this suite can't affect tests that run later in the same worker.

♻️ Proposed change
-import { beforeEach, describe, expect, it, vi } from "vitest";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
 import { PAYSTACK_BASE_URL, PaystackClient } from "../PaystackClient";
@@
   beforeEach(() => {
     client = new PaystackClient("sk_test_xxxxx");
   });
+
+  afterEach(() => {
+    vi.unstubAllGlobals();
+  });
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/app-store/paystack/lib/__tests__/PaystackClient.test.ts` around
lines 6 - 11, The test suite stubs global fetch with vi.stubGlobal and doesn't
restore it, risking leaks to other tests; add an afterEach that calls
vi.unstubAllGlobals() (or at least vi.unstubGlobal("fetch")) to restore globals
after each test in the describe block that initializes the PaystackClient in
beforeEach (PaystackClient, client) so the stubbed fetch won't affect other
suites.

87-158: Consider adding error-path coverage for verifyTransaction and createRefund.

Only initializeTransaction has a non-OK response test. Since all three methods share the same error-handling branch, a quick parity test for each would lock in that behavior and catch regressions if someone later refactors (e.g., to check response.ok before response.json()).

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/app-store/paystack/lib/__tests__/PaystackClient.test.ts` around
lines 87 - 158, Add missing negative-path unit tests for
PaystackClient.verifyTransaction and PaystackClient.createRefund to mirror the
existing initializeTransaction non-OK response test: stub global fetch to return
ok: false (and a json payload if needed), call client.verifyTransaction("...")
and client.createRefund({ transaction: "..." }) respectively, and assert they
throw or return the same error-handling outcome as initializeTransaction (i.e.,
verify the error is thrown/rejected or the returned error shape), so regressions
in the shared response.ok handling are covered.
packages/app-store/paystack/lib/currencyOptions.ts (1)

9-19: Tighten the type on currencySymbols so isAcceptedCurrencyCode actually narrows.

With Record<string, string>, keyof typeof currencySymbols collapses to string, so the type-guard doesn't meaningfully narrow — any string input satisfies it at the type level. Dropping the annotation (or using as const) gives you a proper literal union.

♻️ Proposed refactor
-export const currencySymbols: Record<string, string> = {
+export const currencySymbols = {
   ngn: "₦",
   ghs: "GH₵",
   zar: "R",
   kes: "KSh",
   usd: "$",
-};
+} as const;
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/app-store/paystack/lib/currencyOptions.ts` around lines 9 - 19, The
current currencySymbols is typed as Record<string,string> which collapses keyof
typeof currencySymbols to string; remove that explicit annotation and define
currencySymbols as a const literal (e.g. export const currencySymbols = { ngn:
"₦", ghs: "GH₵", zar: "R", kes: "KSh", usd: "$" } as const) so keyof typeof
currencySymbols becomes the literal union; you can then keep
isAcceptedCurrencyCode(code: string): code is keyof typeof currencySymbols as-is
(or optionally export a CurrencyCode = keyof typeof currencySymbols type) and
ensure no other annotations override the inferred literal type.
packages/app-store/paystack/lib/__tests__/verifyWebhookSignature.test.ts (1)

7-28: Solid coverage; consider one more case for the timingSafeEqual catch branch.

crypto.timingSafeEqual throws when buffer lengths differ, and the implementation relies on catching that to return false. None of the current cases exercise that path (wrong-secret still yields a 128-char hex, same length as validSignature). A length-mismatched signature test would lock in the safeguard against future regressions.

♻️ Optional addition
   it("returns false for empty signature", () => {
     expect(verifyWebhookSignature(body, "", secretKey)).toBe(false);
   });
+
+  it("returns false for signatures with mismatched length", () => {
+    expect(verifyWebhookSignature(body, "deadbeef", secretKey)).toBe(false);
+  });
 });
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/app-store/paystack/lib/__tests__/verifyWebhookSignature.test.ts`
around lines 7 - 28, The tests never exercise the timingSafeEqual catch branch
in verifyWebhookSignature where differing buffer lengths cause an exception; add
a new test case that supplies the same JSON body and secretKey but a
length-mismatched signature (e.g., a short string like "shortsig" or a hex with
different length) and assert verifyWebhookSignature(body, mismatchedSignature,
secretKey) returns false so the catch branch is covered.
apps/web/app/(use-page-wrapper)/payment/[uid]/PaymentPage.tsx (1)

184-195: clientId prop is unused by the receiving component.

Looking at packages/app-store/paystack/components/PaystackPaymentComponent.tsx, the component destructures { payment, bookingUid, bookingTitle, amount, currency }clientId is declared in its PaystackPaymentComponentProps interface but never read from props (the component reads publicKey from payment.data directly). Either drop the prop here and from the interface, or consume clientId inside the component instead of re-reading from payment.data.

Also, the as unknown as { publicKey: string } double cast circumvents the type system; since payment.data already contains publicKey (written in PaymentService.create), the cleaner path is to have the child component own the extraction via its typed PaystackPaymentData.

🧹 Proposed cleanup
                   {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}
                     />
                   )}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/web/app/`(use-page-wrapper)/payment/[uid]/PaymentPage.tsx around lines
184 - 195, Remove the unused clientId prop from the JSX and from
PaystackPaymentComponentProps, and stop passing (props.payment.data as unknown
as { publicKey: string }).publicKey here; instead keep the child component
(PaystackPaymentComponent) responsible for extracting publicKey from
payment.data by typing payment.data as the PaystackPaymentData type inside
PaystackPaymentComponent, update PaystackPaymentComponent to read publicKey from
payment.data (instead of ignoring the prop), and delete the double-cast; note
PaymentService.create already writes publicKey so no extra plumbing is needed.
packages/app-store/paystack/_metadata.ts (1)

19-19: isOAuth: true is semantically misleading for a pure API-key integration.

Paystack uses API key authentication (Authorization: Bearer ${secretKey}), not OAuth. While the flag currently serves to route the installation flow to the account setup step (same as Stripe, which does use real OAuth), the naming contradicts the type definition in App.d.ts which documents isOAuth as "Specifies if the App uses an OAuth flow."

The installation logic doesn't validate the actual protocol, so there's no immediate functional issue. However, this naming could create confusion for maintainers and risk introducing OAuth-specific logic (token refresh, consent screens, etc.) that incorrectly assumes all apps with isOAuth: true follow OAuth semantics.

Consider renaming to a more accurate flag like requiresSetup or hasAccountConfiguration, or clarify in code comments that isOAuth indicates "setup required" rather than "OAuth protocol used."

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/app-store/paystack/_metadata.ts` at line 19, The isOAuth: true flag
in packages/app-store/paystack/_metadata.ts is misleading for an API-key based
integration; rename the property to a clearer name such as requiresSetup (or
hasAccountConfiguration) in that metadata object and update all references that
route the installation flow (the installation/account-setup logic and any checks
against isOAuth, and the App.d.ts type) to use the new property name so
semantics reflect "requires setup" rather than OAuth; also update the App.d.ts
documentation to describe the new field and add a brief code comment in the
Paystack metadata explaining this is an API-key integration.
packages/app-store/paystack/components/PaystackPaymentComponent.tsx (1)

72-80: Swallowing the underlying error.

Both the onError handler and the outer catch fall back to the same generic "try again" string without logging the underlying error. This makes production debugging (Paystack SDK load failure vs. transaction error) much harder. Consider at minimum console.error(err) before setting status.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/app-store/paystack/components/PaystackPaymentComponent.tsx` around
lines 72 - 80, The error handlers in PaystackPaymentComponent.tsx currently
swallow underlying errors; update the onError handler for the Paystack SDK and
the outer catch to accept the error (e.g., err), log it (console.error or your
app logger) and surface useful info in the status/message flows (call
setStatus("error"), setErrorMessage(...) as before but include or append
err.message or a short descriptor). Target the onError callback and the catch
block that call setStatus/setErrorMessage so both log the raw error and preserve
the user-facing "payment_failed_try_again" text.
apps/web/components/apps/paystack/Setup.tsx (1)

38-40: Loading placeholder is not a real spinner.

An empty gray div with absolute z-50 overlays the entire screen while integrations is pending; there's no spinner, label, or accessible status. Minor UX/a11y gap — consider reusing the shared loader used by other Setup pages (e.g., Stripe/HitPay).

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/web/components/apps/paystack/Setup.tsx` around lines 38 - 40, Replace
the empty gray div shown while integrations.isPending in the Setup component
with the shared loader used by other Setup pages (use the same component or hook
used in Stripe/HitPay pages) and ensure it includes an accessible status
(role="status" or aria-live) and visible spinner + label; specifically, update
the conditional in Setup.tsx that checks integrations.isPending to render the
shared Loader component (matching its props/placement and z-index classes)
instead of the plain absolute gray div so users and assistive tech get a proper
loading indicator.
packages/app-store/paystack/zod.ts (1)

6-15: Consider adding bounds on numeric fields.

price and refundDaysCount accept any number including negatives/NaN. The UI enforces min="0.5" for price; mirroring that in the zod schema gives you server-side safety too.

♻️ Suggested tightening
-    price: z.number(),
-    currency: z.string(),
+    price: z.number().int().nonnegative(),
+    currency: z.string().min(3),
     paymentOption: z.literal("ON_BOOKING").optional(),
     refundPolicy: z.nativeEnum(RefundPolicy).optional(),
-    refundDaysCount: z.number().optional(),
+    refundDaysCount: z.number().int().nonnegative().optional(),
     refundCountCalendarDays: z.boolean().optional(),
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/app-store/paystack/zod.ts` around lines 6 - 15, The numeric fields
in appDataSchema currently accept any number (including negatives/NaN); update
the schema so price enforces the UI minimum and finite values (e.g., replace
price: z.number() with z.number().min(0.5).finite()) and tighten refundDaysCount
to a non-negative integer (e.g., refundDaysCount:
z.number().int().min(0).optional()) to mirror client-side constraints; keep
other fields unchanged and reference appDataSchema, price, and refundDaysCount
when making the changes.
packages/app-store/paystack/lib/PaymentService.ts (1)

59-59: Use WEBAPP_URL instead of reading the env var directly.

@calcom/lib/constants already exports WEBAPP_URL with fallback handling (e.g., VERCEL_URL in preview deploys). Your own EventTypeAppCardInterface.tsx imports it on line 7. Using process.env.NEXT_PUBLIC_WEBAPP_URL here bypasses that normalization and produces undefined/api/integrations/paystack/verify if the variable is unset in any environment (e.g., preview).

+import { WEBAPP_URL } from "@calcom/lib/constants";
 import prisma from "@calcom/prisma";
@@
-      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 the current code and only fix it if needed.

In `@packages/app-store/paystack/lib/PaymentService.ts` at line 59, Replace the
direct env usage in PaymentService where callback_url is built with the
normalized WEBAPP_URL constant: import { WEBAPP_URL } from
"@calcom/lib/constants" at the top of the file and change the callback_url value
from `${process.env.NEXT_PUBLIC_WEBAPP_URL}/api/integrations/paystack/verify` to
`${WEBAPP_URL}/api/integrations/paystack/verify`; this ensures PaymentService
uses the centralized WEBAPP_URL fallback logic used elsewhere (e.g.,
EventTypeAppCardInterface) and prevents undefined callback URLs in preview
environments.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@apps/web/components/apps/paystack/Setup.tsx`:
- Around line 23-26: The current fallback sets credentialId to -1 which is
truthy, so showContent renders the form and submissions call
updateAppCredentials with credentialId -1; change the fallback so credentialId
is undefined when paystackCredentials is missing (e.g., derive credentialId from
paystackCredentials?.userCredentialIds with no -1 default) and tighten the
showContent check to require a real number (e.g., typeof credentialId ===
'number'), then guard the submit handler to only call updateAppCredentials when
credentialId is a number; reference paystackCredentials, credentialId,
showContent, updateAppCredentials and the AppNotInstalledMessage branch when
making the changes.

In `@packages/app-store/paystack/api/add.ts`:
- Around line 24-48: Change the existence check to only select the id (use
prisma.credential.findFirst with select: { id: true }) and, if a credential
exists, immediately return res.status(409).json({ message: "Already installed"
}) instead of throwing new Error("Already installed"); then let the existing
catch block (which calls getServerErrorFromUnknown) only handle unexpected
errors. Ensure prisma.credential.create remains unchanged and that no
credential.key is ever selected or returned.

In `@packages/app-store/paystack/api/verify.ts`:
- Around line 45-53: The current non-atomic check of payment.success followed by
calling handlePaymentSuccess can race with the webhook; instead perform an
atomic compare-and-set update (like updateMany where id = payment.id and success
= false, set success = true) and only call handlePaymentSuccess if the update
affected at least one row. Concretely: replace the payment.success check and
subsequent unconditional call to handlePaymentSuccess with an updateMany (or
equivalent) conditional update on the payment record, then call
handlePaymentSuccess only when the update returns a count > 0; keep the existing
not-found check for payment.bookingId and use the same identifiers
(payment.bookingId, payment.success, handlePaymentSuccess, and the updateMany
pattern used in the webhook handler) so side effects aren’t duplicated.
- Line 23: The code unsafely casts req.query.reference to string; instead read
it as e.g. referenceRaw = req.query.reference, validate that it's a single
string (reject undefined or string[]), and return a 400 with a clear message if
it's missing or an array; then assign a typed const reference: string =
referenceRaw and use that in the Prisma query (where: { externalId: reference })
to avoid passing an array into Prisma. Ensure you update the logic in verify.ts
around the req.query.reference handling and error response.
- Around line 93-108: The catch currently treats the success-sentinel thrown by
handlePaymentSuccess (HttpCode with statusCode 200) as an actual error; update
the catch in verify.ts to detect if the caught err (from
getServerErrorFromUnknown) is an instance of HttpCode and has statusCode < 400,
and in that case respond with the original success shape (res.status(200).json({
status: "success", message: "Payment confirmed" })) without calling log.error;
for all other errors keep the existing log.error and the error response shape
(using err.message and conditional stack). Ensure you reference
handlePaymentSuccess, HttpCode, getServerErrorFromUnknown, and log.error when
making the change.

In `@packages/app-store/paystack/api/webhook.ts`:
- Around line 143-164: The outer catch is treating the HttpCode(200) success
sentinel as an error; update the outer catch that uses getServerErrorFromUnknown
to detect the same success sentinel (err instanceof HttpCode && err.statusCode <
400) and, if so, do not call log.error or send the error response—instead
respond with res.status(200).json({ received: true }) and return; keep the
existing behavior (log.error + res.status(err.statusCode).send(...)) only for
real errors. Ensure you reference the HttpCode sentinel check and use
res.status(...).json({ received: true }) to short-circuit the successful flow.
- Around line 66-69: The branch that throws new HttpCode when payment?.bookingId
is missing uses statusCode 204 which is semantically wrong and incompatible with
an error body; update the HttpCode thrown in this block (the throw in
packages/app-store/paystack/api/webhook.ts that checks if (!payment?.bookingId))
to use statusCode: 200 to match the other no-op webhook branches (or 404 if you
intend to signal a not-found client error), and keep the existing error message
logging (log.error(...)) so Paystack won’t retry while observability sees a
consistent status handling.

In `@packages/app-store/paystack/components/PaystackPaymentComponent.tsx`:
- Around line 38-43: Remove the redundant double-cast on payment.data by using
the typed prop directly (refer to paymentData and PaystackPaymentComponentProps)
instead of "as unknown as PaystackPaymentData"; and protect the
Intl.NumberFormat call that builds formattedAmount by validating/guarding the
currency value (currency) — e.g., attempt to construct the formatter for the
given currency in a try/catch or check currency validity and if it throws or is
invalid fall back to a safe formatting path using
convertFromSmallestToPresentableCurrencyUnit(amount, currency) with a neutral
formatter or a default currency code so the component cannot crash from a
stale/invalid ISO code.

In `@packages/app-store/paystack/lib/PaymentService.ts`:
- Around line 184-193: The deletePayment implementation currently swallows all
errors; update the try/catch around prisma.payment.delete in
deletePayment(paymentId) to catch the error as e, detect Prisma "record not
found" using Prisma.PrismaClientKnownRequestError with code 'P2025' and return
false for that case, but for any other error log the full error (use the
existing logger if available, e.g., processLogger.error or fallback to
console.error) and rethrow the error so incidents are visible; reference
prisma.payment.delete and the deletePayment method when making the change.
- Around line 162-168: The methods getPaymentPaidStatus() and
getPaymentDetails() in PaymentService.ts currently return unsafe placeholders;
either implement them properly or mark them explicitly as unreachable
placeholders: replace the hardcoded return in getPaymentPaidStatus() and the
thrown Error in getPaymentDetails() with clear TODO comments indicating they are
intentionally unimplemented (e.g., "TODO: unimplemented - placeholder for future
PaymentService implementation") or remove them if the PaymentService interface
no longer requires them; reference the exact methods getPaymentPaidStatus() and
getPaymentDetails() in the PaymentService class to locate and update the code
accordingly so callers won't receive false positives or runtime exceptions.

In `@packages/app-store/paystack/lib/PaystackClient.ts`:
- Around line 31-35: The HTTP response body is parsed with await response.json()
before checking response.ok in PaystackClient methods (verifyTransaction,
createRefund, and the other request), which throws on non-JSON error bodies;
update each method to first check response.ok (and optionally Content-Type) and
only parse JSON on success, or wrap response.json() in a try/catch and, on
failure, throw a new Error that includes response.status and response.statusText
(and raw text if available) so callers see the HTTP status and message instead
of a SyntaxError.
- Around line 22-29: All fetch calls in PaystackClient (e.g., the POST to
`${PAYSTACK_BASE_URL}/transaction/initialize` and the other verify/refund
fetches) must use a request timeout: create a private helper (e.g., private
async doRequest(url, init, timeoutMs = 15000)) that uses
AbortSignal.timeout(timeoutMs) (or an AbortController) to attach a timeout to
fetch, centralizes headers/JSON handling, and preserves existing error handling;
replace direct fetch(...) calls in methods like initializeTransaction,
verifyTransaction, and refundTransaction with calls to this helper and use a
reasonable default timeout (10–15s).
- Around line 63-83: The createRefund method returns the full Paystack envelope
but its signature Promise<{ status: boolean; data: unknown }> and team
convention expect returning json.data; change createRefund to return json.data
and update its return type accordingly (e.g., Promise<unknown> or a specific
Refund type) and ensure the error branch still throws on !response.ok ||
!json.status; also address the optional amount parameter on createRefund: either
remove amount if unused by PaymentService (reference PaymentService call sites)
or keep it and add a clarifying comment on the expected unit (kobo/ngn minor
unit) so callers know the unit expected by Paystack.

In `@packages/app-store/paystack/package.json`:
- Around line 12-13: The call to Paystack InlineJS's resumeTransaction is using
an invalid callbacks object — replace the incorrect call that passes {
onSuccess, onCancel, onError } to resumeTransaction with the correct API call
popup.resumeTransaction(access_code) (use the same access_code variable), remove
the ignored callbacks from that call, and either attach client-side event
handlers supported by InlineJS (e.g., popup.on('close') /
popup.on('paymentSuccess') if available) or move the success/error handling to
backend webhook verification; refer to the resumeTransaction call and the
onSuccess/onCancel/onError callbacks in your code and update them accordingly.

In `@packages/app-store/paystack/pages/setup/_getServerSideProps.ts`:
- Around line 29-32: Remove the unnecessary and sensitive credential.key
selection in the Prisma query inside _getServerSideProps: update the select
block (currently containing id and key) to only include id so that
credential.key is not pulled into memory; locate the Prisma query where select:
{ id: true, key: true } is used and delete the key: true entry to comply with
the "Never expose credential.key" rule.
- Around line 19-21: Guard against string[] and NaN by only accepting a single
string teamId and converting it to a number or null before calling
throwIfNotHaveAdminAccessToTeam: change how teamId is derived from query.teamId
so that if typeof query.teamId !== "string" it becomes null, otherwise parse it
with Number (or parseInt) and if the result is NaN set teamId to null; then call
throwIfNotHaveAdminAccessToTeam({ teamId, userId: session.user.id }) with this
validated value.

In `@packages/app-store/video.adapters.generated.ts`:
- Around line 5-18: The diff shows manual/cosmetic edits in the autogenerated
VideoApiAdapterMap (symbol: VideoApiAdapterMap) — do not edit *.generated.ts
files; revert this file to its previous state or regenerate the exact output by
running the generator (run yarn app-store:build from a clean main) so the file
matches the canonical generated form and only the Paystack changes remain in the
PR.

---

Nitpick comments:
In `@apps/web/app/`(use-page-wrapper)/payment/[uid]/PaymentPage.tsx:
- Around line 184-195: Remove the unused clientId prop from the JSX and from
PaystackPaymentComponentProps, and stop passing (props.payment.data as unknown
as { publicKey: string }).publicKey here; instead keep the child component
(PaystackPaymentComponent) responsible for extracting publicKey from
payment.data by typing payment.data as the PaystackPaymentData type inside
PaystackPaymentComponent, update PaystackPaymentComponent to read publicKey from
payment.data (instead of ignoring the prop), and delete the double-cast; note
PaymentService.create already writes publicKey so no extra plumbing is needed.

In `@apps/web/components/apps/paystack/Setup.tsx`:
- Around line 38-40: Replace the empty gray div shown while
integrations.isPending in the Setup component with the shared loader used by
other Setup pages (use the same component or hook used in Stripe/HitPay pages)
and ensure it includes an accessible status (role="status" or aria-live) and
visible spinner + label; specifically, update the conditional in Setup.tsx that
checks integrations.isPending to render the shared Loader component (matching
its props/placement and z-index classes) instead of the plain absolute gray div
so users and assistive tech get a proper loading indicator.

In `@packages/app-store/calendar.services.generated.ts`:
- Around line 1-17: The CalendarServiceMap autogenerated file appears
modified—confirm it was regenerated, not hand-edited: run the app-store
generator (yarn app-store:build) or yarn app-store:build --watch to regenerate
CalendarServiceMap and verify the file header and exported symbol
CalendarServiceMap are produced by the tool; if the only changes are
formatting/noise unrelated to adding the Paystack app, revert this file to the
generated output (or remove it from the commit) so generated artifacts remain
unchanged by hand edits and the generator output matches git.

In `@packages/app-store/crm.apps.generated.ts`:
- Around line 6-12: The diff shows formatting-only edits to the generated
mapping in crm.apps.generated.ts (entries like "closecom", "hubspot",
"pipedrive-crm", "salesforce", "zoho-bigin", "zohocrm"); do not edit this
generated file by hand—revert these manual changes and restore the original
crm.apps.generated.ts, then if the change is actually required, re-run the
app-store CLI generator that produces crm.apps.generated.ts so the update is
applied correctly rather than editing the file directly.

In `@packages/app-store/paystack/_metadata.ts`:
- Line 19: The isOAuth: true flag in packages/app-store/paystack/_metadata.ts is
misleading for an API-key based integration; rename the property to a clearer
name such as requiresSetup (or hasAccountConfiguration) in that metadata object
and update all references that route the installation flow (the
installation/account-setup logic and any checks against isOAuth, and the
App.d.ts type) to use the new property name so semantics reflect "requires
setup" rather than OAuth; also update the App.d.ts documentation to describe the
new field and add a brief code comment in the Paystack metadata explaining this
is an API-key integration.

In `@packages/app-store/paystack/components/PaystackPaymentComponent.tsx`:
- Around line 72-80: The error handlers in PaystackPaymentComponent.tsx
currently swallow underlying errors; update the onError handler for the Paystack
SDK and the outer catch to accept the error (e.g., err), log it (console.error
or your app logger) and surface useful info in the status/message flows (call
setStatus("error"), setErrorMessage(...) as before but include or append
err.message or a short descriptor). Target the onError callback and the catch
block that call setStatus/setErrorMessage so both log the raw error and preserve
the user-facing "payment_failed_try_again" text.

In `@packages/app-store/paystack/index.ts`:
- Around line 1-2: Remove the barrel export file index.ts that re-exports api
and lib: delete the file exporting "export * as api from \"./api\";" and "export
* as lib from \"./lib\"". Ensure no codebase imports from the package root
(e.g., `@calcom/app-store/paystack`) remain—if any import exists, update them to
direct subpath imports such as "./api" or "./lib/PaymentService". Commit the
deletion and run project-wide import checks/tsc to confirm no breakage.

In `@packages/app-store/paystack/lib/__tests__/PaystackClient.test.ts`:
- Around line 6-11: The test suite stubs global fetch with vi.stubGlobal and
doesn't restore it, risking leaks to other tests; add an afterEach that calls
vi.unstubAllGlobals() (or at least vi.unstubGlobal("fetch")) to restore globals
after each test in the describe block that initializes the PaystackClient in
beforeEach (PaystackClient, client) so the stubbed fetch won't affect other
suites.
- Around line 87-158: Add missing negative-path unit tests for
PaystackClient.verifyTransaction and PaystackClient.createRefund to mirror the
existing initializeTransaction non-OK response test: stub global fetch to return
ok: false (and a json payload if needed), call client.verifyTransaction("...")
and client.createRefund({ transaction: "..." }) respectively, and assert they
throw or return the same error-handling outcome as initializeTransaction (i.e.,
verify the error is thrown/rejected or the returned error shape), so regressions
in the shared response.ok handling are covered.

In `@packages/app-store/paystack/lib/__tests__/verifyWebhookSignature.test.ts`:
- Around line 7-28: The tests never exercise the timingSafeEqual catch branch in
verifyWebhookSignature where differing buffer lengths cause an exception; add a
new test case that supplies the same JSON body and secretKey but a
length-mismatched signature (e.g., a short string like "shortsig" or a hex with
different length) and assert verifyWebhookSignature(body, mismatchedSignature,
secretKey) returns false so the catch branch is covered.

In `@packages/app-store/paystack/lib/currencyOptions.ts`:
- Around line 9-19: The current currencySymbols is typed as
Record<string,string> which collapses keyof typeof currencySymbols to string;
remove that explicit annotation and define currencySymbols as a const literal
(e.g. export const currencySymbols = { ngn: "₦", ghs: "GH₵", zar: "R", kes:
"KSh", usd: "$" } as const) so keyof typeof currencySymbols becomes the literal
union; you can then keep isAcceptedCurrencyCode(code: string): code is keyof
typeof currencySymbols as-is (or optionally export a CurrencyCode = keyof typeof
currencySymbols type) and ensure no other annotations override the inferred
literal type.

In `@packages/app-store/paystack/lib/PaymentService.ts`:
- Line 59: Replace the direct env usage in PaymentService where callback_url is
built with the normalized WEBAPP_URL constant: import { WEBAPP_URL } from
"@calcom/lib/constants" at the top of the file and change the callback_url value
from `${process.env.NEXT_PUBLIC_WEBAPP_URL}/api/integrations/paystack/verify` to
`${WEBAPP_URL}/api/integrations/paystack/verify`; this ensures PaymentService
uses the centralized WEBAPP_URL fallback logic used elsewhere (e.g.,
EventTypeAppCardInterface) and prevents undefined callback URLs in preview
environments.

In `@packages/app-store/paystack/zod.ts`:
- Around line 6-15: The numeric fields in appDataSchema currently accept any
number (including negatives/NaN); update the schema so price enforces the UI
minimum and finite values (e.g., replace price: z.number() with
z.number().min(0.5).finite()) and tighten refundDaysCount to a non-negative
integer (e.g., refundDaysCount: z.number().int().min(0).optional()) to mirror
client-side constraints; keep other fields unchanged and reference
appDataSchema, price, and refundDaysCount when making the changes.
🪄 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: fa825efc-0d7d-4bf8-a18f-52a055474947

📥 Commits

Reviewing files that changed from the base of the PR and between a17f28e and 441e7e0.

⛔ Files ignored due to path filters (2)
  • packages/app-store/paystack/static/icon.svg is excluded by !**/*.svg
  • yarn.lock is excluded by !**/yarn.lock, !**/*.lock
📒 Files selected for processing (37)
  • apps/web/app/(use-page-wrapper)/payment/[uid]/PaymentPage.tsx
  • apps/web/components/apps/AppSetupPage.tsx
  • apps/web/components/apps/paystack/Setup.tsx
  • packages/app-store/_pages/setup/_getServerSideProps.tsx
  • packages/app-store/analytics.services.generated.ts
  • packages/app-store/apps.browser.generated.tsx
  • packages/app-store/apps.keys-schemas.generated.ts
  • packages/app-store/apps.metadata.generated.ts
  • packages/app-store/apps.schemas.generated.ts
  • packages/app-store/apps.server.generated.ts
  • packages/app-store/bookerApps.metadata.generated.ts
  • packages/app-store/calendar.services.generated.ts
  • packages/app-store/crm.apps.generated.ts
  • packages/app-store/payment.services.generated.ts
  • packages/app-store/paystack/_metadata.ts
  • packages/app-store/paystack/api/add.ts
  • packages/app-store/paystack/api/index.ts
  • packages/app-store/paystack/api/verify.ts
  • packages/app-store/paystack/api/webhook.ts
  • packages/app-store/paystack/components/EventTypeAppCardInterface.tsx
  • packages/app-store/paystack/components/EventTypeAppSettingsInterface.tsx
  • packages/app-store/paystack/components/PaystackPaymentComponent.tsx
  • packages/app-store/paystack/config.json
  • packages/app-store/paystack/index.ts
  • packages/app-store/paystack/lib/PaymentService.ts
  • packages/app-store/paystack/lib/PaystackClient.ts
  • packages/app-store/paystack/lib/__tests__/PaystackClient.test.ts
  • packages/app-store/paystack/lib/__tests__/verifyWebhookSignature.test.ts
  • packages/app-store/paystack/lib/currencyOptions.ts
  • packages/app-store/paystack/lib/verifyWebhookSignature.ts
  • packages/app-store/paystack/package.json
  • packages/app-store/paystack/pages/setup/_getServerSideProps.ts
  • packages/app-store/paystack/zod.ts
  • packages/app-store/redirect-apps.generated.ts
  • packages/app-store/video.adapters.generated.ts
  • packages/i18n/locales/en/common.json
  • scripts/seed-app-store.ts

Comment on lines +23 to +26
const [paystackCredentials] = integrations.data?.items || [];
const [credentialId] = paystackCredentials?.userCredentialIds || [-1];

const showContent = !!integrations.data && integrations.isSuccess && !!credentialId;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

credentialId fallback of -1 is treated as valid and renders the form.

const [credentialId] = paystackCredentials?.userCredentialIds || [-1];
const showContent = !!integrations.data && integrations.isSuccess && !!credentialId;

!!-1 is true, so when paystackCredentials is missing, the form renders anyway and any save will hit the tRPC updateAppCredentials handler with credentialId: -1, which throws BAD_REQUEST: Could not find credential -1. The user sees an error toast instead of the AppNotInstalledMessage branch.

🐛 Proposed fix
-  const [paystackCredentials] = integrations.data?.items || [];
-  const [credentialId] = paystackCredentials?.userCredentialIds || [-1];
-
-  const showContent = !!integrations.data && integrations.isSuccess && !!credentialId;
+  const [paystackCredentials] = integrations.data?.items || [];
+  const credentialId = paystackCredentials?.userCredentialIds?.[0];
+  const showContent =
+    !!integrations.data && integrations.isSuccess && typeof credentialId === "number" && credentialId > 0;

And guard the submit so credentialId is narrowed to number before calling the mutation.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const [paystackCredentials] = integrations.data?.items || [];
const [credentialId] = paystackCredentials?.userCredentialIds || [-1];
const showContent = !!integrations.data && integrations.isSuccess && !!credentialId;
const [paystackCredentials] = integrations.data?.items || [];
const credentialId = paystackCredentials?.userCredentialIds?.[0];
const showContent =
!!integrations.data && integrations.isSuccess && typeof credentialId === "number" && credentialId > 0;
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/web/components/apps/paystack/Setup.tsx` around lines 23 - 26, The
current fallback sets credentialId to -1 which is truthy, so showContent renders
the form and submissions call updateAppCredentials with credentialId -1; change
the fallback so credentialId is undefined when paystackCredentials is missing
(e.g., derive credentialId from paystackCredentials?.userCredentialIds with no
-1 default) and tighten the showContent check to require a real number (e.g.,
typeof credentialId === 'number'), then guard the submit handler to only call
updateAppCredentials when credentialId is a number; reference
paystackCredentials, credentialId, showContent, updateAppCredentials and the
AppNotInstalledMessage branch when making the changes.

Comment on lines +24 to +48
try {
const alreadyInstalled = await prisma.credential.findFirst({
where: {
type: appType,
...ownerFilter,
},
});
if (alreadyInstalled) {
throw new Error("Already installed");
}
await prisma.credential.create({
data: {
type: appType,
key: {},
appId: "paystack",
...ownerFilter,
},
});
} catch (error: unknown) {
if (error instanceof Error && error.message === "Already installed") {
return res.status(409).json({ message: error.message });
}
const httpError = getServerErrorFromUnknown(error);
return res.status(httpError.statusCode).json({ message: httpError.message });
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Avoid the string-sentinel error for "already installed".

Throwing a generic Error("Already installed") only to re-detect it by error.message in the catch is brittle (any future thrown Error with that exact message collides) and violates the project's error convention for non-tRPC code.

Simplest fix: respond directly and let the catch handle only unexpected errors.

♻️ Proposed fix
   try {
     const alreadyInstalled = await prisma.credential.findFirst({
       where: {
         type: appType,
         ...ownerFilter,
       },
+      select: { id: true },
     });
     if (alreadyInstalled) {
-      throw new Error("Already installed");
+      return res.status(409).json({ message: "Already installed" });
     }
     await prisma.credential.create({
       data: {
         type: appType,
         key: {},
         appId: "paystack",
         ...ownerFilter,
       },
     });
   } catch (error: unknown) {
-    if (error instanceof Error && error.message === "Already installed") {
-      return res.status(409).json({ message: error.message });
-    }
     const httpError = getServerErrorFromUnknown(error);
     return res.status(httpError.statusCode).json({ message: httpError.message });
   }

Also added select: { id: true } so we don't pull the whole credential (including key) just to test existence.

As per coding guidelines: "Use ErrorWithCode for errors in non-tRPC files (services, repositories, utilities); use TRPCError only in tRPC routers" and "Never expose credential.key field in API responses or queries".

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
try {
const alreadyInstalled = await prisma.credential.findFirst({
where: {
type: appType,
...ownerFilter,
},
});
if (alreadyInstalled) {
throw new Error("Already installed");
}
await prisma.credential.create({
data: {
type: appType,
key: {},
appId: "paystack",
...ownerFilter,
},
});
} catch (error: unknown) {
if (error instanceof Error && error.message === "Already installed") {
return res.status(409).json({ message: error.message });
}
const httpError = getServerErrorFromUnknown(error);
return res.status(httpError.statusCode).json({ message: httpError.message });
}
try {
const alreadyInstalled = await prisma.credential.findFirst({
where: {
type: appType,
...ownerFilter,
},
select: { id: true },
});
if (alreadyInstalled) {
return res.status(409).json({ message: "Already installed" });
}
await prisma.credential.create({
data: {
type: appType,
key: {},
appId: "paystack",
...ownerFilter,
},
});
} catch (error: unknown) {
const httpError = getServerErrorFromUnknown(error);
return res.status(httpError.statusCode).json({ message: httpError.message });
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/app-store/paystack/api/add.ts` around lines 24 - 48, Change the
existence check to only select the id (use prisma.credential.findFirst with
select: { id: true }) and, if a credential exists, immediately return
res.status(409).json({ message: "Already installed" }) instead of throwing new
Error("Already installed"); then let the existing catch block (which calls
getServerErrorFromUnknown) only handle unexpected errors. Ensure
prisma.credential.create remains unchanged and that no credential.key is ever
selected or returned.

throw new HttpCode({ statusCode: 405, message: "Method Not Allowed" });
}

const reference = req.query.reference as string;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Unsafe cast for req.query.reference.

Next.js typing is string | string[] | undefined. If Paystack (or a client) sends ?reference=a&reference=b, this cast silently produces an array at runtime, and the subsequent Prisma where: { externalId: reference } will receive an array — throwing an opaque Prisma error rather than the intended 400.

-    const reference = req.query.reference as string;
-    if (!reference) {
+    const { reference } = req.query;
+    if (typeof reference !== "string" || !reference) {
       throw new HttpCode({ statusCode: 400, message: "Missing reference parameter" });
     }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/app-store/paystack/api/verify.ts` at line 23, The code unsafely
casts req.query.reference to string; instead read it as e.g. referenceRaw =
req.query.reference, validate that it's a single string (reject undefined or
string[]), and return a 400 with a clear message if it's missing or an array;
then assign a typed const reference: string = referenceRaw and use that in the
Prisma query (where: { externalId: reference }) to avoid passing an array into
Prisma. Ensure you update the logic in verify.ts around the req.query.reference
handling and error response.

Comment on lines +45 to +53
if (!payment?.bookingId) {
throw new HttpCode({ statusCode: 404, message: "Payment not found" });
}

// Already processed
if (payment.success) {
res.status(200).json({ status: "success", message: "Payment already confirmed" });
return;
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Race condition: non-atomic check-then-call can duplicate side effects.

Line 50 reads payment.success and line 93 calls handlePaymentSuccess only if it's false. If the Paystack webhook arrives nearly simultaneously with the client redirect hitting this endpoint (very common — Paystack posts the webhook as soon as the charge succeeds), both paths can observe success: false and both will invoke handlePaymentSuccess, duplicating calendar event creation, BOOKING_PAID webhooks, workflows, and emails.

The webhook handler guards against this with an atomic compare-and-set (updateMany({ where: { id, success: false }, data: { success: true } }), webhook.ts:110-119). Apply the same pattern here before calling handlePaymentSuccess.

🛠️ Proposed fix
-    // Confirm booking
+    // Atomic idempotency: only proceed if we can flip success from false to true
+    const claimed = await prisma.payment.updateMany({
+      where: { id: payment.id, success: false },
+      data: { success: true },
+    });
+    if (claimed.count === 0) {
+      res.status(200).json({ status: "success", message: "Payment already confirmed" });
+      return;
+    }
+
     const traceContext = distributedTracing.createTrace("paystack_verify", {
       meta: { reference, bookingId: payment.bookingId },
     });
-
-    await handlePaymentSuccess({
-      paymentId: payment.id,
-      bookingId: payment.bookingId,
-      appSlug: "paystack",
-      traceContext,
-    });
+
+    try {
+      await handlePaymentSuccess({
+        paymentId: payment.id,
+        bookingId: payment.bookingId,
+        appSlug: "paystack",
+        traceContext,
+      });
+    } catch (processingError) {
+      const isSuccessSentinel =
+        processingError instanceof HttpCode && processingError.statusCode < 400;
+      if (!isSuccessSentinel) {
+        await prisma.payment.update({ where: { id: payment.id }, data: { success: false } });
+      }
+      throw processingError;
+    }

Also applies to: 93-98

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/app-store/paystack/api/verify.ts` around lines 45 - 53, The current
non-atomic check of payment.success followed by calling handlePaymentSuccess can
race with the webhook; instead perform an atomic compare-and-set update (like
updateMany where id = payment.id and success = false, set success = true) and
only call handlePaymentSuccess if the update affected at least one row.
Concretely: replace the payment.success check and subsequent unconditional call
to handlePaymentSuccess with an updateMany (or equivalent) conditional update on
the payment record, then call handlePaymentSuccess only when the update returns
a count > 0; keep the existing not-found check for payment.bookingId and use the
same identifiers (payment.bookingId, payment.success, handlePaymentSuccess, and
the updateMany pattern used in the webhook handler) so side effects aren’t
duplicated.

Comment on lines +93 to +108
await handlePaymentSuccess({
paymentId: payment.id,
bookingId: payment.bookingId,
appSlug: "paystack",
traceContext,
});

res.status(200).json({ status: "success", message: "Payment confirmed" });
} catch (_err) {
const err = getServerErrorFromUnknown(_err);
log.error(`Verify Error: ${err.message}`, safeStringify(err));
res.status(err.statusCode).json({
message: err.message,
stack: IS_PRODUCTION ? undefined : err.cause?.stack,
});
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Confirm PaystackPaymentComponent's expected response shape from the verify endpoint.
fd -t f 'PaystackPaymentComponent' | xargs rg -nC3 'verify|status|reference'

Repository: calcom/cal.diy

Length of output: 1489


🏁 Script executed:

# Search for handlePaymentSuccess implementation
fd -type f 'handlePaymentSuccess'

Repository: calcom/cal.diy

Length of output: 228


🏁 Script executed:

# Search for handlePaymentSuccess to understand the throw pattern
rg -n 'throw.*HttpCode.*200|HttpCode.*statusCode.*200' packages/app-store --type ts --type tsx

Repository: calcom/cal.diy

Length of output: 85


🏁 Script executed:

# Look at webhook.ts to confirm the instanceof HttpCode pattern mentioned
rg -n -B2 -A2 'instanceof HttpCode.*statusCode < 400' packages/app-store

Repository: calcom/cal.diy

Length of output: 604


🏁 Script executed:

# Get full context of verify.ts around the catch block
cat -n packages/app-store/paystack/api/verify.ts | sed -n '85,115p'

Repository: calcom/cal.diy

Length of output: 926


🏁 Script executed:

# Check for any response parsing/branching on status field in related payment components
rg -n 'response.*status.*success|body\.status|\.status.*success' packages/app-store --type ts --type tsx | head -20

Repository: calcom/cal.diy

Length of output: 85


🏁 Script executed:

# Find handlePaymentSuccess file
find packages/app-store -name "*handlePaymentSuccess*" -o -name "*payments*" | grep -i handlepayment

Repository: calcom/cal.diy

Length of output: 180


🏁 Script executed:

# Search for HttpCode throws in handlePaymentSuccess
rg -n 'throw new HttpCode|throw HttpCode' packages/app-store -A 2 -B 2

Repository: calcom/cal.diy

Length of output: 19898


🏁 Script executed:

# Check what handlePaymentSuccess actually does
rg -n 'export.*handlePaymentSuccess|function handlePaymentSuccess' packages/app-store -A 15

Repository: calcom/cal.diy

Length of output: 1683


🏁 Script executed:

# Verify the race condition claim by looking at payment.success check
rg -n 'payment\.success' packages/app-store/paystack/api/verify.ts -B 3 -A 3

Repository: calcom/cal.diy

Length of output: 246


🏁 Script executed:

# Check if PaystackPaymentComponent reads the verify response at all
rg -n 'fetch.*verify' packages/app-store -A 5 | head -40

Repository: calcom/cal.diy

Length of output: 2327


🏁 Script executed:

# Verify if there's a similar early check in webhook.ts to confirm race mitigation
rg -n 'if.*payment.*success' packages/app-store/paystack/api/webhook.ts -B 2 -A 2

Repository: calcom/cal.diy

Length of output: 40


🏁 Script executed:

# Search webhook.ts more broadly to see the full flow around handlePaymentSuccess
sed -n '50,150p' packages/app-store/paystack/api/webhook.ts

Repository: calcom/cal.diy

Length of output: 3701


Missing HttpCode discriminator causes successful verifications to be logged as errors with incorrect response shape.

handlePaymentSuccess intentionally throws HttpCode({ statusCode: 200 }) as its success sentinel. The verify endpoint does not discriminate this from actual errors:

  • Line 100 is unreachable — handlePaymentSuccess throws on success, never returns normally.
  • The catch block at line 101 calls log.error(...), logging successful payment confirmations as errors.
  • The response body becomes { message: "Booking with id '…' was paid and confirmed.", stack: undefined } instead of { status: "success", message: "Payment confirmed" }.

The webhook handler already recognizes this pattern (webhook.ts:145: processingError instanceof HttpCode && processingError.statusCode < 400). Apply the same check here.

🛠️ Proposed fix
     await handlePaymentSuccess({
       paymentId: payment.id,
       bookingId: payment.bookingId,
       appSlug: "paystack",
       traceContext,
     });

     res.status(200).json({ status: "success", message: "Payment confirmed" });
   } catch (_err) {
+    if (_err instanceof HttpCode && _err.statusCode < 400) {
+      // handlePaymentSuccess signals success via HttpCode(200)
+      res.status(200).json({ status: "success", message: "Payment confirmed" });
+      return;
+    }
     const err = getServerErrorFromUnknown(_err);
     log.error(`Verify Error: ${err.message}`, safeStringify(err));
     res.status(err.statusCode).json({
       message: err.message,
       stack: IS_PRODUCTION ? undefined : err.cause?.stack,
     });
   }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/app-store/paystack/api/verify.ts` around lines 93 - 108, The catch
currently treats the success-sentinel thrown by handlePaymentSuccess (HttpCode
with statusCode 200) as an actual error; update the catch in verify.ts to detect
if the caught err (from getServerErrorFromUnknown) is an instance of HttpCode
and has statusCode < 400, and in that case respond with the original success
shape (res.status(200).json({ status: "success", message: "Payment confirmed"
})) without calling log.error; for all other errors keep the existing log.error
and the error response shape (using err.message and conditional stack). Ensure
you reference handlePaymentSuccess, HttpCode, getServerErrorFromUnknown, and
log.error when making the change.

Comment on lines +63 to +83
async createRefund(params: {
transaction: string;
amount?: number;
}): Promise<{ status: boolean; data: unknown }> {
const response = await fetch(`${PAYSTACK_BASE_URL}/refund`, {
method: "POST",
headers: {
Authorization: `Bearer ${this.secretKey}`,
"Content-Type": "application/json",
},
body: JSON.stringify(params),
});

const json = await response.json();

if (!response.ok || !json.status) {
throw new Error(`Paystack API error: ${json.message || "Unknown error"}`);
}

return json;
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Confirm what PaymentService passes as refund amount and how it's returned to callers
rg -nP -C5 'createRefund' packages/app-store/paystack/

Repository: calcom/cal.diy

Length of output: 3880


🏁 Script executed:

#!/bin/bash
# Read the full PaystackClient.ts file to see all methods and their return types
cat -n packages/app-store/paystack/lib/PaystackClient.ts

Repository: calcom/cal.diy

Length of output: 2741


🏁 Script executed:

#!/bin/bash
# Search for all createRefund calls across the codebase to see if amount is ever used
rg -n 'createRefund' --type ts --type tsx -B2 -A2 packages/

Repository: calcom/cal.diy

Length of output: 85


🏁 Script executed:

#!/bin/bash
# Search for all createRefund calls across the codebase with corrected syntax
rg -n 'createRefund' packages/ -A2 -B2

Repository: calcom/cal.diy

Length of output: 1774


🏁 Script executed:

#!/bin/bash
# Check if there's a type definition for Paystack responses
rg -n 'PaystackEnvelope|RefundData|data.*unknown' packages/app-store/paystack/

Repository: calcom/cal.diy

Length of output: 958


Return type mismatch and inconsistent pattern in createRefund.

The signature declares Promise<{ status: boolean; data: unknown }> but the method returns the full JSON envelope ({ status, message, data, … }). Other methods in the class return json.data; createRefund should do the same for consistency. Update line 82 to return json.data; and adjust the return type accordingly.

The amount parameter is declared optional but never passed by PaymentService (line 152-154) or in tests. If this parameter isn't needed, remove it; if it is needed, add a comment clarifying the unit expected by Paystack's API.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/app-store/paystack/lib/PaystackClient.ts` around lines 63 - 83, The
createRefund method returns the full Paystack envelope but its signature
Promise<{ status: boolean; data: unknown }> and team convention expect returning
json.data; change createRefund to return json.data and update its return type
accordingly (e.g., Promise<unknown> or a specific Refund type) and ensure the
error branch still throws on !response.ok || !json.status; also address the
optional amount parameter on createRefund: either remove amount if unused by
PaymentService (reference PaymentService call sites) or keep it and add a
clarifying comment on the expected unit (kobo/ngn minor unit) so callers know
the unit expected by Paystack.

Comment on lines +12 to +13
"@paystack/inline-js": "^2.0.0",
"uuid": "^9.0.0"
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

🌐 Web query:

@paystack/inline-js latest version npm resumeTransaction API

💡 Result:

The latest version of @paystack/inline-js on npm is 2.22.8, published about 2 months ago (around February 2026). The resumeTransaction API is part of Paystack InlineJS v2 (imported as PaystackPop). It completes a pre-initialized transaction using an access_code obtained from the backend Initialize Transaction API. Usage: 1. Initialize transaction on backend: POST to Paystack's Initialize Transaction endpoint to get access_code. 2. On frontend: import PaystackPop from '@paystack/inline-js'; const popup = new PaystackPop; popup.resumeTransaction('access_code'); This triggers the checkout popup for the user to select a payment channel and complete the transaction. Official docs recommend backend initialization for control over transaction details, followed by frontend completion with resumeTransaction. Always verify transaction status on backend after success.

Citations:


🏁 Script executed:

find packages/app-store/paystack -type f \( -name "*.ts" -o -name "*.tsx" -o -name "*.js" -o -name "*.jsx" \) | head -20

Repository: calcom/cal.diy

Length of output: 955


🏁 Script executed:

rg "resumeTransaction" packages/app-store/paystack --type ts --type tsx -B 2 -A 2

Repository: calcom/cal.diy

Length of output: 85


🏁 Script executed:

rg "resumeTransaction" packages/app-store/paystack -B 2 -A 2

Repository: calcom/cal.diy

Length of output: 559


🏁 Script executed:

cat -n packages/app-store/paystack/components/PaystackPaymentComponent.tsx

Repository: calcom/cal.diy

Length of output: 4156


The resumeTransaction API signature is incorrect and won't work as implemented.

The code passes { onSuccess, onCancel, onError } callbacks to resumeTransaction, but Paystack InlineJS v2 (including latest 2.22.8) does not accept a callbacks object. The API only takes the access code: popup.resumeTransaction(access_code).

The callbacks defined at lines 54-75 will be ignored, meaning payment success, cancellation, and error states won't be handled. Replace with event listening or move success/error logic to the backend webhook verification instead.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/app-store/paystack/package.json` around lines 12 - 13, The call to
Paystack InlineJS's resumeTransaction is using an invalid callbacks object —
replace the incorrect call that passes { onSuccess, onCancel, onError } to
resumeTransaction with the correct API call popup.resumeTransaction(access_code)
(use the same access_code variable), remove the ignored callbacks from that
call, and either attach client-side event handlers supported by InlineJS (e.g.,
popup.on('close') / popup.on('paymentSuccess') if available) or move the
success/error handling to backend webhook verification; refer to the
resumeTransaction call and the onSuccess/onCancel/onError callbacks in your code
and update them accordingly.

Comment on lines +19 to +21
const teamId = query.teamId ? Number(query.teamId) : null;

await throwIfNotHaveAdminAccessToTeam({ teamId, userId: session.user.id });
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Guard against NaN/array values for teamId.

query.teamId can be string | string[] | undefined. Number(["1","2"]) yields NaN, and throwIfNotHaveAdminAccessToTeam will be called with teamId: NaN, which is neither a valid number nor null. The sibling add.ts handler already validates this; mirror it here.

🛡️ Proposed fix
-  const teamId = query.teamId ? Number(query.teamId) : null;
-
-  await throwIfNotHaveAdminAccessToTeam({ teamId, userId: session.user.id });
+  const rawTeamId = Array.isArray(query.teamId) ? query.teamId[0] : query.teamId;
+  const teamId = rawTeamId ? Number(rawTeamId) : null;
+  if (teamId !== null && Number.isNaN(teamId)) return notFound;
+
+  await throwIfNotHaveAdminAccessToTeam({ teamId, userId: session.user.id });
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/app-store/paystack/pages/setup/_getServerSideProps.ts` around lines
19 - 21, Guard against string[] and NaN by only accepting a single string teamId
and converting it to a number or null before calling
throwIfNotHaveAdminAccessToTeam: change how teamId is derived from query.teamId
so that if typeof query.teamId !== "string" it becomes null, otherwise parse it
with Number (or parseInt) and if the result is NaN set teamId to null; then call
throwIfNotHaveAdminAccessToTeam({ teamId, userId: session.user.id }) with this
validated value.

Comment on lines +29 to +32
select: {
id: true,
key: true,
},
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Drop key: true from the Prisma select.

Only credential.id is used; selecting key unnecessarily pulls the raw secret into memory and violates the project rule against exposing credentials.

🔒 Proposed fix
     select: {
       id: true,
-      key: true,
     },

As per coding guidelines: "Never expose credential.key field in API responses or queries".

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
select: {
id: true,
key: true,
},
select: {
id: true,
},
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/app-store/paystack/pages/setup/_getServerSideProps.ts` around lines
29 - 32, Remove the unnecessary and sensitive credential.key selection in the
Prisma query inside _getServerSideProps: update the select block (currently
containing id and key) to only include id so that credential.key is not pulled
into memory; locate the Prisma query where select: { id: true, key: true } is
used and delete the key: true entry to comply with the "Never expose
credential.key" rule.

Comment on lines +5 to +18
export const VideoApiAdapterMap = process.env.NEXT_PUBLIC_IS_E2E === '1' ? {} : {
"dailyvideo": import("./dailyvideo/lib/VideoApiAdapter"),
"huddle01video": import("./huddle01video/lib/VideoApiAdapter"),
"jelly": import("./jelly/lib/VideoApiAdapter"),
"jitsivideo": import("./jitsivideo/lib/VideoApiAdapter"),
"lyra": import("./lyra/lib/VideoApiAdapter"),
"nextcloudtalk": import("./nextcloudtalk/lib/VideoApiAdapter"),
"office365video": import("./office365video/lib/VideoApiAdapter"),
"shimmervideo": import("./shimmervideo/lib/VideoApiAdapter"),
"sylapsvideo": import("./sylapsvideo/lib/VideoApiAdapter"),
"tandemvideo": import("./tandemvideo/lib/VideoApiAdapter"),
"webex": import("./webex/lib/VideoApiAdapter"),
"zoomvideo": import("./zoomvideo/lib/VideoApiAdapter"),
}; No newline at end of file
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Avoid manual edits to autogenerated file; re-run the generator.

This file is marked autogenerated (header comment) and the PR's scope is adding Paystack (a payment app, not a video adapter). The diff here is purely cosmetic (quoted keys, single quotes, missing trailing newline) and doesn't add/remove any adapter entries, which strongly suggests it was either hand-edited or produced by a locally diverged generator. Either way it creates churn that will flip back on the next legitimate yarn app-store:build run by another contributor.

Please revert this file to its previous state (or regenerate via yarn app-store:build from a clean main) so the PR only contains the Paystack-related changes.

As per coding guidelines: "Never modify *.generated.ts files directly - they're created by app-store-cli".

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/app-store/video.adapters.generated.ts` around lines 5 - 18, The diff
shows manual/cosmetic edits in the autogenerated VideoApiAdapterMap (symbol:
VideoApiAdapterMap) — do not edit *.generated.ts files; revert this file to its
previous state or regenerate the exact output by running the generator (run yarn
app-store:build from a clean main) so the file matches the canonical generated
form and only the Paystack changes remain in the PR.

@romitg2 romitg2 closed this May 6, 2026
@MarvelNwachukwu
Copy link
Copy Markdown
Author

Hey @romitg2,

Got notification this was closed is there a reason why? I really looked forward to contributing to cal.com

@romitg2
Copy link
Copy Markdown
Member

romitg2 commented May 9, 2026

Hey @romitg2,

Got notification this was closed is there a reason why? I really looked forward to contributing to cal.com

please reopen with addressing coderabbit comments

@MarvelNwachukwu
Copy link
Copy Markdown
Author

Hi @romitg2 — GitHub's API refused to reopen this PR (UNPROCESSABLE, no detail), and the reopen button isn't visible from my side either.

I've opened #29296 from the same branch with all the coderabbitai/cubic-dev-ai comments addressed and upstream/main merged in. Commit messages cite which reviewer raised each concern. Locally: 8/8 paystack tests pass and tsc --noEmit -p packages/app-store/tsconfig.json exits 0.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants