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: 40 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -103,11 +103,38 @@ const Checkout = () => {
};
```

### Resume transaction
<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.
</p>

```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 <Button title="Resume Payment" onPress={resumePayment} />;
};
```

---

## 🧠 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
Expand Down Expand Up @@ -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
Expand Down
175 changes: 130 additions & 45 deletions __tests__/index.test.tsx
Original file line number Diff line number Diff line change
@@ -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'),
);
});
});

Expand Down Expand Up @@ -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', () => {
Expand Down Expand Up @@ -147,4 +232,4 @@ describe('Paystack Utils', () => {
expect(Linking.openURL).not.toHaveBeenCalled();
});
});
});
});
14 changes: 9 additions & 5 deletions development/PaystackProvider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -17,6 +18,7 @@ export const PaystackContext = createContext<{
popup: {
checkout: (params: PaystackParams) => void;
newTransaction: (params: PaystackParams) => void;
resumeTransaction: (params: PaystackParams) => void;
};
} | null>(null);

Expand All @@ -32,7 +34,7 @@ export const PaystackProvider: React.FC<PaystackProviderProps> = ({
}) => {
const [visible, setVisible] = useState(false);
const [params, setParams] = useState<PaystackParams | null>(null);
const [method, setMethod] = useState<'checkout' | 'newTransaction'>('checkout');
const [method, setMethod] = useState<TransactionMethod>(TransactionMethod.CHECKOUT);
Comment thread
just1and0 marked this conversation as resolved.

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

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

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);
Expand All @@ -52,8 +54,9 @@ export const PaystackProvider: React.FC<PaystackProviderProps> = ({
[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);
Comment thread
just1and0 marked this conversation as resolved.

const close = () => {
setVisible(false);
Expand Down Expand Up @@ -87,6 +90,7 @@ export const PaystackProvider: React.FC<PaystackProviderProps> = ({
subaccount: params.subaccount,
split: params.split,
split_code: params.split_code,
accessCode: params.accessCode,
}),
method
);
Expand All @@ -97,7 +101,7 @@ export const PaystackProvider: React.FC<PaystackProviderProps> = ({
}

return (
<PaystackContext.Provider value={{ popup: { checkout, newTransaction } }}>
<PaystackContext.Provider value={{ popup: { checkout, newTransaction, resumeTransaction } }}>
{children}
<Modal visible={visible} transparent animationType="slide">
<SafeAreaView style={styles.container}>
Expand Down
Loading
Loading