Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 41 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,7 @@ const Checkout = () => {
variable_name: 'order_id',
value: 'OID1234'
}

]
},
onSuccess: (res) => console.log('Success:', res),
Expand Down Expand Up @@ -124,6 +125,7 @@ const Checkout = () => {
| `publicKey` | `string` | — | Your Paystack public key |
| `currency` | `string` | — | Currency code (optional) |
| `defaultChannels` | `string[]`| `['card']`| Payment channels |
| `deepLinkHosts` | `(string \| RegExp)[]` | `[]` | Extra hosts to hand off to the OS instead of loading in the WebView (see [Deep linking](#-deep-linking)) |
| `debug` | `boolean` | `false` | Show debug logs |
| `onGlobalSuccess` | `func` | — | Called on all successful transactions |
| `onGlobalCancel` | `func` | — | Called on all cancelled transactions |
Expand Down Expand Up @@ -185,6 +187,45 @@ const Checkout = () => {

---

## 🔗 Deep linking

Some payment channels need to hand off to a partner app. For example, the **Zap** channel renders a universal link to `https://joinzap.com/app/...` that should open the Zap app.

WKWebView on iOS does **not** perform universal-link handoff or custom-scheme dispatch for navigations that originate inside the WebView, so tapping such a link would otherwise just load it as a web page. To fix this, the provider intercepts these navigations and forwards them to the OS via `Linking.openURL`, which triggers the universal-link handoff (iOS) or App Links / `Intent.ACTION_VIEW` dispatch (Android).

`https://joinzap.com/app/` ships as a built-in default and **always applies** — it can't be removed. Use the `deepLinkHosts` prop to add your own partner hosts on top of the defaults:

```tsx
<PaystackProvider
publicKey="pk_test_XXXXXX"
deepLinkHosts={['mypartner://', /^https?:\/\/my-partner\.app\//]}
>
<App />
</PaystackProvider>
```

Each entry is matched against the navigation URL. String entries match if the URL **starts with** the string; `RegExp` entries are matched with `RegExp.test(url)`.

> ⚠️ Only add hosts that should leave the WebView. Do **not** add 3DS / issuer ACS challenge pages, bank-redirect flows, or `checkout.paystack.com` — those must stay in the WebView for checkout to complete.

### Returning to your app from Zap

When the customer is sent to the Zap app, you can have Zap return to your app after the action completes by passing a `callback_url` in the transaction `metadata`. Set it to a URL (or deep link) that resolves back to your app:

```tsx
popup.checkout({
email: 'jane.doe@example.com',
amount: 5000,
metadata: {
callback_url: 'https://paystack.com',
},
onSuccess: (res) => console.log('Success:', res),
onCancel: () => console.log('User cancelled'),
});
```

---

## 🧪 Debugging

Enable `debug={true}` on the `PaystackProvider` to get logs like:
Expand Down
69 changes: 66 additions & 3 deletions __tests__/index.test.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import { validateParams, sanitize, generatePaystackParams } from '../development/utils';
import { Alert } from 'react-native';
import { validateParams, sanitize, generatePaystackParams, shouldHandleExternally, openExternalUrl } from '../development/utils';
import { Alert, Linking } from 'react-native';

jest.mock('react-native', () => ({
Alert: { alert: jest.fn() }
Alert: { alert: jest.fn() },
Linking: { canOpenURL: jest.fn(), openURL: jest.fn() }
}));

describe('Paystack Utils', () => {
Expand Down Expand Up @@ -84,4 +85,66 @@ describe('Paystack Utils', () => {
expect(js).toContain("amount: 10000");
});
});

describe('shouldHandleExternally', () => {
it('matches a string host by prefix', () => {
expect(
shouldHandleExternally('https://joinzap.com/app/abc', ['https://joinzap.com/app/'])
).toBe(true);
});

it('does not match when only part of the URL contains the prefix', () => {
expect(
shouldHandleExternally('https://evil.com/?u=https://joinzap.com/app/', ['https://joinzap.com/app/'])
).toBe(false);
});

it('matches a RegExp host', () => {
expect(
shouldHandleExternally('mypartner://pay', [/^mypartner:\/\//])
).toBe(true);
});

it('returns false when no host matches', () => {
expect(
shouldHandleExternally('https://checkout.paystack.com/123', ['https://joinzap.com/app/'])
).toBe(false);
});

it('returns false for an empty URL', () => {
expect(shouldHandleExternally('', ['https://joinzap.com/app/'])).toBe(false);
});
});

describe('openExternalUrl', () => {
beforeEach(() => {
(Linking.canOpenURL as jest.Mock).mockReset();
(Linking.openURL as jest.Mock).mockReset();
});

it('opens the URL when an app can handle it', async () => {
(Linking.canOpenURL as jest.Mock).mockResolvedValue(true);
(Linking.openURL as jest.Mock).mockResolvedValue(undefined);

await openExternalUrl('https://joinzap.com/app/abc');

expect(Linking.canOpenURL).toHaveBeenCalledWith('https://joinzap.com/app/abc');
expect(Linking.openURL).toHaveBeenCalledWith('https://joinzap.com/app/abc');
});

it('does not open the URL when no app can handle it', async () => {
(Linking.canOpenURL as jest.Mock).mockResolvedValue(false);

await openExternalUrl('mypartner://pay');

expect(Linking.openURL).not.toHaveBeenCalled();
});

it('swallows errors instead of throwing', async () => {
(Linking.canOpenURL as jest.Mock).mockRejectedValue(new Error('boom'));

await expect(openExternalUrl('https://joinzap.com/app/abc')).resolves.toBeUndefined();
expect(Linking.openURL).not.toHaveBeenCalled();
});
});
});
23 changes: 21 additions & 2 deletions development/PaystackProvider.tsx
Original file line number Diff line number Diff line change
@@ -1,14 +1,18 @@
import React, { createContext, useCallback, useMemo, useState } from 'react';
import { Modal, ActivityIndicator, } from 'react-native';
import { Modal, ActivityIndicator } from 'react-native';
import { WebView, WebViewMessageEvent } from 'react-native-webview';
import { SafeAreaView } from 'react-native-safe-area-context';
import {
PaystackParams,
PaystackProviderProps,
} from './types';
import { validateParams, paystackHtmlContent, generatePaystackParams, handlePaystackMessage } from './utils';
import { validateParams, paystackHtmlContent, generatePaystackParams, handlePaystackMessage, shouldHandleExternally, openExternalUrl } from './utils';
import { styles } from './styles';

export const DEFAULT_DEEP_LINK_HOSTS: string[] = [
'https://joinzap.com/app/',
];

export const PaystackContext = createContext<{
popup: {
checkout: (params: PaystackParams) => void;
Expand All @@ -20,6 +24,7 @@ export const PaystackProvider: React.FC<PaystackProviderProps> = ({
publicKey,
currency,
defaultChannels = ['card'],
deepLinkHosts = [],
debug = false,
children,
onGlobalSuccess,
Expand All @@ -31,6 +36,11 @@ export const PaystackProvider: React.FC<PaystackProviderProps> = ({

const fallbackRef = useMemo(() => `ref_${Date.now()}`, []);

const resolvedDeepLinkHosts = useMemo(
() => [...DEFAULT_DEEP_LINK_HOSTS, ...deepLinkHosts],
[deepLinkHosts]
);

const open = useCallback(
(params: PaystackParams, selectedMethod: 'checkout' | 'newTransaction') => {
if (debug) console.log(`[Paystack] Opening modal with method: ${selectedMethod}`);
Expand Down Expand Up @@ -95,6 +105,15 @@ export const PaystackProvider: React.FC<PaystackProviderProps> = ({
originWhitelist={["*"]}
source={{ html: paystackHTML }}
onMessage={handleMessage}
onShouldStartLoadWithRequest={(request) => {
const url = request.url ?? '';
if (!shouldHandleExternally(url, resolvedDeepLinkHosts)) {
return true;
}
if (debug) console.log('[Paystack] Opening external/deep link via OS:', url);
void openExternalUrl(url, debug);
return false;
}}
Comment on lines +108 to +116
Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

url.indexOf(matcher) === 0 works, but startsWith is a bit clearer here. Also, RegExp.test() can behave unexpectedly if someone passes a global regex (/.../g) since it mutates lastIndex.

How about extracting the matching logic into a helper like this:

const shouldOpenExternally = (url: string) =>
  deepLinkHosts.some((matcher) => {
    if (typeof matcher === 'string') {
      return url.startsWith(matcher);
    }

    matcher.lastIndex = 0;
    return matcher.test(url);
  });

Then the WebView callback becomes a bit easier to read:

onShouldStartLoadWithRequest={(request) => {
  const url = request.url ?? '';

  if (url && deepLinkHosts.length > 0 && shouldOpenExternally(url)) {
    if (debug) {
      console.log('[Paystack] Opening external/deep link via OS:', url);
    }

    void Linking.openURL(url).catch((err) => {
      if (debug) {
        console.log('[Paystack] Linking.openURL failed:', err);
      }
    });

    return false;
  }

  return true;
}}

Don't forget to write test for shouldOpenExternally

javaScriptEnabled
domStorageEnabled
startInLoadingState
Expand Down
6 changes: 6 additions & 0 deletions development/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,12 @@ export type PaystackProviderProps = {
publicKey: string;
currency?: Currency;
defaultChannels?: PaymentChannels;
/**
* Additional hosts to hand off to the OS instead of loading inside the
* checkout WebView. Added to the built-in defaults, which always apply.
* See the "Deep linking" section of the README.
*/
Comment on lines +15 to +19
Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

Unnecessary comment, please remove.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Same comment here, this should should also go to the docs/README

deepLinkHosts?: Array<string | RegExp>;
debug?: boolean;
children: React.ReactNode;
onGlobalSuccess?: (data: PaystackTransactionResponse) => void;
Expand Down
35 changes: 33 additions & 2 deletions development/utils.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,36 @@
import { Alert } from 'react-native';
import { Currency, DynamicMultiSplitProps, PaymentChannels, PaystackParams, PaystackTransactionResponse } from './types';
import { Alert, Linking } from 'react-native';
import { Currency, DynamicMultiSplitProps, PaymentChannels, PaystackParams, PaystackTransactionResponse } from './types';

/**
* Whether a navigation URL should be handed off to the OS instead of being
* loaded inside the checkout WebView. String matchers match by prefix;
* RegExp matchers use `.test(url)`.
*/
export const shouldHandleExternally = (
url: string,
hosts: Array<string | RegExp>
): boolean =>
!!url &&
hosts.some((matcher) =>
typeof matcher === 'string' ? url.indexOf(matcher) === 0 : matcher.test(url)
);

/**
* Hand a URL off to the OS. Checks `Linking.canOpenURL` first so we don't
* attempt to open a URL no installed app can handle, and swallows any error.
*/
export const openExternalUrl = async (url: string, debug = false): Promise<void> => {
try {
const supported = await Linking.canOpenURL(url);
if (!supported) {
if (debug) console.log('[Paystack] No app can handle URL:', url);
return;
}
await Linking.openURL(url);
} catch (err) {
if (debug) console.log('[Paystack] Linking.openURL failed:', err);
}
};

export const validateParams = (params: PaystackParams, debug: boolean): boolean => {
const errors: string[] = [];
Expand Down