diff --git a/README.md b/README.md
index 698a3f9..8813095 100644
--- a/README.md
+++ b/README.md
@@ -103,11 +103,38 @@ const Checkout = () => {
};
```
+### Resume transaction
+
The resume transaction flow allows you to initiate a transaction on your server and complete it in the app. This flow provides both the security of server initialization and the convenience of the user experience in the app.
+
+
+```tsx
+import React from 'react';
+import { Button } from 'react-native';
+import { usePaystack } from 'react-native-paystack-webview';
+
+const ResumeTransaction = () => {
+ const { popup } = usePaystack();
+
+ const resumePayment = () => {
+ popup.resumeTransaction({
+ accessCode: 'ACCESS_CODE_FROM_PAYSTACK',
+ onSuccess: (res) => console.log('Payment resumed successfully:', res),
+ onCancel: () => console.log('User cancelled'),
+ onLoad: (res) => console.log('WebView Loaded:', res),
+ onError: (err) => console.log('WebView Error:', err)
+ });
+ };
+
+ return ;
+};
+```
+
---
## 🧠Features
-- ✅ Simple `checkout()` or `newTransaction()` calls
+- ✅ Simple `checkout()`, `newTransaction()`, or `resumeTransaction()` calls
+- ✅ Resume interrupted transactions with access codes
- ✅ Global callbacks with `onGlobalSuccess` or `onGlobalCancel`
- ✅ Debug logging with `debug` prop
- ✅ Fully typed params for transactions
@@ -149,6 +176,18 @@ const Checkout = () => {
| `onLoad` | `(res) => void` | — | Triggered when transaction view loads |
| `onError` | `(err) => void` | — | Triggered on WebView or script error |
+### `popup.resumeTransaction()`
+
+Resume a transaction that was previously interrupted. This method allows users to complete a payment using an access code provided by Paystack.
+
+| Param | Type | Required | Description |
+|---------------|---------------------|----------|-------------------------------------------|
+| `accessCode` | `string` | ✅ | Access code from Paystack for resuming a transaction |
+| `onSuccess` | `(res) => void` | ✅ | Called on successful payment |
+| `onCancel` | `() => void` | ✅ | Called on cancellation |
+| `onLoad` | `(res) => void` | — | Triggered when transaction view loads |
+| `onError` | `(err) => void` | — | Triggered on WebView or script error |
+
---
#### Meta Props
diff --git a/__tests__/index.test.tsx b/__tests__/index.test.tsx
index 52c30ae..26747b5 100644
--- a/__tests__/index.test.tsx
+++ b/__tests__/index.test.tsx
@@ -1,54 +1,105 @@
-import { validateParams, sanitize, generatePaystackParams, shouldHandleExternally, openExternalUrl } from '../development/utils';
+import {
+ validateParams,
+ sanitize,
+ generatePaystackParams,
+ shouldHandleExternally,
+ openExternalUrl,
+} from '../development/utils';
import { Alert, Linking } from 'react-native';
+import { TransactionType } from '../development/types';
jest.mock('react-native', () => ({
Alert: { alert: jest.fn() },
- Linking: { canOpenURL: jest.fn(), openURL: jest.fn() }
+ Linking: { canOpenURL: jest.fn(), openURL: jest.fn() },
}));
describe('Paystack Utils', () => {
describe('validateParams', () => {
it('should return true for valid params', () => {
- const result = validateParams({
- email: 'test@example.com',
- amount: 5000,
- onSuccess: jest.fn(),
- onCancel: jest.fn()
- }, false);
+ const result = validateParams(
+ {
+ email: 'test@example.com',
+ amount: 5000,
+ onSuccess: jest.fn(),
+ onCancel: jest.fn(),
+ },
+ false,
+ );
+ expect(result).toBe(true);
+ });
+
+ it('should return true for valid resume transaction params', () => {
+ const result = validateParams(
+ {
+ accessCode: 'ac_123',
+ onSuccess: jest.fn(),
+ onCancel: jest.fn(),
+ },
+ false,
+ );
expect(result).toBe(true);
});
it('should fail with missing email and show alert', () => {
- const result = validateParams({
- email: '',
- amount: 5000,
- onSuccess: jest.fn(),
- onCancel: jest.fn()
- }, true);
+ const result = validateParams(
+ {
+ email: '',
+ amount: 5000,
+ onSuccess: jest.fn(),
+ onCancel: jest.fn(),
+ },
+ true,
+ );
expect(result).toBe(false);
expect(Alert.alert).toHaveBeenCalledWith('Payment Error', expect.stringContaining('Email is required'));
});
it('should fail with invalid amount', () => {
- const result = validateParams({
- email: 'test@example.com',
- amount: 0,
- onSuccess: jest.fn(),
- onCancel: jest.fn()
- }, true);
+ const result = validateParams(
+ {
+ email: 'test@example.com',
+ amount: 0,
+ onSuccess: jest.fn(),
+ onCancel: jest.fn(),
+ },
+ true,
+ );
expect(result).toBe(false);
- expect(Alert.alert).toHaveBeenCalledWith('Payment Error', expect.stringContaining('Amount must be a valid number'));
+ expect(Alert.alert).toHaveBeenCalledWith(
+ 'Payment Error',
+ expect.stringContaining('Amount must be a valid number'),
+ );
});
it('should fail with missing callbacks', () => {
- const result = validateParams({
- email: 'test@example.com',
- amount: 1000,
- onSuccess: undefined,
- onCancel: undefined
- } as any, true);
+ const result = validateParams(
+ {
+ email: 'test@example.com',
+ amount: 1000,
+ onSuccess: undefined,
+ onCancel: undefined,
+ } as any,
+ true,
+ );
expect(result).toBe(false);
- expect(Alert.alert).toHaveBeenCalledWith('Payment Error', expect.stringContaining('onSuccess callback is required'));
+ expect(Alert.alert).toHaveBeenCalledWith(
+ 'Payment Error',
+ expect.stringContaining('onSuccess callback is required'),
+ );
+ });
+
+ it('should fail with invalid accessCode', () => {
+ const result = validateParams(
+ {
+ accessCode: ' ',
+ } as any,
+ true,
+ );
+ expect(result).toBe(false);
+ expect(Alert.alert).toHaveBeenCalledWith(
+ 'Payment Error',
+ expect.stringContaining('accessCode must be a non-empty string'),
+ );
});
});
@@ -78,37 +129,71 @@ describe('Paystack Utils', () => {
reference: 'ref123',
metadata: { order: 123 },
currency: 'NGN',
- channels: ['card']
+ channels: ['card'],
+ });
+ expect(js.mode).toBe(TransactionType.STANDARD);
+ expect(js.params).toContain("key: 'pk_test'");
+ expect(js.params).toContain("email: 'email@test.com'");
+ expect(js.params).toContain('amount: 10000');
+ });
+
+ it('should throw error if required fields are missing', () => {
+ expect(() =>
+ generatePaystackParams({
+ email: 'email@test.com',
+ amount: 100,
+ reference: 'ref123',
+ } as any),
+ ).toThrow('Public Key is required to generate Paystack parameters');
+ expect(() =>
+ generatePaystackParams({
+ publicKey: 'pk_test',
+ amount: 100,
+ reference: 'ref123',
+ } as any),
+ ).toThrow('Email and Amount are required to generate Paystack parameters');
+ expect(() =>
+ generatePaystackParams({
+ publicKey: 'pk_test',
+ email: 'email@test.com',
+ reference: 'ref123',
+ } as any),
+ ).toThrow('Email and Amount are required to generate Paystack parameters');
+ expect(() =>
+ generatePaystackParams({
+ publicKey: 'pk_test',
+ email: 'email@test.com',
+ amount: 100,
+ } as any),
+ ).toThrow('Reference is required to generate Paystack parameters');
+ });
+
+ it('should generate params for resume transaction', () => {
+ const js = generatePaystackParams({
+ accessCode: 'ac_123',
});
- expect(js).toContain("key: 'pk_test'");
- expect(js).toContain("email: 'email@test.com'");
- expect(js).toContain("amount: 10000");
+ expect(js.mode).toBe(TransactionType.RESUME);
+ expect(js.accessCode).toBe('ac_123');
});
});
describe('shouldHandleExternally', () => {
it('matches a string host by prefix', () => {
- expect(
- shouldHandleExternally('https://joinzap.com/app/abc', ['https://joinzap.com/app/'])
- ).toBe(true);
+ 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);
+ 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);
+ 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);
+ expect(shouldHandleExternally('https://checkout.paystack.com/123', ['https://joinzap.com/app/'])).toBe(false);
});
it('returns false for an empty URL', () => {
@@ -147,4 +232,4 @@ describe('Paystack Utils', () => {
expect(Linking.openURL).not.toHaveBeenCalled();
});
});
-});
\ No newline at end of file
+});
diff --git a/development/PaystackProvider.tsx b/development/PaystackProvider.tsx
index 6f13feb..e2b707d 100644
--- a/development/PaystackProvider.tsx
+++ b/development/PaystackProvider.tsx
@@ -5,6 +5,7 @@ import { SafeAreaView } from 'react-native-safe-area-context';
import {
PaystackParams,
PaystackProviderProps,
+ TransactionMethod
} from './types';
import { validateParams, paystackHtmlContent, generatePaystackParams, handlePaystackMessage, shouldHandleExternally, openExternalUrl } from './utils';
import { styles } from './styles';
@@ -17,6 +18,7 @@ export const PaystackContext = createContext<{
popup: {
checkout: (params: PaystackParams) => void;
newTransaction: (params: PaystackParams) => void;
+ resumeTransaction: (params: PaystackParams) => void;
};
} | null>(null);
@@ -32,7 +34,7 @@ export const PaystackProvider: React.FC = ({
}) => {
const [visible, setVisible] = useState(false);
const [params, setParams] = useState(null);
- const [method, setMethod] = useState<'checkout' | 'newTransaction'>('checkout');
+ const [method, setMethod] = useState(TransactionMethod.CHECKOUT);
const fallbackRef = useMemo(() => `ref_${Date.now()}`, []);
@@ -42,7 +44,7 @@ export const PaystackProvider: React.FC = ({
);
const open = useCallback(
- (params: PaystackParams, selectedMethod: 'checkout' | 'newTransaction') => {
+ (params: PaystackParams, selectedMethod: TransactionMethod) => {
if (debug) console.log(`[Paystack] Opening modal with method: ${selectedMethod}`);
if (!validateParams(params, debug)) return;
setParams(params);
@@ -52,8 +54,9 @@ export const PaystackProvider: React.FC = ({
[debug]
);
- const checkout = (params: PaystackParams) => open(params, 'checkout');
- const newTransaction = (params: PaystackParams) => open(params, 'newTransaction');
+ const checkout = (params: PaystackParams) => open(params, TransactionMethod.CHECKOUT);
+ const newTransaction = (params: PaystackParams) => open(params, TransactionMethod.NEW_TRANSACTION);
+ const resumeTransaction = (params: PaystackParams) => open(params, TransactionMethod.RESUME_TRANSACTION);
const close = () => {
setVisible(false);
@@ -87,6 +90,7 @@ export const PaystackProvider: React.FC = ({
subaccount: params.subaccount,
split: params.split,
split_code: params.split_code,
+ accessCode: params.accessCode,
}),
method
);
@@ -97,7 +101,7 @@ export const PaystackProvider: React.FC = ({
}
return (
-
+
{children}
diff --git a/development/types.ts b/development/types.ts
index fa3fc73..e22e2ed 100644
--- a/development/types.ts
+++ b/development/types.ts
@@ -24,7 +24,14 @@ export type PaystackProviderProps = {
onGlobalCancel?: () => void;
};
-export type PaystackParams = {
+type PaystackBaseCallbacks = {
+ onSuccess: (data: PaystackTransactionResponse) => void;
+ onCancel: () => void;
+ onLoad?: (res: PaystackOnloadResponse) => void;
+ onError?: (res: any) => void;
+};
+
+type StandardFields = {
email: string;
amount: number;
metadata?: Record;
@@ -34,11 +41,23 @@ export type PaystackParams = {
subaccount?: string;
split_code?: string;
split?: DynamicMultiSplitProps;
- onSuccess: (data: PaystackTransactionResponse) => void;
- onCancel: () => void;
- onLoad?: (res: PaystackOnloadResponse) => void;
- onError?: (res: any) => void;
-};
+ accessCode?: never;
+}
+
+type ResumeTransactionFields = {
+ accessCode: string;
+ email?: never;
+ amount?: never;
+ metadata?: never;
+ reference?: never;
+ plan?: never;
+ invoice_limit?: never;
+ subaccount?: never;
+ split_code?: never;
+ split?: never;
+}
+
+export type PaystackParams = PaystackBaseCallbacks & (StandardFields | ResumeTransactionFields);
export type PaystackCheckoutParams = {
email: string;
@@ -86,3 +105,20 @@ export interface DynamicMultiSplitProps {
bearer_subaccount?: string;
reference?: string;
}
+
+export enum TransactionMethod {
+ CHECKOUT = 'checkout',
+ NEW_TRANSACTION = 'newTransaction',
+ RESUME_TRANSACTION = 'resumeTransaction'
+}
+
+export enum TransactionType {
+ STANDARD = 'standard',
+ RESUME = 'resume'
+}
+
+export type PaystackGeneratedConfig = {
+ mode: TransactionType;
+ accessCode?: string;
+ params: string;
+};
\ No newline at end of file
diff --git a/development/utils.ts b/development/utils.ts
index a0cefc1..ef96126 100644
--- a/development/utils.ts
+++ b/development/utils.ts
@@ -1,14 +1,17 @@
import { Alert, Linking } from 'react-native';
-import { Currency, DynamicMultiSplitProps, PaymentChannels, PaystackParams, PaystackTransactionResponse } from './types';
+import {
+ Currency,
+ DynamicMultiSplitProps,
+ PaymentChannels,
+ PaystackParams,
+ PaystackTransactionResponse,
+ TransactionMethod,
+ TransactionType,
+ PaystackGeneratedConfig,
+} from './types';
-export const shouldHandleExternally = (
- url: string,
- hosts: Array
-): boolean =>
- !!url &&
- hosts.some((matcher) =>
- typeof matcher === 'string' ? url.indexOf(matcher) === 0 : matcher.test(url)
- );
+export const shouldHandleExternally = (url: string, hosts: Array): boolean =>
+ !!url && hosts.some((matcher) => (typeof matcher === 'string' ? url.indexOf(matcher) === 0 : matcher.test(url)));
export const openExternalUrl = async (url: string, debug = false): Promise => {
try {
@@ -25,17 +28,38 @@ export const openExternalUrl = async (url: string, debug = false): Promise
export const validateParams = (params: PaystackParams, debug: boolean): boolean => {
const errors: string[] = [];
- if (!params.email) errors.push('Email is required');
- if (!params.amount || typeof params.amount !== 'number' || params.amount <= 0) {
- errors.push('Amount must be a valid number greater than 0');
- }
+
if (!params.onSuccess || typeof params.onSuccess !== 'function') {
errors.push('onSuccess callback is required and must be a function');
}
+
if (!params.onCancel || typeof params.onCancel !== 'function') {
errors.push('onCancel callback is required and must be a function');
}
+ if (params.accessCode) {
+ if (typeof params.accessCode !== 'string' || params.accessCode.trim() === '') {
+ errors.push('accessCode must be a non-empty string');
+ }
+ if (params.email || params.amount) {
+ errors.push('When accessCode is provided, email and amount should not be included');
+ }
+
+ if (errors.length > 0) {
+ debug && console.warn('Paystack Validation Errors', errors);
+ Alert.alert('Payment Error', errors.join('\n'));
+ return false;
+ }
+ // If accessCode is provided, we assume it's a resume transaction and skip email/amount validation
+ return true;
+ }
+
+ if (!params.email) errors.push('Email is required');
+
+ if (!params.amount || typeof params.amount !== 'number' || params.amount <= 0) {
+ errors.push('Amount must be a valid number greater than 0');
+ }
+
if (errors.length > 0) {
debug && console.warn('Paystack Validation Errors:', errors);
Alert.alert('Payment Error', errors.join('\n'));
@@ -44,11 +68,7 @@ export const validateParams = (params: PaystackParams, debug: boolean): boolean
return true;
};
-export const sanitize = (
- value: unknown,
- fallback: string | number | object,
- wrapString = true
-): string => {
+export const sanitize = (value: unknown, fallback: string | number | object, wrapString = true): string => {
try {
if (typeof value === 'string') return wrapString ? `'${value}'` : value;
return JSON.stringify(value ?? fallback);
@@ -107,23 +127,57 @@ export const handlePaystackMessage = ({
};
export const generatePaystackParams = (config: {
- publicKey: string;
- email: string;
- amount: number;
- reference: string;
+ publicKey?: string;
+ email?: string;
+ amount?: number;
+ reference?: string;
metadata?: object;
currency?: Currency;
- channels: PaymentChannels;
+ channels?: PaymentChannels;
plan?: string;
invoice_limit?: number;
subaccount?: string;
split_code?: string;
split?: DynamicMultiSplitProps;
-}): string => {
+ accessCode?: string;
+}): PaystackGeneratedConfig => {
+ const callbacks: string = `
+ onSuccess: function(response) {
+ window.ReactNativeWebView.postMessage(JSON.stringify({ event: 'success', data: response }));
+ },
+ onCancel: function() {
+ window.ReactNativeWebView.postMessage(JSON.stringify({ event: 'cancel' }));
+ },
+ onLoad: function(response) {
+ window.ReactNativeWebView.postMessage(JSON.stringify({ event: 'load', data: response }));
+ },
+ onError: function(error) {
+ window.ReactNativeWebView.postMessage(JSON.stringify({ event: 'error', error: { message: error.message } }));
+ }
+ `;
+
+ if (config.accessCode) {
+ return {
+ mode: TransactionType.RESUME,
+ accessCode: config.accessCode,
+ params: callbacks,
+ };
+ }
+
+ if (!config.email || !config.amount) {
+ throw new Error('Email and Amount are required to generate Paystack parameters');
+ }
+ if (!config.publicKey) {
+ throw new Error('Public Key is required to generate Paystack parameters');
+ }
+ if (!config.reference) {
+ throw new Error('Reference is required to generate Paystack parameters');
+ }
+
const props = [
`key: '${config.publicKey}'`,
`email: '${config.email}'`,
- `amount: ${config.amount * 100}`,
+ `amount: ${(config.amount || 0) * 100}`,
config.currency ? `currency: '${config.currency}'` : '',
`reference: '${config.reference}'`,
config.metadata ? `metadata: ${JSON.stringify(config.metadata)}` : '',
@@ -133,27 +187,25 @@ export const generatePaystackParams = (config: {
config.subaccount ? `subaccount: '${config.subaccount}'` : '',
config.split_code ? `split_code: '${config.split_code}'` : '',
config.split ? `split: ${JSON.stringify(config.split)}` : '',
- `onSuccess: function(response) {
- window.ReactNativeWebView.postMessage(JSON.stringify({ event: 'success', data: response }));
- }`,
- `onCancel: function() {
- window.ReactNativeWebView.postMessage(JSON.stringify({ event: 'cancel' }));
- }`,
- `onLoad: function(response) {
- window.ReactNativeWebView.postMessage(JSON.stringify({ event: 'load', data: response }));
- }`,
- `onError: function(error) {
- window.ReactNativeWebView.postMessage(JSON.stringify({ event: 'error', error: { message: error.message } }));
- }`
+ callbacks,
];
- return props.filter(Boolean).join(',\n');
+ return {
+ mode: TransactionType.STANDARD,
+ params: props.filter(Boolean).join(',\n'),
+ };
};
export const paystackHtmlContent = (
- params: string,
- method: 'checkout' | 'newTransaction' = 'checkout'
-): string => `
+ params: PaystackGeneratedConfig,
+ method: TransactionMethod = TransactionMethod.CHECKOUT,
+): string => {
+ const functionCall =
+ params.mode === TransactionType.RESUME
+ ? `paystack.${method}('${params.accessCode}', { ${params.params} });`
+ : `paystack.${method}({ ${params.params} });`;
+
+ return `
@@ -166,11 +218,10 @@ export const paystackHtmlContent = (
`;
+};