react-native-fincra-checkout 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,309 @@
1
+ # react-native-fincra-checkout
2
+
3
+ <p align="center">
4
+ <img src="https://img.shields.io/npm/v/react-native-fincra-checkout?color=0066FF&style=flat-square" alt="npm version" />
5
+ <img src="https://img.shields.io/badge/TypeScript-100%25-blue?style=flat-square" alt="TypeScript" />
6
+ <img src="https://img.shields.io/badge/license-MIT-green?style=flat-square" alt="license" />
7
+ <img src="https://img.shields.io/badge/platform-iOS%20%7C%20Android-lightgrey?style=flat-square" alt="platforms" />
8
+ </p>
9
+
10
+ A **production-ready**, **100% TypeScript** React Native SDK for [Fincra Checkout](https://fincra.com/checkout), with full feature and architectural parity with the official `flutter_fincra_checkout` package.
11
+
12
+ ---
13
+
14
+ ## Features
15
+
16
+ - ✅ **Two checkout modes**: WebView (recommended) and Inline JavaScript
17
+ - ✅ **Imperative API**: `await FincraCheckout.openWebView({...})` from anywhere
18
+ - ✅ **Declarative API**: `<FincraWebViewCheckout />` and `<FincraInlineCheckout />`
19
+ - ✅ **Strongly-typed result**: Discriminated union — `success | error | cancelled`
20
+ - ✅ **URL interception**: Redirect URL prefix match + query-param fallback
21
+ - ✅ **15-second init timeout** for the Inline mode
22
+ - ✅ **Modern SafeAreaView** via `react-native-safe-area-context`
23
+ - ✅ **Built-in Error Recovery & Offline Retry UI** with custom `renderError` prop support
24
+ - ✅ **Android back button** support
25
+ - ✅ **Cancellation confirmation dialog** (optional)
26
+ - ✅ **XSS-safe** HTML generation (all inputs JSON-encoded)
27
+
28
+ ---
29
+
30
+ ## Installation
31
+
32
+ ```bash
33
+ npm install react-native-fincra-checkout react-native-webview react-native-safe-area-context
34
+ # or
35
+ yarn add react-native-fincra-checkout react-native-webview react-native-safe-area-context
36
+ ```
37
+
38
+ ### iOS — link native modules
39
+
40
+ ```bash
41
+ cd ios && pod install
42
+ ```
43
+
44
+ ### Android — no extra steps needed
45
+
46
+ `react-native-webview` auto-links on Android.
47
+
48
+ ---
49
+
50
+ ## ⚠️ Security Notice
51
+
52
+ > **Never store your Fincra Secret Key in your mobile app bundle.**
53
+ >
54
+ > - For **WebView Checkout**: Generate the `checkoutUrl` server-side using your secret key via the Fincra API, then pass the URL to the SDK.
55
+ > - For **Inline Checkout**: Only your **public key** (`pk_...`) is used. This is safe to bundle.
56
+ >
57
+ > Storing secret keys in client code exposes them to reverse engineering and can lead to fraudulent transactions.
58
+
59
+ ---
60
+
61
+ ## Setup — Add the Host Component
62
+
63
+ Add `<FincraCheckoutHost />` **once** at your app root. This enables the imperative `FincraCheckout.open*()` API:
64
+
65
+ ```tsx
66
+ // App.tsx
67
+ import { FincraCheckoutHost } from 'react-native-fincra-checkout';
68
+
69
+ export default function App() {
70
+ return (
71
+ <>
72
+ <NavigationContainer>
73
+ <RootNavigator />
74
+ </NavigationContainer>
75
+
76
+ {/* ← Add this once at the end of your root component */}
77
+ <FincraCheckoutHost />
78
+ </>
79
+ );
80
+ }
81
+ ```
82
+
83
+ > **Note**: The host renders nothing until a checkout is opened. It must be inside a rendered component tree (not a provider).
84
+
85
+ ---
86
+
87
+ ## WebView vs. Inline — Comparison
88
+
89
+ | Feature | WebView Checkout | Inline JS Checkout |
90
+ |---|---|---|
91
+ | **Trigger** | Backend-generated URL | Public key + params |
92
+ | **Key required** | Secret key *(server-side only)* | Public key *(client-safe)* |
93
+ | **Payment flow** | Full Fincra-hosted page | Embedded Fincra JS widget |
94
+ | **URL interception** | ✅ Redirect URL or query params | ❌ N/A (JS bridge events) |
95
+ | **Init timeout** | ❌ N/A | ✅ 15 seconds |
96
+ | **Recommended for** | Production (most secure) | Frontend-only prototypes |
97
+
98
+ ---
99
+
100
+ ## Usage
101
+
102
+ ### A. Imperative API (Promise / async-await)
103
+
104
+ #### WebView Mode — recommended
105
+
106
+ ```tsx
107
+ import { FincraCheckout } from 'react-native-fincra-checkout';
108
+
109
+ async function handlePayment() {
110
+ const result = await FincraCheckout.openWebView({
111
+ // Generated by your backend using Fincra API + secret key
112
+ checkoutUrl: 'https://checkout.fincra.com/pay/abc123',
113
+ // Your backend redirect URL — intercepted by the SDK
114
+ redirectUrl: 'https://api.yourapp.com/payment/callback',
115
+ headerTitle: 'Complete Payment',
116
+ showCancelConfirmationDialog: true,
117
+ });
118
+
119
+ switch (result.type) {
120
+ case 'success':
121
+ console.log('Payment successful:', result.response.reference);
122
+ break;
123
+ case 'error':
124
+ console.error('Payment failed:', result.error.message);
125
+ break;
126
+ case 'cancelled':
127
+ console.log('User cancelled the payment');
128
+ break;
129
+ }
130
+ }
131
+ ```
132
+
133
+ #### Inline Mode
134
+
135
+ ```tsx
136
+ import { FincraCheckout } from 'react-native-fincra-checkout';
137
+
138
+ async function handleInlinePayment() {
139
+ const result = await FincraCheckout.openInline({
140
+ publicKey: 'pk_live_xxxxxxxxxxxx',
141
+ amount: 5000, // in smallest currency unit (e.g., kobo for NGN)
142
+ currency: 'NGN',
143
+ customerEmail: 'customer@example.com',
144
+ customerName: 'Jane Doe',
145
+ customerPhoneNumber: '08012345678',
146
+ feeBearer: 'customer',
147
+ reference: 'ORDER-001', // optional — Fincra generates one if omitted
148
+ paymentMethods: ['card', 'bank_transfer'], // optional
149
+ });
150
+
151
+ if (result.type === 'success') {
152
+ const { reference, transactionId, status } = result.response;
153
+ console.log({ reference, transactionId, status });
154
+ }
155
+ }
156
+ ```
157
+
158
+ ---
159
+
160
+ ### B. Declarative Component API
161
+
162
+ Embed checkout views directly inside your own modals, bottom sheets, or navigation screens:
163
+
164
+ #### `<FincraWebViewCheckout />`
165
+
166
+ ```tsx
167
+ import { FincraWebViewCheckout } from 'react-native-fincra-checkout';
168
+
169
+ function PaymentScreen() {
170
+ return (
171
+ <FincraWebViewCheckout
172
+ checkoutUrl="https://checkout.fincra.com/pay/abc123"
173
+ redirectUrl="https://api.yourapp.com/payment/callback"
174
+ headerTitle="Secure Payment"
175
+ headerBackgroundColor="#0066FF"
176
+ headerTintColor="#FFFFFF"
177
+ showCancelConfirmationDialog
178
+ onSuccess={(response) => {
179
+ console.log('Success:', response.reference);
180
+ navigation.navigate('PaymentSuccess');
181
+ }}
182
+ onFailed={(error) => {
183
+ console.error('Error:', error.message);
184
+ }}
185
+ onCancelled={() => {
186
+ navigation.goBack();
187
+ }}
188
+ />
189
+ );
190
+ }
191
+ ```
192
+
193
+ #### `<FincraInlineCheckout />`
194
+
195
+ ```tsx
196
+ import { FincraInlineCheckout } from 'react-native-fincra-checkout';
197
+
198
+ function InlinePaymentScreen() {
199
+ return (
200
+ <FincraInlineCheckout
201
+ publicKey="pk_live_xxxxxxxxxxxx"
202
+ amount={10000}
203
+ currency="NGN"
204
+ customerEmail="customer@example.com"
205
+ customerName="John Doe"
206
+ customerPhoneNumber="08099887766"
207
+ feeBearer="business"
208
+ onSuccess={(response) => console.log(response)}
209
+ onFailed={(error) => console.error(error)}
210
+ onCancelled={() => navigation.goBack()}
211
+ />
212
+ );
213
+ }
214
+ ```
215
+
216
+ ---
217
+
218
+ ## TypeScript Types
219
+
220
+ ```typescript
221
+ import type {
222
+ FincraCheckoutResult,
223
+ FincraPaymentResponse,
224
+ FincraPaymentError,
225
+ WebViewCheckoutConfig,
226
+ InlineCheckoutConfig,
227
+ FincraCurrency,
228
+ FeeBearer,
229
+ } from 'react-native-fincra-checkout';
230
+
231
+ // Discriminated union result
232
+ const result: FincraCheckoutResult =
233
+ | { type: 'success'; response: FincraPaymentResponse }
234
+ | { type: 'error'; error: FincraPaymentError }
235
+ | { type: 'cancelled' };
236
+ ```
237
+
238
+ ### Supported Currencies
239
+
240
+ `NGN` · `USD` · `GBP` · `EUR` · `GHS` · `KES` · `ZAR` · `UGX` · `XAF` · `XOF`
241
+
242
+ ---
243
+
244
+ ## Props Reference
245
+
246
+ ### Shared (`BaseCheckoutProps`)
247
+
248
+ | Prop | Type | Default | Description |
249
+ |---|---|---|---|
250
+ | `onSuccess` | `(response) => void` | — | Called on successful payment |
251
+ | `onFailed` | `(error) => void` | — | Called on payment error |
252
+ | `onCancelled` | `() => void` | — | Called when user cancels |
253
+ | `headerTitle` | `string` | `'Secure Checkout'` | Navigation bar title |
254
+ | `headerBackgroundColor` | `string` | `'#FFFFFF'` | Nav bar background color |
255
+ | `headerTintColor` | `string` | `'#000000'` | Nav bar text/icon color |
256
+ | `showCancelConfirmationDialog` | `boolean` | `false` | Show Alert before closing |
257
+ | `loadingComponent` | `ReactNode` | `ActivityIndicator` | Custom loading spinner |
258
+ | `closeIcon` | `ReactNode` | `✕` text | Custom close button content |
259
+
260
+ ### `WebViewCheckoutConfig`
261
+
262
+ | Prop | Type | Required | Description |
263
+ |---|---|---|---|
264
+ | `checkoutUrl` | `string` | ✅ | Backend-generated Fincra checkout URL |
265
+ | `redirectUrl` | `string` | — | Redirect URL to intercept for completion |
266
+
267
+ ### `InlineCheckoutConfig`
268
+
269
+ | Prop | Type | Required | Description |
270
+ |---|---|---|---|
271
+ | `publicKey` | `string` | ✅ | Your Fincra public key (`pk_...`) |
272
+ | `amount` | `number` | ✅ | Amount in smallest currency unit |
273
+ | `currency` | `FincraCurrency` | ✅ | Payment currency |
274
+ | `customerEmail` | `string` | ✅ | Customer email |
275
+ | `customerName` | `string` | ✅ | Customer full name |
276
+ | `customerPhoneNumber` | `string` | ✅ | Customer phone number |
277
+ | `feeBearer` | `FeeBearer` | ✅ | `'business'` or `'customer'` |
278
+ | `reference` | `string` | — | Custom transaction reference |
279
+ | `paymentMethods` | `string[]` | — | Restrict to specific methods |
280
+
281
+ ---
282
+
283
+ ## How URL Interception Works
284
+
285
+ The WebView mode intercepts navigation requests:
286
+
287
+ 1. **If `redirectUrl` is set**: Any URL starting with `redirectUrl` triggers completion (prefix match — mirrors Flutter's `url.startsWith(redirectUrl)`).
288
+ 2. **Fallback** (no `redirectUrl`): Completion is detected when both `status` (or `payment_status`) **and** `reference` query params are present.
289
+
290
+ Response parameters are normalized:
291
+ - `customerReference` → `reference` (preferred)
292
+ - `merchantReference` → `reference` (fallback)
293
+ - `transactionReference` → `transactionId`
294
+
295
+ ---
296
+
297
+ ## Running Tests
298
+
299
+ ```bash
300
+ npm test
301
+ ```
302
+
303
+ Tests cover `UrlHandler` (URL detection, param extraction, reference normalization) and `JsBridge` (event parsing, data coercion, malformed input handling) — no device or emulator required.
304
+
305
+ ---
306
+
307
+ ## License
308
+
309
+ MIT © [Fincra](https://fincra.com)
@@ -0,0 +1,249 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
35
+ Object.defineProperty(exports, "__esModule", { value: true });
36
+ exports.FincraCheckout = exports.FincraCheckoutHost = void 0;
37
+ exports._registerHostRef = _registerHostRef;
38
+ exports._unregisterHostRef = _unregisterHostRef;
39
+ exports.FincraCheckoutHostRegistrar = FincraCheckoutHostRegistrar;
40
+ const react_1 = __importStar(require("react"));
41
+ const react_native_1 = require("react-native");
42
+ const FincraWebViewCheckout_1 = require("../components/FincraWebViewCheckout");
43
+ const FincraInlineCheckout_1 = require("../components/FincraInlineCheckout");
44
+ /**
45
+ * Place this component once at your app root (inside your root view, after
46
+ * your navigator/providers). It renders nothing until `FincraCheckout.open*()`
47
+ * is called — then it mounts a full-screen `Modal` over the current UI.
48
+ *
49
+ * @example
50
+ * ```tsx
51
+ * // App.tsx
52
+ * export default function App() {
53
+ * return (
54
+ * <NavigationContainer>
55
+ * <RootNavigator />
56
+ * <FincraCheckoutHost /> {/* ← add this once *\/}
57
+ * </NavigationContainer>
58
+ * );
59
+ * }
60
+ * ```
61
+ */
62
+ exports.FincraCheckoutHost = (0, react_1.forwardRef)(function FincraCheckoutHost(_props, ref) {
63
+ const [modalState, setModalState] = (0, react_1.useState)({ mode: null });
64
+ const resolveRef = (0, react_1.useRef)(null);
65
+ // ── Resolve and dismiss ────────────────────────────────────────────────────
66
+ const resolve = (0, react_1.useCallback)((result) => {
67
+ setModalState({ mode: null });
68
+ resolveRef.current?.(result);
69
+ resolveRef.current = null;
70
+ }, []);
71
+ // ── Expose imperative methods via ref ──────────────────────────────────────
72
+ (0, react_1.useImperativeHandle)(ref, () => ({
73
+ // Fix #1: guard against double-open — reject instead of orphaning the
74
+ // pending Promise and silently clobbering resolveRef.
75
+ _openWebView(config) {
76
+ if (resolveRef.current) {
77
+ return Promise.reject(new Error('[FincraCheckout] A checkout session is already open. ' +
78
+ 'Await the current session before opening another.'));
79
+ }
80
+ return new Promise((res) => {
81
+ resolveRef.current = res;
82
+ setModalState({ mode: 'webview', webViewConfig: config });
83
+ });
84
+ },
85
+ _openInline(config) {
86
+ if (resolveRef.current) {
87
+ return Promise.reject(new Error('[FincraCheckout] A checkout session is already open. ' +
88
+ 'Await the current session before opening another.'));
89
+ }
90
+ return new Promise((res) => {
91
+ resolveRef.current = res;
92
+ setModalState({ mode: 'inline', inlineConfig: config });
93
+ });
94
+ },
95
+ }), [ /* resolve not needed — used via resolveRef */]);
96
+ const isVisible = modalState.mode !== null;
97
+ // ── Shared callback builders ───────────────────────────────────────────────
98
+ const buildCallbacks = (0, react_1.useCallback)((config) => ({
99
+ onSuccess: (response) => {
100
+ config.onSuccess?.(response);
101
+ resolve({ type: 'success', response });
102
+ },
103
+ onFailed: (error) => {
104
+ config.onFailed?.(error);
105
+ resolve({ type: 'error', error });
106
+ },
107
+ onCancelled: () => {
108
+ config.onCancelled?.();
109
+ resolve({ type: 'cancelled' });
110
+ },
111
+ }), [resolve]);
112
+ return (react_1.default.createElement(react_native_1.Modal, { visible: isVisible, animationType: "slide", presentationStyle: "fullScreen", statusBarTranslucent: true, onRequestClose: () => {
113
+ // Android hardware back — treat as cancellation
114
+ const cfg = modalState.webViewConfig ?? modalState.inlineConfig;
115
+ if (cfg) {
116
+ cfg.onCancelled?.();
117
+ }
118
+ resolve({ type: 'cancelled' });
119
+ } },
120
+ react_1.default.createElement(react_native_1.View, { style: styles.fullscreen },
121
+ modalState.mode === 'webview' && modalState.webViewConfig && (react_1.default.createElement(FincraWebViewCheckout_1.FincraWebViewCheckout, { ...modalState.webViewConfig, ...buildCallbacks(modalState.webViewConfig) })),
122
+ modalState.mode === 'inline' && modalState.inlineConfig && (react_1.default.createElement(FincraInlineCheckout_1.FincraInlineCheckout, { ...modalState.inlineConfig, ...buildCallbacks(modalState.inlineConfig) })))));
123
+ });
124
+ // ─── Singleton Ref ─────────────────────────────────────────────────────────────
125
+ // A module-level ref that FincraCheckout.open*() calls are routed through.
126
+ // Set by the first <FincraCheckoutHost /> that mounts.
127
+ let _hostRef = null;
128
+ /**
129
+ * @internal
130
+ * Called by `<FincraCheckoutHostRegistrar />` to register the singleton ref.
131
+ * Not part of the public API — do not call this directly.
132
+ */
133
+ function _registerHostRef(ref) {
134
+ _hostRef = ref;
135
+ }
136
+ /**
137
+ * @internal
138
+ * Clears the singleton ref when the host unmounts.
139
+ * Not part of the public API — do not call this directly.
140
+ */
141
+ function _unregisterHostRef() {
142
+ _hostRef = null;
143
+ }
144
+ // ─── Public FincraCheckout Static API ─────────────────────────────────────────
145
+ /**
146
+ * Imperative static API for opening Fincra Checkout modals from anywhere
147
+ * in your app — no navigation prop or context required.
148
+ *
149
+ * **Prerequisite**: `<FincraCheckoutHost />` must be mounted at your app root.
150
+ *
151
+ * @example
152
+ * ```typescript
153
+ * // WebView mode (recommended — backend-generated URL)
154
+ * const result = await FincraCheckout.openWebView({
155
+ * checkoutUrl: 'https://checkout.fincra.com/pay/...',
156
+ * redirectUrl: 'https://api.yourapp.com/payment/callback',
157
+ * });
158
+ *
159
+ * // Inline mode (frontend-initiated)
160
+ * const result = await FincraCheckout.openInline({
161
+ * publicKey: 'pk_live_...',
162
+ * amount: 5000,
163
+ * currency: 'NGN',
164
+ * customerEmail: 'user@example.com',
165
+ * customerName: 'Jane Doe',
166
+ * customerPhoneNumber: '08012345678',
167
+ * feeBearer: 'customer',
168
+ * });
169
+ *
170
+ * switch (result.type) {
171
+ * case 'success': console.log(result.response.reference); break;
172
+ * case 'error': console.error(result.error.message); break;
173
+ * case 'cancelled': console.log('User cancelled'); break;
174
+ * }
175
+ * ```
176
+ */
177
+ class FincraCheckout {
178
+ /**
179
+ * Opens the WebView checkout in a full-screen modal.
180
+ *
181
+ * This is the **recommended** flow — your backend generates the `checkoutUrl`
182
+ * using the Fincra API with your **secret key** (never in the app).
183
+ *
184
+ * @param config - WebView checkout configuration.
185
+ * @returns A promise resolving to a `FincraCheckoutResult` discriminated union.
186
+ * @throws Error if `<FincraCheckoutHost />` is not mounted.
187
+ * @throws Error if a checkout session is already open.
188
+ */
189
+ static openWebView(config) {
190
+ FincraCheckout._assertHostMounted();
191
+ return _hostRef.current._openWebView(config);
192
+ }
193
+ /**
194
+ * Opens the Inline JavaScript checkout in a full-screen modal.
195
+ *
196
+ * Uses only the Fincra **public key** (`pk_...`).
197
+ * The Fincra JS SDK is loaded from the CDN at runtime.
198
+ *
199
+ * @param config - Inline checkout configuration.
200
+ * @returns A promise resolving to a `FincraCheckoutResult` discriminated union.
201
+ * @throws Error if `<FincraCheckoutHost />` is not mounted.
202
+ * @throws Error if a checkout session is already open.
203
+ */
204
+ static openInline(config) {
205
+ FincraCheckout._assertHostMounted();
206
+ return _hostRef.current._openInline(config);
207
+ }
208
+ static _assertHostMounted() {
209
+ if (!_hostRef?.current) {
210
+ throw new Error('[react-native-fincra-checkout] FincraCheckoutHost is not mounted. ' +
211
+ 'Add <FincraCheckoutHost /> to your App root before calling FincraCheckout.open*().');
212
+ }
213
+ }
214
+ }
215
+ exports.FincraCheckout = FincraCheckout;
216
+ // ─── Self-registering Host wrapper ────────────────────────────────────────────
217
+ /**
218
+ * The component you add to your app root.
219
+ * It self-registers as the singleton checkout host.
220
+ *
221
+ * @example
222
+ * ```tsx
223
+ * // App.tsx
224
+ * import { FincraCheckoutHost } from 'react-native-fincra-checkout';
225
+ *
226
+ * export default function App() {
227
+ * return (
228
+ * <>
229
+ * <YourApp />
230
+ * <FincraCheckoutHost />
231
+ * </>
232
+ * );
233
+ * }
234
+ * ```
235
+ */
236
+ function FincraCheckoutHostRegistrar() {
237
+ const ref = (0, react_1.useRef)(null);
238
+ (0, react_1.useEffect)(() => {
239
+ _registerHostRef(ref);
240
+ return () => _unregisterHostRef();
241
+ }, []);
242
+ return react_1.default.createElement(exports.FincraCheckoutHost, { ref: ref });
243
+ }
244
+ // ─── Styles ────────────────────────────────────────────────────────────────────
245
+ const styles = react_native_1.StyleSheet.create({
246
+ fullscreen: {
247
+ flex: 1,
248
+ },
249
+ });