Skip to content

Commit c99abd9

Browse files
committed
feat(RNPSW): add resume transaction functionality and validation updates
1 parent 5e75326 commit c99abd9

5 files changed

Lines changed: 316 additions & 101 deletions

File tree

README.md

Lines changed: 40 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -103,11 +103,38 @@ const Checkout = () => {
103103
};
104104
```
105105

106+
### Resume transaction
107+
<p> 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.
108+
</p>
109+
110+
```tsx
111+
import React from 'react';
112+
import { Button } from 'react-native';
113+
import { usePaystack } from 'react-native-paystack-webview';
114+
115+
const ResumeTransaction = () => {
116+
const { popup } = usePaystack();
117+
118+
const resumePayment = () => {
119+
popup.resumeTransaction({
120+
accessCode: 'ACCESS_CODE_FROM_PAYSTACK',
121+
onSuccess: (res) => console.log('Payment resumed successfully:', res),
122+
onCancel: () => console.log('User cancelled'),
123+
onLoad: (res) => console.log('WebView Loaded:', res),
124+
onError: (err) => console.log('WebView Error:', err)
125+
});
126+
};
127+
128+
return <Button title="Resume Payment" onPress={resumePayment} />;
129+
};
130+
```
131+
106132
---
107133

108134
## 🧠 Features
109135

110-
- ✅ Simple `checkout()` or `newTransaction()` calls
136+
- ✅ Simple `checkout()`, `newTransaction()`, or `resumeTransaction()` calls
137+
- ✅ Resume interrupted transactions with access codes
111138
- ✅ Global callbacks with `onGlobalSuccess` or `onGlobalCancel`
112139
- ✅ Debug logging with `debug` prop
113140
- ✅ Fully typed params for transactions
@@ -149,6 +176,18 @@ const Checkout = () => {
149176
| `onLoad` | `(res) => void` || Triggered when transaction view loads |
150177
| `onError` | `(err) => void` || Triggered on WebView or script error |
151178

179+
### `popup.resumeTransaction()`
180+
181+
Resume a transaction that was previously interrupted. This method allows users to complete a payment using an access code provided by Paystack.
182+
183+
| Param | Type | Required | Description |
184+
|---------------|---------------------|----------|-------------------------------------------|
185+
| `accessCode` | `string` || Access code from Paystack for resuming a transaction |
186+
| `onSuccess` | `(res) => void` || Called on successful payment |
187+
| `onCancel` | `() => void` || Called on cancellation |
188+
| `onLoad` | `(res) => void` || Triggered when transaction view loads |
189+
| `onError` | `(err) => void` || Triggered on WebView or script error |
190+
152191
---
153192

154193
#### Meta Props

__tests__/index.test.tsx

Lines changed: 130 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -1,54 +1,105 @@
1-
import { validateParams, sanitize, generatePaystackParams, shouldHandleExternally, openExternalUrl } from '../development/utils';
1+
import {
2+
validateParams,
3+
sanitize,
4+
generatePaystackParams,
5+
shouldHandleExternally,
6+
openExternalUrl,
7+
} from '../development/utils';
28
import { Alert, Linking } from 'react-native';
9+
import { TransactionType } from '../development/types';
310

411
jest.mock('react-native', () => ({
512
Alert: { alert: jest.fn() },
6-
Linking: { canOpenURL: jest.fn(), openURL: jest.fn() }
13+
Linking: { canOpenURL: jest.fn(), openURL: jest.fn() },
714
}));
815

916
describe('Paystack Utils', () => {
1017
describe('validateParams', () => {
1118
it('should return true for valid params', () => {
12-
const result = validateParams({
13-
email: 'test@example.com',
14-
amount: 5000,
15-
onSuccess: jest.fn(),
16-
onCancel: jest.fn()
17-
}, false);
19+
const result = validateParams(
20+
{
21+
email: 'test@example.com',
22+
amount: 5000,
23+
onSuccess: jest.fn(),
24+
onCancel: jest.fn(),
25+
},
26+
false,
27+
);
28+
expect(result).toBe(true);
29+
});
30+
31+
it('should return true for valid resume transaction params', () => {
32+
const result = validateParams(
33+
{
34+
accessCode: 'ac_123',
35+
onSuccess: jest.fn(),
36+
onCancel: jest.fn(),
37+
},
38+
false,
39+
);
1840
expect(result).toBe(true);
1941
});
2042

2143
it('should fail with missing email and show alert', () => {
22-
const result = validateParams({
23-
email: '',
24-
amount: 5000,
25-
onSuccess: jest.fn(),
26-
onCancel: jest.fn()
27-
}, true);
44+
const result = validateParams(
45+
{
46+
email: '',
47+
amount: 5000,
48+
onSuccess: jest.fn(),
49+
onCancel: jest.fn(),
50+
},
51+
true,
52+
);
2853
expect(result).toBe(false);
2954
expect(Alert.alert).toHaveBeenCalledWith('Payment Error', expect.stringContaining('Email is required'));
3055
});
3156

3257
it('should fail with invalid amount', () => {
33-
const result = validateParams({
34-
email: 'test@example.com',
35-
amount: 0,
36-
onSuccess: jest.fn(),
37-
onCancel: jest.fn()
38-
}, true);
58+
const result = validateParams(
59+
{
60+
email: 'test@example.com',
61+
amount: 0,
62+
onSuccess: jest.fn(),
63+
onCancel: jest.fn(),
64+
},
65+
true,
66+
);
3967
expect(result).toBe(false);
40-
expect(Alert.alert).toHaveBeenCalledWith('Payment Error', expect.stringContaining('Amount must be a valid number'));
68+
expect(Alert.alert).toHaveBeenCalledWith(
69+
'Payment Error',
70+
expect.stringContaining('Amount must be a valid number'),
71+
);
4172
});
4273

4374
it('should fail with missing callbacks', () => {
44-
const result = validateParams({
45-
email: 'test@example.com',
46-
amount: 1000,
47-
onSuccess: undefined,
48-
onCancel: undefined
49-
} as any, true);
75+
const result = validateParams(
76+
{
77+
email: 'test@example.com',
78+
amount: 1000,
79+
onSuccess: undefined,
80+
onCancel: undefined,
81+
} as any,
82+
true,
83+
);
5084
expect(result).toBe(false);
51-
expect(Alert.alert).toHaveBeenCalledWith('Payment Error', expect.stringContaining('onSuccess callback is required'));
85+
expect(Alert.alert).toHaveBeenCalledWith(
86+
'Payment Error',
87+
expect.stringContaining('onSuccess callback is required'),
88+
);
89+
});
90+
91+
it('should fail with invalid accessCode', () => {
92+
const result = validateParams(
93+
{
94+
accessCode: ' ',
95+
} as any,
96+
true,
97+
);
98+
expect(result).toBe(false);
99+
expect(Alert.alert).toHaveBeenCalledWith(
100+
'Payment Error',
101+
expect.stringContaining('accessCode must be a non-empty string'),
102+
);
52103
});
53104
});
54105

@@ -78,37 +129,71 @@ describe('Paystack Utils', () => {
78129
reference: 'ref123',
79130
metadata: { order: 123 },
80131
currency: 'NGN',
81-
channels: ['card']
132+
channels: ['card'],
133+
});
134+
expect(js.mode).toBe(TransactionType.STANDARD);
135+
expect(js.params).toContain("key: 'pk_test'");
136+
expect(js.params).toContain("email: 'email@test.com'");
137+
expect(js.params).toContain('amount: 10000');
138+
});
139+
140+
it('should throw error if required fields are missing', () => {
141+
expect(() =>
142+
generatePaystackParams({
143+
email: 'email@test.com',
144+
amount: 100,
145+
reference: 'ref123',
146+
} as any),
147+
).toThrow('Public Key is required to generate Paystack parameters');
148+
expect(() =>
149+
generatePaystackParams({
150+
publicKey: 'pk_test',
151+
amount: 100,
152+
reference: 'ref123',
153+
} as any),
154+
).toThrow('Email and Amount are required to generate Paystack parameters');
155+
expect(() =>
156+
generatePaystackParams({
157+
publicKey: 'pk_test',
158+
email: 'email@test.com',
159+
reference: 'ref123',
160+
} as any),
161+
).toThrow('Email and Amount are required to generate Paystack parameters');
162+
expect(() =>
163+
generatePaystackParams({
164+
publicKey: 'pk_test',
165+
email: 'email@test.com',
166+
amount: 100,
167+
} as any),
168+
).toThrow('Reference is required to generate Paystack parameters');
169+
});
170+
171+
it('should generate params for resume transaction', () => {
172+
const js = generatePaystackParams({
173+
accessCode: 'ac_123',
82174
});
83-
expect(js).toContain("key: 'pk_test'");
84-
expect(js).toContain("email: 'email@test.com'");
85-
expect(js).toContain("amount: 10000");
175+
expect(js.mode).toBe(TransactionType.RESUME);
176+
expect(js.accessCode).toBe('ac_123');
86177
});
87178
});
88179

89180
describe('shouldHandleExternally', () => {
90181
it('matches a string host by prefix', () => {
91-
expect(
92-
shouldHandleExternally('https://joinzap.com/app/abc', ['https://joinzap.com/app/'])
93-
).toBe(true);
182+
expect(shouldHandleExternally('https://joinzap.com/app/abc', ['https://joinzap.com/app/'])).toBe(true);
94183
});
95184

96185
it('does not match when only part of the URL contains the prefix', () => {
97-
expect(
98-
shouldHandleExternally('https://evil.com/?u=https://joinzap.com/app/', ['https://joinzap.com/app/'])
99-
).toBe(false);
186+
expect(shouldHandleExternally('https://evil.com/?u=https://joinzap.com/app/', ['https://joinzap.com/app/'])).toBe(
187+
false,
188+
);
100189
});
101190

102191
it('matches a RegExp host', () => {
103-
expect(
104-
shouldHandleExternally('mypartner://pay', [/^mypartner:\/\//])
105-
).toBe(true);
192+
expect(shouldHandleExternally('mypartner://pay', [/^mypartner:\/\//])).toBe(true);
106193
});
107194

108195
it('returns false when no host matches', () => {
109-
expect(
110-
shouldHandleExternally('https://checkout.paystack.com/123', ['https://joinzap.com/app/'])
111-
).toBe(false);
196+
expect(shouldHandleExternally('https://checkout.paystack.com/123', ['https://joinzap.com/app/'])).toBe(false);
112197
});
113198

114199
it('returns false for an empty URL', () => {
@@ -147,4 +232,4 @@ describe('Paystack Utils', () => {
147232
expect(Linking.openURL).not.toHaveBeenCalled();
148233
});
149234
});
150-
});
235+
});

development/PaystackProvider.tsx

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { SafeAreaView } from 'react-native-safe-area-context';
55
import {
66
PaystackParams,
77
PaystackProviderProps,
8+
TransactionMethod
89
} from './types';
910
import { validateParams, paystackHtmlContent, generatePaystackParams, handlePaystackMessage, shouldHandleExternally, openExternalUrl } from './utils';
1011
import { styles } from './styles';
@@ -17,6 +18,7 @@ export const PaystackContext = createContext<{
1718
popup: {
1819
checkout: (params: PaystackParams) => void;
1920
newTransaction: (params: PaystackParams) => void;
21+
resumeTransaction: (params: PaystackParams) => void;
2022
};
2123
} | null>(null);
2224

@@ -32,7 +34,7 @@ export const PaystackProvider: React.FC<PaystackProviderProps> = ({
3234
}) => {
3335
const [visible, setVisible] = useState(false);
3436
const [params, setParams] = useState<PaystackParams | null>(null);
35-
const [method, setMethod] = useState<'checkout' | 'newTransaction'>('checkout');
37+
const [method, setMethod] = useState<TransactionMethod>(TransactionMethod.CHECKOUT);
3638

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

@@ -42,7 +44,7 @@ export const PaystackProvider: React.FC<PaystackProviderProps> = ({
4244
);
4345

4446
const open = useCallback(
45-
(params: PaystackParams, selectedMethod: 'checkout' | 'newTransaction') => {
47+
(params: PaystackParams, selectedMethod: TransactionMethod) => {
4648
if (debug) console.log(`[Paystack] Opening modal with method: ${selectedMethod}`);
4749
if (!validateParams(params, debug)) return;
4850
setParams(params);
@@ -52,8 +54,9 @@ export const PaystackProvider: React.FC<PaystackProviderProps> = ({
5254
[debug]
5355
);
5456

55-
const checkout = (params: PaystackParams) => open(params, 'checkout');
56-
const newTransaction = (params: PaystackParams) => open(params, 'newTransaction');
57+
const checkout = (params: PaystackParams) => open(params, TransactionMethod.CHECKOUT);
58+
const newTransaction = (params: PaystackParams) => open(params, TransactionMethod.NEW_TRANSACTION);
59+
const resumeTransaction = (params: PaystackParams) => open(params, TransactionMethod.RESUME_TRANSACTION);
5760

5861
const close = () => {
5962
setVisible(false);
@@ -87,6 +90,7 @@ export const PaystackProvider: React.FC<PaystackProviderProps> = ({
8790
subaccount: params.subaccount,
8891
split: params.split,
8992
split_code: params.split_code,
93+
accessCode: params.accessCode,
9094
}),
9195
method
9296
);
@@ -97,7 +101,7 @@ export const PaystackProvider: React.FC<PaystackProviderProps> = ({
97101
}
98102

99103
return (
100-
<PaystackContext.Provider value={{ popup: { checkout, newTransaction } }}>
104+
<PaystackContext.Provider value={{ popup: { checkout, newTransaction, resumeTransaction } }}>
101105
{children}
102106
<Modal visible={visible} transparent animationType="slide">
103107
<SafeAreaView style={styles.container}>

0 commit comments

Comments
 (0)