react-native-fincra-checkout 1.0.0 → 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.
@@ -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
- const hasCompleted = (0, react_1.useRef)(false);
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
- // Safely assume success if status is missing (matches Flutter behaviour)
82
- const rawStatus = params['status']?.toLowerCase() ?? 'success';
83
- if (UrlHandler_1.UrlHandler.isSuccessStatus(rawStatus)) {
84
- const response = UrlHandler_1.UrlHandler.parsePaymentResponse(params);
85
- onSuccess?.(response);
86
- }
87
- else {
88
- const err = {
89
- code: rawStatus,
90
- message: params['message'] ?? 'Payment failed',
91
- };
92
- onFailed?.(err);
93
- }
94
- }, [onSuccess, onFailed]);
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 (hasCompleted.current)
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
- onPress: () => {
114
- if (!hasCompleted.current) {
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
- hasCompleted.current = true;
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
- // ── WebView error ───────────────────────────────────────────────────────────
132
- const handleError = (0, react_1.useCallback)((syntheticEvent) => {
133
- if (hasCompleted.current)
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
- // ── HTTP error (non-completion URLs only) ───────────────────────────────────
144
- const handleHttpError = (0, react_1.useCallback)((e) => {
145
- if (!UrlHandler_1.UrlHandler.isCompletionUrl(e.nativeEvent.url, redirectUrl)) {
146
- handleError(e);
147
- }
148
- }, [redirectUrl, handleError]);
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 (hasCompleted.current)
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
- return (react_1.default.createElement(react_native_safe_area_context_1.SafeAreaView, { style: styles.container },
169
- react_1.default.createElement(react_native_1.StatusBar, { barStyle: statusBarStyle, backgroundColor: headerBackgroundColor }),
170
- react_1.default.createElement(react_native_1.View, { style: [styles.header, { backgroundColor: headerBackgroundColor }] },
171
- 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"))),
172
- react_1.default.createElement(react_native_1.Text, { style: [styles.headerTitle, { color: headerTintColor }], numberOfLines: 1 }, headerTitle),
173
- react_1.default.createElement(react_native_1.View, { style: styles.closeButton })),
174
- react_1.default.createElement(react_native_1.View, { style: styles.webViewContainer },
175
- 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: () => {
176
- if (!hasCompleted.current)
177
- setIsLoading(true);
178
- }, onLoadEnd: () => {
179
- if (!hasCompleted.current)
180
- setIsLoading(false);
181
- }, onError: handleError, onHttpError: handleHttpError, onShouldStartLoadWithRequest: onShouldStartLoadWithRequest, onNavigationStateChange: onNavigationStateChange,
182
- // Fix #14: restrict to HTTPS + about:blank — prevents intent:// and
183
- // other dangerous scheme navigations in a payment context.
184
- originWhitelist: ['https://*', 'about:blank'] }),
185
- 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" })))),
186
- 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 },
187
- react_1.default.createElement(react_native_1.Text, { style: styles.errorIcon }, "\u26A0\uFE0F"),
188
- react_1.default.createElement(react_native_1.Text, { style: styles.errorTitle }, "Connection Error"),
189
- react_1.default.createElement(react_native_1.Text, { style: styles.errorMessage }, errorState.message),
190
- react_1.default.createElement(react_native_1.TouchableOpacity, { style: styles.retryButton, onPress: handleRetry, accessibilityRole: "button", accessibilityLabel: "Retry loading checkout" },
191
- react_1.default.createElement(react_native_1.Text, { style: styles.retryButtonText }, "Retry")),
192
- react_1.default.createElement(react_native_1.TouchableOpacity, { style: styles.cancelButton, onPress: handleCancellation, accessibilityRole: "button", accessibilityLabel: "Cancel checkout" },
193
- react_1.default.createElement(react_native_1.Text, { style: styles.cancelButtonText }, "Cancel")))))))));
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
- map['data'] != null &&
45
- typeof map['data'] === 'object' &&
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 dataMap = { ...rawData };
50
- // Ensure status is always set for the response
51
- if (!dataMap['status']) {
52
- dataMap['status'] = 'success';
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: ALL user-supplied string values are encoded via JSON.stringify(),
9
- // which escapes special characters and wraps in double-quotes, preventing
10
- // HTML/JS injection attacks (same approach as the Flutter implementation).
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 — all string values JSON-encoded to prevent injection ──
25
- const key = JSON.stringify(config.publicKey);
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, check `url.startsWith(expectedRedirectUrl)`.
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 url.startsWith(expectedRedirectUrl);
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).
@@ -61,7 +61,6 @@ export declare function _unregisterHostRef(): void;
61
61
  * currency: 'NGN',
62
62
  * customerEmail: 'user@example.com',
63
63
  * customerName: 'Jane Doe',
64
- * customerPhoneNumber: '08012345678',
65
64
  * feeBearer: 'customer',
66
65
  * });
67
66
  *
@@ -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
- /** Populated only for `success` and `error` events. */
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
- /** Customer's phone number. */
135
- customerPhoneNumber: string;
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. */