diff --git a/README.md b/README.md
index 5593dcd..7af7a4d 100644
--- a/README.md
+++ b/README.md
@@ -102,11 +102,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
@@ -146,6 +173,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 969c473..c12d656 100644
--- a/__tests__/index.test.tsx
+++ b/__tests__/index.test.tsx
@@ -1,5 +1,6 @@
-import { validateParams, sanitize, generatePaystackParams } from '../development/utils';
import { Alert } from 'react-native';
+import { validateParams, sanitize, generatePaystackParams } from '../development/utils';
+import { TransactionType } from '../development/types';
jest.mock('react-native', () => ({
Alert: { alert: jest.fn() }
@@ -17,6 +18,15 @@ describe('Paystack Utils', () => {
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: '',
@@ -49,6 +59,14 @@ describe('Paystack Utils', () => {
expect(result).toBe(false);
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'));
+ });
});
describe('sanitize', () => {
@@ -79,9 +97,41 @@ describe('Paystack Utils', () => {
currency: 'NGN',
channels: ['card']
});
- expect(js).toContain("key: 'pk_test'");
- expect(js).toContain("email: 'email@test.com'");
- expect(js).toContain("amount: 10000");
+ 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.mode).toBe(TransactionType.RESUME);
+ expect(js.accessCode).toBe('ac_123');
});
});
});
\ No newline at end of file
diff --git a/development/PaystackProvider.tsx b/development/PaystackProvider.tsx
index c70d88c..d179af2 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 } from './utils';
import { styles } from './styles';
@@ -13,6 +14,7 @@ export const PaystackContext = createContext<{
popup: {
checkout: (params: PaystackParams) => void;
newTransaction: (params: PaystackParams) => void;
+ resumeTransaction: (params: PaystackParams) => void;
};
} | null>(null);
@@ -27,12 +29,12 @@ 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()}`, []);
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);
@@ -42,8 +44,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);
@@ -77,6 +80,7 @@ export const PaystackProvider: React.FC = ({
subaccount: params.subaccount,
split: params.split,
split_code: params.split_code,
+ accessCode: params.accessCode,
}),
method
);
@@ -87,7 +91,7 @@ export const PaystackProvider: React.FC = ({
}
return (
-
+
{children}
diff --git a/development/types.ts b/development/types.ts
index eff687d..69cff5c 100644
--- a/development/types.ts
+++ b/development/types.ts
@@ -18,7 +18,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;
@@ -28,11 +35,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;
@@ -80,3 +99,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 f404c3d..9888d73 100644
--- a/development/utils.ts
+++ b/development/utils.ts
@@ -1,19 +1,40 @@
import { Alert } from 'react-native';
-import { Currency, DynamicMultiSplitProps, PaymentChannels, PaystackParams, PaystackTransactionResponse } from './types';
+import { Currency, DynamicMultiSplitProps, PaymentChannels, PaystackParams, PaystackTransactionResponse, TransactionMethod, TransactionType, PaystackGeneratedConfig } from './types';
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'));
@@ -84,24 +105,60 @@ 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 && config.accessCode!== '' && config.accessCode !== undefined) {
+ 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)}` : '',
@@ -111,27 +168,24 @@ 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`
@@ -144,11 +198,10 @@ export const paystackHtmlContent = (
`;
+}
\ No newline at end of file