react-native-fincra-checkout 1.0.1 → 1.0.2
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 +52 -10
- package/lib/commonjs/checkout/FincraCheckout.js +41 -45
- package/lib/commonjs/components/FincraInlineCheckout.js +110 -97
- package/lib/commonjs/components/FincraWebViewCheckout.js +99 -72
- package/lib/commonjs/inline/JsBridge.js +21 -15
- package/lib/commonjs/inline/htmlGenerator.js +53 -31
- package/lib/commonjs/utils/UrlHandler.js +62 -3
- package/lib/typescript/checkout/FincraCheckout.d.ts +0 -1
- package/lib/typescript/components/FincraInlineCheckout.d.ts +1 -2
- package/lib/typescript/components/FincraWebViewCheckout.d.ts +1 -1
- package/lib/typescript/inline/JsBridge.d.ts +4 -1
- package/lib/typescript/inline/htmlGenerator.d.ts +30 -0
- package/lib/typescript/types/index.d.ts +14 -3
- package/lib/typescript/utils/UrlHandler.d.ts +30 -1
- package/package.json +1 -1
- package/src/checkout/FincraCheckout.tsx +75 -69
- package/src/components/FincraInlineCheckout.tsx +202 -159
- package/src/components/FincraWebViewCheckout.tsx +184 -138
- package/src/inline/JsBridge.ts +28 -20
- package/src/inline/htmlGenerator.ts +65 -35
- package/src/types/index.ts +14 -3
- package/src/utils/UrlHandler.ts +76 -3
|
@@ -53,12 +53,30 @@ const UrlHandler_1 = require("../utils/UrlHandler");
|
|
|
53
53
|
* />
|
|
54
54
|
* ```
|
|
55
55
|
*/
|
|
56
|
-
function FincraWebViewCheckout({ checkoutUrl, redirectUrl, headerTitle = 'Secure Checkout', headerBackgroundColor = '#FFFFFF', headerTintColor = '#000000', showCancelConfirmationDialog = false, loadingComponent, closeIcon, renderError, onSuccess, onFailed, onCancelled, }) {
|
|
56
|
+
function FincraWebViewCheckout({ checkoutUrl, redirectUrl, headerTitle = 'Secure Checkout', headerBackgroundColor = '#FFFFFF', headerTintColor = '#000000', showCancelConfirmationDialog = false, showCloseButton = true, loadingComponent, closeIcon, renderError, onSuccess, onFailed, onCancelled, }) {
|
|
57
57
|
const [isLoading, setIsLoading] = (0, react_1.useState)(true);
|
|
58
58
|
const [errorState, setErrorState] = (0, react_1.useState)(null);
|
|
59
59
|
const [reloadKey, setReloadKey] = (0, react_1.useState)(0);
|
|
60
60
|
const webViewRef = (0, react_1.useRef)(null);
|
|
61
|
-
|
|
61
|
+
// `settledRef`: a result (success / error / cancel) has been delivered.
|
|
62
|
+
// `isMountedRef`: late native callbacks after unmount must be ignored.
|
|
63
|
+
const settledRef = (0, react_1.useRef)(false);
|
|
64
|
+
const isMountedRef = (0, react_1.useRef)(false);
|
|
65
|
+
(0, react_1.useEffect)(() => {
|
|
66
|
+
isMountedRef.current = true;
|
|
67
|
+
return () => {
|
|
68
|
+
isMountedRef.current = false;
|
|
69
|
+
};
|
|
70
|
+
}, []);
|
|
71
|
+
/** True while callbacks may still update state or deliver a result. */
|
|
72
|
+
const isActive = (0, react_1.useCallback)(() => isMountedRef.current && !settledRef.current, []);
|
|
73
|
+
/** Single exit point — delivers at most one result per session. */
|
|
74
|
+
const settle = (0, react_1.useCallback)((deliver) => {
|
|
75
|
+
if (!isMountedRef.current || settledRef.current)
|
|
76
|
+
return;
|
|
77
|
+
settledRef.current = true;
|
|
78
|
+
deliver();
|
|
79
|
+
}, []);
|
|
62
80
|
// Fix #10: Stable ref for handleCancellation — the BackHandler effect
|
|
63
81
|
// always calls through this ref, never capturing a stale closure.
|
|
64
82
|
const handleCancellationRef = (0, react_1.useRef)(() => { });
|
|
@@ -74,24 +92,23 @@ function FincraWebViewCheckout({ checkoutUrl, redirectUrl, headerTitle = 'Secure
|
|
|
74
92
|
}, []); // safe: always calls through the ref
|
|
75
93
|
// ── Completion handler ──────────────────────────────────────────────────────
|
|
76
94
|
const handleCompletion = (0, react_1.useCallback)((url) => {
|
|
77
|
-
if (hasCompleted.current)
|
|
78
|
-
return;
|
|
79
|
-
hasCompleted.current = true;
|
|
80
95
|
const params = UrlHandler_1.UrlHandler.extractResponseParams(url);
|
|
81
|
-
//
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
96
|
+
// `status` or `payment_status`; missing → success (sandbox omits it).
|
|
97
|
+
// Merchants must verify every payment server-side.
|
|
98
|
+
const rawStatus = UrlHandler_1.UrlHandler.extractStatus(params);
|
|
99
|
+
settle(() => {
|
|
100
|
+
if (UrlHandler_1.UrlHandler.isSuccessStatus(rawStatus)) {
|
|
101
|
+
onSuccess?.(UrlHandler_1.UrlHandler.parsePaymentResponse(params));
|
|
102
|
+
}
|
|
103
|
+
else {
|
|
104
|
+
const err = {
|
|
105
|
+
code: rawStatus,
|
|
106
|
+
message: params['message'] ?? 'Payment failed',
|
|
107
|
+
};
|
|
108
|
+
onFailed?.(err);
|
|
109
|
+
}
|
|
110
|
+
});
|
|
111
|
+
}, [settle, onSuccess, onFailed]);
|
|
95
112
|
// ── URL interception ────────────────────────────────────────────────────────
|
|
96
113
|
const onShouldStartLoadWithRequest = (0, react_1.useCallback)((request) => {
|
|
97
114
|
if (UrlHandler_1.UrlHandler.isCompletionUrl(request.url, redirectUrl)) {
|
|
@@ -102,7 +119,7 @@ function FincraWebViewCheckout({ checkoutUrl, redirectUrl, headerTitle = 'Secure
|
|
|
102
119
|
}, [redirectUrl, handleCompletion]);
|
|
103
120
|
// ── Cancellation ────────────────────────────────────────────────────────────
|
|
104
121
|
const handleCancellation = (0, react_1.useCallback)(() => {
|
|
105
|
-
if (
|
|
122
|
+
if (!isActive())
|
|
106
123
|
return;
|
|
107
124
|
if (showCancelConfirmationDialog) {
|
|
108
125
|
react_native_1.Alert.alert('Cancel Payment?', 'Are you sure you want to cancel this payment?', [
|
|
@@ -110,42 +127,44 @@ function FincraWebViewCheckout({ checkoutUrl, redirectUrl, headerTitle = 'Secure
|
|
|
110
127
|
{
|
|
111
128
|
text: 'Yes',
|
|
112
129
|
style: 'destructive',
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
hasCompleted.current = true;
|
|
116
|
-
onCancelled?.();
|
|
117
|
-
}
|
|
118
|
-
},
|
|
130
|
+
// May fire after unmount — settle() ignores it then.
|
|
131
|
+
onPress: () => settle(() => onCancelled?.()),
|
|
119
132
|
},
|
|
120
133
|
]);
|
|
121
134
|
}
|
|
122
135
|
else {
|
|
123
|
-
|
|
124
|
-
onCancelled?.();
|
|
136
|
+
settle(() => onCancelled?.());
|
|
125
137
|
}
|
|
126
|
-
}, [showCancelConfirmationDialog, onCancelled]);
|
|
138
|
+
}, [isActive, showCancelConfirmationDialog, settle, onCancelled]);
|
|
127
139
|
// Keep the ref in sync after every render (Fix #10)
|
|
128
140
|
(0, react_1.useEffect)(() => {
|
|
129
141
|
handleCancellationRef.current = handleCancellation;
|
|
130
142
|
});
|
|
131
|
-
// ──
|
|
132
|
-
|
|
133
|
-
|
|
143
|
+
// ── Load errors ─────────────────────────────────────────────────────────────
|
|
144
|
+
// react-native-webview only reports main-frame failures via onError /
|
|
145
|
+
// onHttpError (sub-resource errors are filtered natively). Load errors never
|
|
146
|
+
// settle the payment: they show the recoverable Retry/Cancel overlay.
|
|
147
|
+
// Errors for the redirect URL are expected (we block that navigation).
|
|
148
|
+
const showLoadError = (0, react_1.useCallback)((url, err) => {
|
|
149
|
+
if (!isActive())
|
|
150
|
+
return;
|
|
151
|
+
if (url && UrlHandler_1.UrlHandler.isCompletionUrl(url, redirectUrl))
|
|
134
152
|
return;
|
|
135
|
-
const { nativeEvent } = syntheticEvent;
|
|
136
|
-
const err = {
|
|
137
|
-
code: String(nativeEvent.code ?? 'webview_error'),
|
|
138
|
-
message: nativeEvent.description ?? 'A WebView error occurred.',
|
|
139
|
-
};
|
|
140
153
|
setErrorState(err);
|
|
141
154
|
setIsLoading(false);
|
|
142
|
-
}, []);
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
}
|
|
148
|
-
}, [
|
|
155
|
+
}, [isActive, redirectUrl]);
|
|
156
|
+
const handleError = (0, react_1.useCallback)(({ nativeEvent }) => {
|
|
157
|
+
showLoadError(nativeEvent.url, {
|
|
158
|
+
code: String(nativeEvent.code ?? 'webview_error'),
|
|
159
|
+
message: nativeEvent.description ?? 'A WebView error occurred.',
|
|
160
|
+
});
|
|
161
|
+
}, [showLoadError]);
|
|
162
|
+
const handleHttpError = (0, react_1.useCallback)(({ nativeEvent }) => {
|
|
163
|
+
showLoadError(nativeEvent.url, {
|
|
164
|
+
code: String(nativeEvent.statusCode ?? 'http_error'),
|
|
165
|
+
message: nativeEvent.description || `HTTP error ${nativeEvent.statusCode}`,
|
|
166
|
+
});
|
|
167
|
+
}, [showLoadError]);
|
|
149
168
|
// ── Retry handler ───────────────────────────────────────────────────────────
|
|
150
169
|
const handleRetry = (0, react_1.useCallback)(() => {
|
|
151
170
|
setErrorState(null);
|
|
@@ -155,45 +174,53 @@ function FincraWebViewCheckout({ checkoutUrl, redirectUrl, headerTitle = 'Secure
|
|
|
155
174
|
// ── Navigation state change (iOS fallback) ──────────────────────────────────
|
|
156
175
|
// Fix #2: guard setIsLoading — don't flip loading state after completion.
|
|
157
176
|
const onNavigationStateChange = (0, react_1.useCallback)((navState) => {
|
|
158
|
-
if (
|
|
177
|
+
if (!isActive())
|
|
159
178
|
return;
|
|
160
179
|
if (navState.url &&
|
|
161
180
|
UrlHandler_1.UrlHandler.isCompletionUrl(navState.url, redirectUrl)) {
|
|
162
181
|
handleCompletion(navState.url);
|
|
163
182
|
}
|
|
164
|
-
}, [redirectUrl, handleCompletion]);
|
|
183
|
+
}, [isActive, redirectUrl, handleCompletion]);
|
|
165
184
|
// ── Computed status bar style (Fix #11) ─────────────────────────────────────
|
|
166
185
|
const statusBarStyle = headerTintColor === '#000000' ? 'dark-content' : 'light-content';
|
|
167
186
|
// ── Render ──────────────────────────────────────────────────────────────────
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
react_1.default.createElement(
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
react_1.default.createElement(react_native_1.
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
react_1.default.createElement(react_native_1.Text, { style: styles.
|
|
187
|
+
// Own SafeAreaProvider: SafeAreaView reads insets from the nearest provider,
|
|
188
|
+
// and a Modal (FincraCheckoutHost) is a separate native tree. Without one the
|
|
189
|
+
// insets are 0 and the header sits under the status bar / Dynamic Island,
|
|
190
|
+
// where iOS swallows taps. Nested providers are fine if the app has its own.
|
|
191
|
+
return (react_1.default.createElement(react_native_safe_area_context_1.SafeAreaProvider, { style: styles.provider },
|
|
192
|
+
react_1.default.createElement(react_native_safe_area_context_1.SafeAreaView, { style: styles.container },
|
|
193
|
+
react_1.default.createElement(react_native_1.StatusBar, { barStyle: statusBarStyle, backgroundColor: headerBackgroundColor }),
|
|
194
|
+
react_1.default.createElement(react_native_1.View, { style: [styles.header, { backgroundColor: headerBackgroundColor }] },
|
|
195
|
+
showCloseButton ? (react_1.default.createElement(react_native_1.TouchableOpacity, { style: styles.closeButton, onPress: handleCancellation, accessibilityLabel: "Close checkout", accessibilityRole: "button", hitSlop: { top: 10, bottom: 10, left: 10, right: 10 } }, closeIcon ?? (react_1.default.createElement(react_native_1.Text, { style: [styles.closeIcon, { color: headerTintColor }] }, "\u2715")))) : (react_1.default.createElement(react_native_1.View, { style: styles.closeButton })),
|
|
196
|
+
react_1.default.createElement(react_native_1.Text, { style: [styles.headerTitle, { color: headerTintColor }], numberOfLines: 1 }, headerTitle),
|
|
197
|
+
react_1.default.createElement(react_native_1.View, { style: styles.closeButton })),
|
|
198
|
+
react_1.default.createElement(react_native_1.View, { style: styles.webViewContainer },
|
|
199
|
+
react_1.default.createElement(react_native_webview_1.WebView, { key: reloadKey, ref: webViewRef, source: { uri: checkoutUrl }, style: styles.webView, javaScriptEnabled: true, domStorageEnabled: true, startInLoadingState: false, onLoadStart: () => {
|
|
200
|
+
if (isActive())
|
|
201
|
+
setIsLoading(true);
|
|
202
|
+
}, onLoadEnd: () => {
|
|
203
|
+
if (isActive())
|
|
204
|
+
setIsLoading(false);
|
|
205
|
+
}, onError: handleError, onHttpError: handleHttpError, onShouldStartLoadWithRequest: onShouldStartLoadWithRequest, onNavigationStateChange: onNavigationStateChange,
|
|
206
|
+
// Fix #14: restrict to HTTPS + about:blank — prevents intent:// and
|
|
207
|
+
// other dangerous scheme navigations in a payment context.
|
|
208
|
+
originWhitelist: ['https://*', 'about:blank'] }),
|
|
209
|
+
isLoading && !errorState && (react_1.default.createElement(react_native_1.View, { style: styles.loadingOverlay, pointerEvents: "none" }, loadingComponent ?? (react_1.default.createElement(react_native_1.ActivityIndicator, { size: "large", color: "#0066FF" })))),
|
|
210
|
+
errorState && (react_1.default.createElement(react_native_1.View, { style: styles.errorOverlay }, renderError ? (renderError(errorState, handleRetry)) : (react_1.default.createElement(react_native_1.View, { style: styles.errorContainer },
|
|
211
|
+
react_1.default.createElement(react_native_1.Text, { style: styles.errorIcon }, "\u26A0\uFE0F"),
|
|
212
|
+
react_1.default.createElement(react_native_1.Text, { style: styles.errorTitle }, "Connection Error"),
|
|
213
|
+
react_1.default.createElement(react_native_1.Text, { style: styles.errorMessage }, errorState.message),
|
|
214
|
+
react_1.default.createElement(react_native_1.TouchableOpacity, { style: styles.retryButton, onPress: handleRetry, accessibilityRole: "button", accessibilityLabel: "Retry loading checkout" },
|
|
215
|
+
react_1.default.createElement(react_native_1.Text, { style: styles.retryButtonText }, "Retry")),
|
|
216
|
+
react_1.default.createElement(react_native_1.TouchableOpacity, { style: styles.cancelButton, onPress: handleCancellation, accessibilityRole: "button", accessibilityLabel: "Cancel checkout" },
|
|
217
|
+
react_1.default.createElement(react_native_1.Text, { style: styles.cancelButtonText }, "Cancel"))))))))));
|
|
194
218
|
}
|
|
195
219
|
// ─── Styles ────────────────────────────────────────────────────────────────────
|
|
196
220
|
const styles = react_native_1.StyleSheet.create({
|
|
221
|
+
provider: {
|
|
222
|
+
flex: 1,
|
|
223
|
+
},
|
|
197
224
|
container: {
|
|
198
225
|
flex: 1,
|
|
199
226
|
backgroundColor: '#FFFFFF',
|
|
@@ -40,22 +40,15 @@ function parseMessage(jsonString) {
|
|
|
40
40
|
}
|
|
41
41
|
const eventStr = typeof map['event'] === 'string' ? map['event'] : null;
|
|
42
42
|
const event = _parseEvent(eventStr);
|
|
43
|
-
if (event === FincraBridgeEvent.Success
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
!Array.isArray(map['data'])) {
|
|
47
|
-
// Fix #5: use a spread copy instead of mutating the JSON.parse result
|
|
43
|
+
if (event === FincraBridgeEvent.Success) {
|
|
44
|
+
// A success event is always a success — even with missing/malformed
|
|
45
|
+
// data (empty references), so a real payment is never hidden.
|
|
48
46
|
const rawData = map['data'];
|
|
49
|
-
const
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
// Coerce all values to strings (mirrors Flutter's `.map((k,v) => MapEntry(k, v.toString()))`)
|
|
55
|
-
const params = {};
|
|
56
|
-
for (const [k, v] of Object.entries(dataMap)) {
|
|
57
|
-
params[k] = String(v);
|
|
58
|
-
}
|
|
47
|
+
const params = rawData != null && typeof rawData === 'object' && !Array.isArray(rawData)
|
|
48
|
+
? normalizeSuccessData(rawData)
|
|
49
|
+
: {};
|
|
50
|
+
if (!params['status'])
|
|
51
|
+
params['status'] = 'success';
|
|
59
52
|
return { event, data: UrlHandler_1.UrlHandler.parsePaymentResponse(params) };
|
|
60
53
|
}
|
|
61
54
|
if (event === FincraBridgeEvent.Error &&
|
|
@@ -75,6 +68,19 @@ function parseMessage(jsonString) {
|
|
|
75
68
|
}
|
|
76
69
|
}
|
|
77
70
|
// ── Internal ──────────────────────────────────────────────────────────────────
|
|
71
|
+
/**
|
|
72
|
+
* Converts success data to a string map: objects/arrays are JSON-encoded,
|
|
73
|
+
* other values stringified, and `null`/`undefined` values dropped.
|
|
74
|
+
*/
|
|
75
|
+
function normalizeSuccessData(data) {
|
|
76
|
+
const params = {};
|
|
77
|
+
for (const [k, v] of Object.entries(data)) {
|
|
78
|
+
if (v == null)
|
|
79
|
+
continue;
|
|
80
|
+
params[k] = typeof v === 'object' ? JSON.stringify(v) : String(v);
|
|
81
|
+
}
|
|
82
|
+
return params;
|
|
83
|
+
}
|
|
78
84
|
function _parseEvent(eventStr) {
|
|
79
85
|
switch (eventStr) {
|
|
80
86
|
case 'ready':
|
|
@@ -1,14 +1,60 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.buildInlineOptions = buildInlineOptions;
|
|
4
|
+
exports.toScriptJson = toScriptJson;
|
|
3
5
|
exports.generateInlineHtml = generateInlineHtml;
|
|
4
6
|
// ─── HTML Generator ────────────────────────────────────────────────────────────
|
|
5
7
|
//
|
|
6
8
|
// Mirrors `_generateHtml()` in flutter_fincra_checkout/lib/src/inline/inline_checkout.dart
|
|
7
9
|
//
|
|
8
|
-
// Security:
|
|
9
|
-
//
|
|
10
|
-
//
|
|
10
|
+
// Security: the whole options object is encoded via toScriptJson() — JSON
|
|
11
|
+
// plus escaping of `<`, `>`, `&`, U+2028 and U+2029 — so no input value can
|
|
12
|
+
// inject JS or close the surrounding <script> element.
|
|
11
13
|
const FINCRA_CDN_URL = 'https://unpkg.com/@fincra-engineering/checkout@2.2.0/dist/inline.min.js';
|
|
14
|
+
/**
|
|
15
|
+
* Builds the plain options object for `Fincra.initialize()`.
|
|
16
|
+
*
|
|
17
|
+
* Optional fields are omitted entirely when absent. `customer.phoneNumber`
|
|
18
|
+
* is optional in Fincra's API: it is trimmed, and left out when blank.
|
|
19
|
+
*
|
|
20
|
+
* Pure function — internal, not part of the package's public API.
|
|
21
|
+
*/
|
|
22
|
+
function buildInlineOptions(config) {
|
|
23
|
+
const customer = {
|
|
24
|
+
name: config.customerName,
|
|
25
|
+
email: config.customerEmail,
|
|
26
|
+
};
|
|
27
|
+
const phone = config.customerPhoneNumber?.trim();
|
|
28
|
+
if (phone)
|
|
29
|
+
customer.phoneNumber = phone;
|
|
30
|
+
const options = {
|
|
31
|
+
key: config.publicKey,
|
|
32
|
+
amount: config.amount,
|
|
33
|
+
currency: config.currency.toUpperCase(),
|
|
34
|
+
feeBearer: config.feeBearer,
|
|
35
|
+
customer,
|
|
36
|
+
};
|
|
37
|
+
if (config.reference != null)
|
|
38
|
+
options.reference = config.reference;
|
|
39
|
+
if (config.paymentMethods != null && config.paymentMethods.length > 0) {
|
|
40
|
+
options.paymentMethods = config.paymentMethods;
|
|
41
|
+
}
|
|
42
|
+
return options;
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* JSON-encodes a value for embedding inside an inline `<script>` block.
|
|
46
|
+
* Besides JSON escaping, `<`, `>` and `&` are escaped so a value containing
|
|
47
|
+
* `</script>` or `<!--` cannot break out of the script element, and
|
|
48
|
+
* U+2028/U+2029 are escaped for older JS engines.
|
|
49
|
+
*/
|
|
50
|
+
function toScriptJson(value) {
|
|
51
|
+
return JSON.stringify(value)
|
|
52
|
+
.replace(/</g, '\\u003c')
|
|
53
|
+
.replace(/>/g, '\\u003e')
|
|
54
|
+
.replace(/&/g, '\\u0026')
|
|
55
|
+
.replace(/\u2028/g, '\\u2028')
|
|
56
|
+
.replace(/\u2029/g, '\\u2029');
|
|
57
|
+
}
|
|
12
58
|
/**
|
|
13
59
|
* Generates the self-contained HTML page that loads the Fincra inline JS SDK,
|
|
14
60
|
* initializes it with the provided config, and posts lifecycle events back to
|
|
@@ -21,21 +67,8 @@ const FINCRA_CDN_URL = 'https://unpkg.com/@fincra-engineering/checkout@2.2.0/dis
|
|
|
21
67
|
* @returns A complete HTML string to be loaded into a WebView.
|
|
22
68
|
*/
|
|
23
69
|
function generateInlineHtml(config) {
|
|
24
|
-
// ── Safe encoding —
|
|
25
|
-
const
|
|
26
|
-
const amount = config.amount; // numeric — safe to embed directly
|
|
27
|
-
const currency = JSON.stringify(config.currency.toUpperCase());
|
|
28
|
-
const name = JSON.stringify(config.customerName);
|
|
29
|
-
const email = JSON.stringify(config.customerEmail);
|
|
30
|
-
const phone = JSON.stringify(config.customerPhoneNumber);
|
|
31
|
-
const feeBearer = JSON.stringify(config.feeBearer);
|
|
32
|
-
// Optional fields — only emit the JS property if the value is present
|
|
33
|
-
const referenceLine = config.reference != null
|
|
34
|
-
? `reference: ${JSON.stringify(config.reference)},`
|
|
35
|
-
: '';
|
|
36
|
-
const paymentMethodsLine = config.paymentMethods != null && config.paymentMethods.length > 0
|
|
37
|
-
? `paymentMethods: ${JSON.stringify(config.paymentMethods)},`
|
|
38
|
-
: '';
|
|
70
|
+
// ── Safe encoding — every value goes through toScriptJson() ──
|
|
71
|
+
const optionsJson = toScriptJson(buildInlineOptions(config));
|
|
39
72
|
return `<!DOCTYPE html>
|
|
40
73
|
<html>
|
|
41
74
|
<head>
|
|
@@ -81,25 +114,14 @@ function generateInlineHtml(config) {
|
|
|
81
114
|
// SDK is available — signal "ready" so the host hides the loading spinner
|
|
82
115
|
postToRN('ready', null);
|
|
83
116
|
|
|
84
|
-
var options = {
|
|
85
|
-
key: ${key},
|
|
86
|
-
amount: ${amount},
|
|
87
|
-
currency: ${currency},
|
|
88
|
-
${referenceLine}
|
|
89
|
-
${paymentMethodsLine}
|
|
90
|
-
feeBearer: ${feeBearer},
|
|
91
|
-
customer: {
|
|
92
|
-
name: ${name},
|
|
93
|
-
email: ${email},
|
|
94
|
-
phoneNumber: ${phone},
|
|
95
|
-
},
|
|
117
|
+
var options = Object.assign(${optionsJson}, {
|
|
96
118
|
onClose: function() {
|
|
97
119
|
postToRN('closed', null);
|
|
98
120
|
},
|
|
99
121
|
onSuccess: function(data) {
|
|
100
122
|
postToRN('success', data);
|
|
101
123
|
},
|
|
102
|
-
};
|
|
124
|
+
});
|
|
103
125
|
|
|
104
126
|
Fincra.initialize(options);
|
|
105
127
|
}
|
|
@@ -5,6 +5,7 @@ exports.UrlHandler = void 0;
|
|
|
5
5
|
//
|
|
6
6
|
// Direct TypeScript port of flutter_fincra_checkout/lib/src/utils/url_handler.dart
|
|
7
7
|
// Mirrors UrlHandler.isCompletionUrl() and UrlHandler.extractResponseParams()
|
|
8
|
+
const DEFAULT_PORTS = { http: '80', https: '443' };
|
|
8
9
|
/**
|
|
9
10
|
* Utilities for detecting Fincra payment completion URLs and
|
|
10
11
|
* extracting normalized response parameters.
|
|
@@ -14,7 +15,8 @@ class UrlHandler {
|
|
|
14
15
|
* Returns `true` if the given URL signals a Fincra payment completion.
|
|
15
16
|
*
|
|
16
17
|
* Logic (mirrors Flutter):
|
|
17
|
-
* 1. If `expectedRedirectUrl` is provided,
|
|
18
|
+
* 1. If `expectedRedirectUrl` is provided, the URL must match it strictly —
|
|
19
|
+
* see {@link UrlHandler.matchesRedirectUrl}.
|
|
18
20
|
* 2. Fallback: Fincra appends `status` (or `payment_status`) AND `reference` as query params.
|
|
19
21
|
*
|
|
20
22
|
* @param url - The URL being navigated to.
|
|
@@ -24,7 +26,7 @@ class UrlHandler {
|
|
|
24
26
|
if (!url)
|
|
25
27
|
return false;
|
|
26
28
|
if (expectedRedirectUrl && expectedRedirectUrl.length > 0) {
|
|
27
|
-
return
|
|
29
|
+
return UrlHandler.matchesRedirectUrl(url, expectedRedirectUrl);
|
|
28
30
|
}
|
|
29
31
|
// Fallback: detect via query parameters
|
|
30
32
|
try {
|
|
@@ -37,6 +39,47 @@ class UrlHandler {
|
|
|
37
39
|
return false;
|
|
38
40
|
}
|
|
39
41
|
}
|
|
42
|
+
/**
|
|
43
|
+
* Strictly matches `url` against the expected redirect URL.
|
|
44
|
+
*
|
|
45
|
+
* - Scheme, host (case-insensitive) and port (default ports normalised) must be equal.
|
|
46
|
+
* - The path must equal the expected path or continue it at a `/` boundary
|
|
47
|
+
* (trailing slashes ignored). An empty expected path matches any path.
|
|
48
|
+
* - Query string and fragment are ignored.
|
|
49
|
+
* - Falls back to `startsWith` only if the expected URL cannot be parsed.
|
|
50
|
+
*
|
|
51
|
+
* Prevents lookalike hosts such as `https://google.com.evil.io` matching
|
|
52
|
+
* `https://google.com`.
|
|
53
|
+
*/
|
|
54
|
+
static matchesRedirectUrl(url, expectedRedirectUrl) {
|
|
55
|
+
const expected = UrlHandler._parseUrl(expectedRedirectUrl);
|
|
56
|
+
if (!expected)
|
|
57
|
+
return url.startsWith(expectedRedirectUrl);
|
|
58
|
+
const actual = UrlHandler._parseUrl(url);
|
|
59
|
+
if (!actual)
|
|
60
|
+
return false;
|
|
61
|
+
if (actual.scheme !== expected.scheme ||
|
|
62
|
+
actual.host !== expected.host ||
|
|
63
|
+
actual.port !== expected.port) {
|
|
64
|
+
return false;
|
|
65
|
+
}
|
|
66
|
+
if (expected.path === '')
|
|
67
|
+
return true;
|
|
68
|
+
return (actual.path === expected.path ||
|
|
69
|
+
actual.path.startsWith(`${expected.path}/`));
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* Reads the payment status from completion params, accepting either
|
|
73
|
+
* `status` or `payment_status`. Returns lower-case.
|
|
74
|
+
*
|
|
75
|
+
* A missing status is treated as `'success'` because the sandbox redirect
|
|
76
|
+
* omits it. **Always verify the payment on your backend** (Fincra API or
|
|
77
|
+
* webhook) before fulfilling an order.
|
|
78
|
+
*/
|
|
79
|
+
static extractStatus(params) {
|
|
80
|
+
const raw = params['status'] ?? params['payment_status'];
|
|
81
|
+
return raw?.toLowerCase() ?? 'success';
|
|
82
|
+
}
|
|
40
83
|
/**
|
|
41
84
|
* Extracts all query parameters from the URL as a `Record<string, string>`.
|
|
42
85
|
*
|
|
@@ -72,7 +115,7 @@ class UrlHandler {
|
|
|
72
115
|
return {
|
|
73
116
|
reference: finalRef,
|
|
74
117
|
transactionId: finalTxId ?? '',
|
|
75
|
-
status: params['status'] ?? 'unknown',
|
|
118
|
+
status: params['status'] ?? params['payment_status'] ?? 'unknown',
|
|
76
119
|
message: params['message'],
|
|
77
120
|
rawResponse: params,
|
|
78
121
|
};
|
|
@@ -87,6 +130,22 @@ class UrlHandler {
|
|
|
87
130
|
return normalized === 'success' || normalized === 'successful';
|
|
88
131
|
}
|
|
89
132
|
// ── Internal ────────────────────────────────────────────────────────────────
|
|
133
|
+
/**
|
|
134
|
+
* Minimal absolute-URL parser. React Native's `URL` polyfill does not
|
|
135
|
+
* implement `hostname`/`port`, so this is done by hand.
|
|
136
|
+
* Returns `null` if the string is not an absolute `scheme://host` URL.
|
|
137
|
+
*/
|
|
138
|
+
static _parseUrl(url) {
|
|
139
|
+
const match = /^([a-z][a-z0-9+.-]*):\/\/(?:[^@/?#]*@)?(\[[^\]]*\]|[^:/?#]*)(?::(\d*))?([^?#]*)/i.exec(url.trim());
|
|
140
|
+
if (!match || !match[2])
|
|
141
|
+
return null;
|
|
142
|
+
const scheme = match[1].toLowerCase();
|
|
143
|
+
const defaultPort = DEFAULT_PORTS[scheme] ?? '';
|
|
144
|
+
const port = match[3] || defaultPort;
|
|
145
|
+
// Normalise trailing slashes so `/callback/` equals `/callback`
|
|
146
|
+
const path = match[4].replace(/\/+$/, '');
|
|
147
|
+
return { scheme, host: match[2].toLowerCase(), port, path };
|
|
148
|
+
}
|
|
90
149
|
/**
|
|
91
150
|
* Parses URL query string into a `URLSearchParams`-like `Map`.
|
|
92
151
|
* Works in React Native (no DOM `URL` API available).
|
|
@@ -11,11 +11,10 @@ import type { InlineCheckoutConfig } from '../types';
|
|
|
11
11
|
* currency="NGN"
|
|
12
12
|
* customerEmail="user@example.com"
|
|
13
13
|
* customerName="John Doe"
|
|
14
|
-
* customerPhoneNumber="08012345678"
|
|
15
14
|
* feeBearer="customer"
|
|
16
15
|
* onSuccess={(res) => console.log(res.reference)}
|
|
17
16
|
* onCancelled={() => navigation.goBack()}
|
|
18
17
|
* />
|
|
19
18
|
* ```
|
|
20
19
|
*/
|
|
21
|
-
export declare function FincraInlineCheckout({ headerTitle, headerBackgroundColor, headerTintColor, showCancelConfirmationDialog, loadingComponent, closeIcon, renderError, onSuccess, onFailed, onCancelled, ...paymentConfig }: InlineCheckoutConfig): React.JSX.Element;
|
|
20
|
+
export declare function FincraInlineCheckout({ headerTitle, headerBackgroundColor, headerTintColor, showCancelConfirmationDialog, showCloseButton, loadingComponent, closeIcon, renderError, onSuccess, onFailed, onCancelled, ...paymentConfig }: InlineCheckoutConfig): React.JSX.Element;
|
|
@@ -15,4 +15,4 @@ export type FincraWebViewCheckoutProps = WebViewCheckoutConfig;
|
|
|
15
15
|
* />
|
|
16
16
|
* ```
|
|
17
17
|
*/
|
|
18
|
-
export declare function FincraWebViewCheckout({ checkoutUrl, redirectUrl, headerTitle, headerBackgroundColor, headerTintColor, showCancelConfirmationDialog, loadingComponent, closeIcon, renderError, onSuccess, onFailed, onCancelled, }: FincraWebViewCheckoutProps): React.JSX.Element;
|
|
18
|
+
export declare function FincraWebViewCheckout({ checkoutUrl, redirectUrl, headerTitle, headerBackgroundColor, headerTintColor, showCancelConfirmationDialog, showCloseButton, loadingComponent, closeIcon, renderError, onSuccess, onFailed, onCancelled, }: FincraWebViewCheckoutProps): React.JSX.Element;
|
|
@@ -15,7 +15,10 @@ export declare enum FincraBridgeEvent {
|
|
|
15
15
|
/** A parsed message posted by the Fincra JS SDK via `postMessage`. */
|
|
16
16
|
export interface FincraBridgeMessage {
|
|
17
17
|
event: FincraBridgeEvent;
|
|
18
|
-
/**
|
|
18
|
+
/**
|
|
19
|
+
* Always populated for `success` events; populated for `error` events
|
|
20
|
+
* that carry a data object.
|
|
21
|
+
*/
|
|
19
22
|
data?: FincraPaymentResponse | {
|
|
20
23
|
message: string;
|
|
21
24
|
};
|
|
@@ -7,6 +7,36 @@ import type { InlineCheckoutConfig } from '../types';
|
|
|
7
7
|
* Fix #8: use a Pick instead of the full InlineCheckoutConfig.
|
|
8
8
|
*/
|
|
9
9
|
export type InlinePaymentConfig = Pick<InlineCheckoutConfig, 'publicKey' | 'amount' | 'currency' | 'customerName' | 'customerEmail' | 'customerPhoneNumber' | 'feeBearer' | 'reference' | 'paymentMethods'>;
|
|
10
|
+
/** Options passed to `Fincra.initialize()` (callbacks are added in the page). */
|
|
11
|
+
export interface InlineSdkOptions {
|
|
12
|
+
key: string;
|
|
13
|
+
amount: number;
|
|
14
|
+
currency: string;
|
|
15
|
+
feeBearer: string;
|
|
16
|
+
reference?: string;
|
|
17
|
+
paymentMethods?: string[];
|
|
18
|
+
customer: {
|
|
19
|
+
name: string;
|
|
20
|
+
email: string;
|
|
21
|
+
phoneNumber?: string;
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Builds the plain options object for `Fincra.initialize()`.
|
|
26
|
+
*
|
|
27
|
+
* Optional fields are omitted entirely when absent. `customer.phoneNumber`
|
|
28
|
+
* is optional in Fincra's API: it is trimmed, and left out when blank.
|
|
29
|
+
*
|
|
30
|
+
* Pure function — internal, not part of the package's public API.
|
|
31
|
+
*/
|
|
32
|
+
export declare function buildInlineOptions(config: InlinePaymentConfig): InlineSdkOptions;
|
|
33
|
+
/**
|
|
34
|
+
* JSON-encodes a value for embedding inside an inline `<script>` block.
|
|
35
|
+
* Besides JSON escaping, `<`, `>` and `&` are escaped so a value containing
|
|
36
|
+
* `</script>` or `<!--` cannot break out of the script element, and
|
|
37
|
+
* U+2028/U+2029 are escaped for older JS engines.
|
|
38
|
+
*/
|
|
39
|
+
export declare function toScriptJson(value: unknown): string;
|
|
10
40
|
/**
|
|
11
41
|
* Generates the self-contained HTML page that loads the Fincra inline JS SDK,
|
|
12
42
|
* initializes it with the provided config, and posts lifecycle events back to
|
|
@@ -37,7 +37,8 @@ export interface FincraPaymentResponse {
|
|
|
37
37
|
/**
|
|
38
38
|
* Full raw response map. All values are strings.
|
|
39
39
|
* For inline mode, numeric/boolean fields from Fincra (e.g. `amount`) are
|
|
40
|
-
* coerced to strings to match the URL-params format used in WebView mode
|
|
40
|
+
* coerced to strings to match the URL-params format used in WebView mode;
|
|
41
|
+
* nested objects/arrays are JSON-encoded and `null` values are dropped.
|
|
41
42
|
*/
|
|
42
43
|
rawResponse: Record<string, string>;
|
|
43
44
|
}
|
|
@@ -93,6 +94,13 @@ export interface BaseCheckoutProps {
|
|
|
93
94
|
showCancelConfirmationDialog?: boolean;
|
|
94
95
|
/** Custom loading indicator to display while the WebView is loading. */
|
|
95
96
|
loadingComponent?: ReactNode;
|
|
97
|
+
/**
|
|
98
|
+
* Show the ✕ close button in the header. Default: true.
|
|
99
|
+
* If you hide it, users can still leave via the error screen's Cancel
|
|
100
|
+
* button and the Android back button — but on iOS there is no other way to
|
|
101
|
+
* leave while the page is loading, or if it hangs.
|
|
102
|
+
*/
|
|
103
|
+
showCloseButton?: boolean;
|
|
96
104
|
/** Custom close icon/element for the header. */
|
|
97
105
|
closeIcon?: ReactNode;
|
|
98
106
|
/** Custom error screen renderer for network/loading recovery. */
|
|
@@ -131,8 +139,11 @@ export interface InlineCheckoutConfig extends BaseCheckoutProps {
|
|
|
131
139
|
customerEmail: string;
|
|
132
140
|
/** Customer's full name. */
|
|
133
141
|
customerName: string;
|
|
134
|
-
/**
|
|
135
|
-
|
|
142
|
+
/**
|
|
143
|
+
* Customer's phone number (optional in Fincra's API). Trimmed; when blank
|
|
144
|
+
* it is not sent to Fincra at all.
|
|
145
|
+
*/
|
|
146
|
+
customerPhoneNumber?: string;
|
|
136
147
|
/** Who bears the Fincra processing fee. */
|
|
137
148
|
feeBearer: FeeBearer;
|
|
138
149
|
/** Optional unique transaction reference. Fincra generates one if omitted. */
|