@glomopay/react-native-sdk 4.1.0 → 5.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.
Files changed (124) hide show
  1. package/CHANGELOG.md +29 -0
  2. package/MIGRATION.md +112 -0
  3. package/README.md +173 -36
  4. package/lib/bridge/file-input-handler.d.ts +25 -0
  5. package/lib/bridge/file-input-handler.d.ts.map +1 -0
  6. package/lib/bridge/file-input-handler.js +38 -0
  7. package/lib/bridge/file-save-handler.d.ts +32 -0
  8. package/lib/bridge/file-save-handler.d.ts.map +1 -0
  9. package/lib/bridge/file-save-handler.js +92 -0
  10. package/lib/bridge/message-router.d.ts +44 -0
  11. package/lib/bridge/message-router.d.ts.map +1 -0
  12. package/lib/bridge/message-router.js +48 -0
  13. package/lib/bridge/payment-payload.d.ts +18 -0
  14. package/lib/bridge/payment-payload.d.ts.map +1 -0
  15. package/lib/bridge/payment-payload.js +31 -0
  16. package/lib/capabilities/file-save.d.ts +40 -0
  17. package/lib/capabilities/file-save.d.ts.map +1 -0
  18. package/lib/capabilities/file-save.js +101 -0
  19. package/lib/capabilities/index.d.ts +22 -0
  20. package/lib/capabilities/index.d.ts.map +1 -0
  21. package/lib/capabilities/index.js +34 -0
  22. package/lib/capabilities/use-capability-reporting.d.ts +24 -0
  23. package/lib/capabilities/use-capability-reporting.d.ts.map +1 -0
  24. package/lib/capabilities/use-capability-reporting.js +44 -0
  25. package/lib/config/base.d.ts +12 -0
  26. package/lib/config/base.d.ts.map +1 -1
  27. package/lib/config/base.js +13 -1
  28. package/lib/glomo-checkout.d.ts +6 -1
  29. package/lib/glomo-checkout.d.ts.map +1 -1
  30. package/lib/glomo-checkout.js +11 -5
  31. package/lib/glomo-lrs-checkout.d.ts.map +1 -1
  32. package/lib/glomo-lrs-checkout.js +33 -10
  33. package/lib/glomo-standard-checkout.d.ts +3 -1
  34. package/lib/glomo-standard-checkout.d.ts.map +1 -1
  35. package/lib/glomo-standard-checkout.js +21 -13
  36. package/lib/glomo-subscriptions-checkout.d.ts.map +1 -1
  37. package/lib/glomo-subscriptions-checkout.js +1 -1
  38. package/lib/index.d.ts +2 -2
  39. package/lib/index.d.ts.map +1 -1
  40. package/lib/index.js +4 -3
  41. package/lib/injections/index.d.ts +24 -0
  42. package/lib/injections/index.d.ts.map +1 -1
  43. package/lib/injections/index.js +41 -0
  44. package/lib/injections/webview-flow.injection.d.ts.map +1 -1
  45. package/lib/injections/webview-flow.injection.js +76 -0
  46. package/lib/injections/webview-main.injection.d.ts.map +1 -1
  47. package/lib/injections/webview-main.injection.js +31 -0
  48. package/lib/injections/webview-standard.injection.d.ts.map +1 -1
  49. package/lib/injections/webview-standard.injection.js +31 -0
  50. package/lib/services/file-saver.d.ts +109 -0
  51. package/lib/services/file-saver.d.ts.map +1 -0
  52. package/lib/services/file-saver.js +416 -0
  53. package/lib/types/checkout.d.ts +58 -6
  54. package/lib/types/checkout.d.ts.map +1 -1
  55. package/lib/types/checkout.js +24 -2
  56. package/lib/types/standard-checkout.d.ts +6 -6
  57. package/lib/types/standard-checkout.d.ts.map +1 -1
  58. package/lib/types/subscriptions-checkout.d.ts +3 -3
  59. package/lib/types/subscriptions-checkout.d.ts.map +1 -1
  60. package/lib/use-glomo-checkout.js +2 -2
  61. package/lib/use-lrs-checkout.d.ts +9 -5
  62. package/lib/use-lrs-checkout.d.ts.map +1 -1
  63. package/lib/use-lrs-checkout.js +317 -171
  64. package/lib/use-standard-checkout.d.ts +17 -13
  65. package/lib/use-standard-checkout.d.ts.map +1 -1
  66. package/lib/use-standard-checkout.js +281 -207
  67. package/lib/utils/analytics.d.ts +177 -51
  68. package/lib/utils/analytics.d.ts.map +1 -1
  69. package/lib/utils/analytics.js +299 -81
  70. package/lib/utils/device-compliance.d.ts +57 -12
  71. package/lib/utils/device-compliance.d.ts.map +1 -1
  72. package/lib/utils/device-compliance.js +101 -37
  73. package/lib/utils/event-projections.d.ts +56 -0
  74. package/lib/utils/event-projections.d.ts.map +1 -0
  75. package/lib/utils/event-projections.js +42 -0
  76. package/lib/utils/file-metadata.d.ts +52 -0
  77. package/lib/utils/file-metadata.d.ts.map +1 -0
  78. package/lib/utils/file-metadata.js +134 -0
  79. package/lib/utils/use-sdk-session.d.ts +12 -0
  80. package/lib/utils/use-sdk-session.d.ts.map +1 -0
  81. package/lib/utils/use-sdk-session.js +37 -0
  82. package/lib/utils/validation.d.ts +37 -3
  83. package/lib/utils/validation.d.ts.map +1 -1
  84. package/lib/utils/validation.js +64 -8
  85. package/lib/webview/capability-props.d.ts +27 -0
  86. package/lib/webview/capability-props.d.ts.map +1 -0
  87. package/lib/webview/capability-props.js +61 -0
  88. package/lib/webview/use-permission-request.d.ts +33 -0
  89. package/lib/webview/use-permission-request.d.ts.map +1 -0
  90. package/lib/webview/use-permission-request.js +89 -0
  91. package/package.json +10 -2
  92. package/src/bridge/file-input-handler.ts +66 -0
  93. package/src/bridge/file-save-handler.ts +141 -0
  94. package/src/bridge/message-router.ts +92 -0
  95. package/src/bridge/payment-payload.ts +31 -0
  96. package/src/capabilities/file-save.ts +124 -0
  97. package/src/capabilities/index.ts +34 -0
  98. package/src/capabilities/use-capability-reporting.ts +55 -0
  99. package/src/config/base.ts +13 -0
  100. package/src/glomo-checkout.tsx +11 -7
  101. package/src/glomo-lrs-checkout.tsx +41 -26
  102. package/src/glomo-standard-checkout.tsx +31 -34
  103. package/src/glomo-subscriptions-checkout.tsx +1 -0
  104. package/src/index.ts +4 -2
  105. package/src/injections/index.ts +43 -0
  106. package/src/injections/webview-flow.injection.ts +76 -0
  107. package/src/injections/webview-main.injection.ts +31 -0
  108. package/src/injections/webview-standard.injection.ts +31 -0
  109. package/src/services/file-saver.ts +497 -0
  110. package/src/types/checkout.ts +62 -6
  111. package/src/types/standard-checkout.ts +6 -6
  112. package/src/types/subscriptions-checkout.ts +7 -3
  113. package/src/use-glomo-checkout.tsx +2 -2
  114. package/src/use-lrs-checkout.tsx +656 -365
  115. package/src/use-standard-checkout.tsx +728 -466
  116. package/src/utils/analytics.ts +422 -117
  117. package/src/utils/device-compliance.ts +148 -38
  118. package/src/utils/event-projections.ts +74 -0
  119. package/src/utils/file-metadata.ts +140 -0
  120. package/src/utils/use-sdk-session.ts +41 -0
  121. package/src/utils/validation.ts +70 -10
  122. package/src/webview/capability-props.ts +86 -0
  123. package/src/webview/use-permission-request.ts +114 -0
  124. package/src/jail-monkey.d.ts +0 -19
@@ -0,0 +1,497 @@
1
+ /**
2
+ * Fetches a document and offers it to the user through the platform save dialog
3
+ *
4
+ * This is the whole of the SDK's answer to a `file.save` bridge message. Nothing here is reachable
5
+ * from the merchant API - the bridge handler is the only caller.
6
+ *
7
+ * Exists because there is no web-only way to put a file on the device from inside a WebView:
8
+ * Android drops `anchor[download]` on a `blob:` URL unless the host registers a DownloadListener,
9
+ * and the Web Share API is absent from Android WebView entirely. The page sends the message only
10
+ * after finding `file.save` in the capability list the injection scripts publish.
11
+ */
12
+
13
+ import { loadBlobUtil, loadDocumentPicker } from "../capabilities/file-save";
14
+ import { SIZE_UNAVAILABLE, extensionOf, mimeTypeFor } from "../utils/file-metadata";
15
+ import { type SdkError } from "../utils/analytics";
16
+
17
+ /** Log prefix. Deliberately unqualified by flow - a save is not owned by one. */
18
+ const LOG_PREFIX = "[Glomo-RN-SDK]";
19
+
20
+ /**
21
+ * How a save attempt ended.
22
+ *
23
+ * Named for the condition rather than carrying a message, for the same reason FileSaveResult
24
+ * carries no filename: every value here reaches telemetry.
25
+ *
26
+ * Values are camelCase where this repo's other string enums are snake_case, and the deviation is
27
+ * deliberate. ASYNC_PAYMENT_EVENTS is merchant-facing API surface, so it follows the SDK's own
28
+ * convention; this one is telemetry that has to join against the Flutter SDK's already-shipped
29
+ * `file.save_completed`, where the binding constraint is that `outcome` reads the same on both.
30
+ */
31
+ export enum FILE_SAVE_OUTCOMES {
32
+ /** The user chose a destination and the bytes were written to it. */
33
+ SAVED = "saved",
34
+
35
+ /** The save dialog was dismissed without choosing one. A normal user action, not a failure. */
36
+ DISMISSED = "dismissed",
37
+
38
+ /** The URL failed the scheme check, so no request was made. */
39
+ REJECTED = "rejected",
40
+
41
+ /** The document could not be fetched: stall, transport, or non-200. */
42
+ FETCH_FAILED = "fetchFailed",
43
+
44
+ /** Larger than FILE_SAVE_MAX_BYTES. Refused mid-stream, so the excess never reached disk. */
45
+ TOO_LARGE = "tooLarge",
46
+
47
+ /** The platform refused to present the save dialog, or failed writing to the chosen target. */
48
+ SAVE_FAILED = "saveFailed",
49
+
50
+ /**
51
+ * The optional save packages are not usable in this build, so the message should not have been
52
+ * sent at all.
53
+ *
54
+ * No Flutter counterpart - there the two packages are hard dependencies and this state cannot
55
+ * exist. It is reachable here whenever a page sends `file.save` without gating on the capability
56
+ * list, which is the failure this outcome exists to make visible rather than silent.
57
+ */
58
+ UNAVAILABLE = "unavailable",
59
+ }
60
+
61
+ /**
62
+ * Outcome of one save, shaped for telemetry.
63
+ *
64
+ * Carries no filename and no URL. The agreement URLs are signed links - a signed link is a bearer
65
+ * credential, and the filename is the same class of data file-metadata exists to keep out of events.
66
+ * Extension and byte count are what a field investigation actually needs.
67
+ */
68
+ export interface FileSaveResult {
69
+ outcome: FILE_SAVE_OUTCOMES;
70
+
71
+ /** From extensionOf, so it is a known extension or a sentinel. */
72
+ fileExtension: string;
73
+
74
+ /** SIZE_UNAVAILABLE when the document was never fetched. */
75
+ fileSize: number;
76
+
77
+ /** Error name or code, never a message - RN network errors interpolate the full URL. */
78
+ failureType?: string;
79
+ }
80
+
81
+ /** The options for saveDocumentToDevice */
82
+ export interface SaveDocumentOptions {
83
+ url: string;
84
+ filename?: string;
85
+ devMode?: boolean;
86
+ }
87
+
88
+ /**
89
+ * The largest document this will put on the device.
90
+ *
91
+ * Held at the Flutter SDK's figure so a merchant does not see one platform refuse what the other
92
+ * accepts, but the reason differs and that matters if either is ever revisited. Flutter is bounded
93
+ * by memory: `FilePicker.saveFile` takes bytes, so the whole document is materialised before the
94
+ * dialog. Here nothing is ever held in memory - blob-util streams to disk and the picker takes a
95
+ * URI - so this bounds device storage and the user's patience instead.
96
+ *
97
+ * Deliberately not justified by agreement PDFs being small. `file.save` is generic device-save
98
+ * capability, reached by any bank or segment, so a response far past a typical agreement is an
99
+ * ordinary case rather than evidence of a misrouted URL.
100
+ */
101
+ export const FILE_SAVE_MAX_BYTES = 100 * 1024 * 1024;
102
+
103
+ /** Matches the Flutter SDK's fetch timeout, and blob-util applies it to connect only (see below). */
104
+ const CONNECT_TIMEOUT_MS = 30_000;
105
+
106
+ /**
107
+ * How long a started download may go without delivering bytes before it is abandoned.
108
+ *
109
+ * Needed because blob-util sets `readTimeout(0)` for file-to-disk responses on Android - deliberate
110
+ * on their side, so a slow but healthy transfer of a large file is not killed by OkHttp's 60s
111
+ * default. The consequence is that a genuinely wedged socket never resolves, and the user is left
112
+ * on a button that already reported success.
113
+ *
114
+ * A stall bound rather than a total bound: a total timeout large enough for FILE_SAVE_MAX_BYTES on
115
+ * a slow connection is too large to catch anything, and one small enough to catch a stall would
116
+ * fail legitimate large downloads. The Flutter SDK applies a 30s total, which is the tradeoff worth
117
+ * revisiting there now that its own cap is 100 MB.
118
+ */
119
+ const STALL_TIMEOUT_MS = 30_000;
120
+
121
+ /** Distinguishes a cancel this code asked for from a transport failure, which look identical. */
122
+ interface CancelReason {
123
+ outcome: FILE_SAVE_OUTCOMES;
124
+ failureType?: string;
125
+ size?: number;
126
+ }
127
+
128
+ /** Only the basename survives, and only from an allowlist */
129
+ const UNSAFE_CHARS = /[^A-Za-z0-9._-]/g;
130
+
131
+ /** Both sides of this protocol independently default to this, which is why collisions are designed out */
132
+ const DEFAULT_FILENAME = "document.pdf";
133
+
134
+ /** Reported when a thrown value carries neither a code nor a name */
135
+ const UNKNOWN_ERROR_TYPE = "UNKNOWN_ERROR_TYPE";
136
+
137
+ /** Distinguishes one save's staging directory from another's. Saves are not serialised. */
138
+ let stagingCounter = 0;
139
+
140
+ /**
141
+ * True for outcomes that mean the user did not get the document.
142
+ *
143
+ * Dismissal is excluded: the user was offered the file and declined it.
144
+ */
145
+ export function isFileSaveFailure(result: FileSaveResult): boolean {
146
+ return result.outcome !== FILE_SAVE_OUTCOMES.SAVED && result.outcome !== FILE_SAVE_OUTCOMES.DISMISSED;
147
+ }
148
+
149
+ /** The telemetry projection of a result. Everything here is safe to send; the inputs are not. */
150
+ export function toFileSaveEventData(result: FileSaveResult): Record<string, unknown> {
151
+ return {
152
+ outcome: result.outcome,
153
+ fileExtension: result.fileExtension,
154
+ fileSize: result.fileSize,
155
+ ...(result.failureType ? { failureType: result.failureType } : {}),
156
+ };
157
+ }
158
+
159
+ /**
160
+ * The host-facing form of a failure.
161
+ *
162
+ * `validation_error` rather than anything terminal because onSdkError is also the bridge's generic
163
+ * catch - without a type these arrive mixed with parse failures and a merchant cannot filter them
164
+ * apart. `field` names the message that produced it for the same reason.
165
+ *
166
+ * Must never be routed anywhere that sets checkout status: a document failing to save must not end
167
+ * the customer's payment session.
168
+ */
169
+ export function toFileSaveSdkError(result: FileSaveResult): SdkError {
170
+ return {
171
+ type: "validation_error",
172
+ message:
173
+ result.outcome === FILE_SAVE_OUTCOMES.TOO_LARGE
174
+ ? `Downloads above ${Math.floor(FILE_SAVE_MAX_BYTES / (1024 * 1024))} MB are not supported.`
175
+ : "Could not save the document to your device.",
176
+ field: "file.save",
177
+ };
178
+ }
179
+
180
+ /**
181
+ * https only, except on loopback.
182
+ *
183
+ * isValidUrl admits any http URL, which is right for bank redirects and wrong here: this fetch
184
+ * carries a signed link and its bytes become a document the user is told is theirs. The loopback
185
+ * exception is what lets the checkout page's dev mock server serve agreements over http.
186
+ *
187
+ * Gated on `devMode`, so a release build is https-only with no exception at all. Ungated, a page
188
+ * could point a production build at a service listening on the customer's own device and have the
189
+ * response written to their files - a dev convenience is not worth a hole in the one rule this
190
+ * function exists to enforce.
191
+ *
192
+ * Loopback only, and deliberately not the Android emulator's `10.0.2.2` alias for the host:
193
+ * reaching a dev server from an emulator is what `adb reverse` is for, which keeps the host
194
+ * `localhost` on both platforms. No other private address belongs in shipped code.
195
+ */
196
+ function isFetchableUrl(url: string, devMode: boolean): boolean {
197
+ const match = /^([a-zA-Z][a-zA-Z0-9+.-]*):\/\/([^/?#]+)/.exec(url);
198
+ if (!match) {
199
+ return false;
200
+ }
201
+
202
+ const scheme = match[1].toLowerCase();
203
+ // Host only - a userinfo or port section must not be mistaken for one
204
+ const host = match[2].split("@").pop()?.split(":")[0]?.toLowerCase() ?? "";
205
+
206
+ if (scheme === "https") {
207
+ return true;
208
+ }
209
+ return devMode && scheme === "http" && (host === "localhost" || host === "127.0.0.1");
210
+ }
211
+
212
+ /**
213
+ * Basename, allowlisted, with a `.pdf` default.
214
+ *
215
+ * The page supplies this and it becomes a path segment under the staging directory, so it is
216
+ * untrusted input: `../../databases/x` would otherwise escape. Falls back to the URL's last
217
+ * segment, then to a constant.
218
+ */
219
+ function sanitize(name?: string | null): string | null {
220
+ if (!name) {
221
+ return null;
222
+ }
223
+
224
+ const base = name.split("/").pop()?.split("\\").pop()?.trim() ?? "";
225
+ const cleaned = base.replace(UNSAFE_CHARS, "_");
226
+
227
+ // A name that is only dots and separators sanitises to something like `..`, still a traversal
228
+ if (cleaned.length === 0 || cleaned.replace(/\./g, "").length === 0) {
229
+ return null;
230
+ }
231
+
232
+ return cleaned.length > 100 ? cleaned.slice(cleaned.length - 100) : cleaned;
233
+ }
234
+
235
+ /** Prefers what the page asked for, then what the URL implies, then a constant. */
236
+ function resolveFilename(supplied: string | undefined, url: string): string {
237
+ const path = url.split("?")[0].split("#")[0];
238
+ const fromUrl = path.split("/").pop();
239
+
240
+ for (const candidate of [supplied, fromUrl]) {
241
+ const cleaned = sanitize(candidate);
242
+ if (cleaned) {
243
+ return cleaned;
244
+ }
245
+ }
246
+ return DEFAULT_FILENAME;
247
+ }
248
+
249
+ /**
250
+ * Fetches the document at `url` and offers it to the user.
251
+ *
252
+ * Never throws: every failure is an outcome, because the caller is a bridge message handler whose
253
+ * only other option is to drop the user's tap silently.
254
+ */
255
+ export async function saveDocumentToDevice({
256
+ url,
257
+ filename,
258
+ devMode = false,
259
+ }: SaveDocumentOptions): Promise<FileSaveResult> {
260
+ /**
261
+ * Resolved before every other check so a rejection still reports the real extension rather than
262
+ * the EXTRACTION_FAILED sentinel, which means something else entirely. Neither call touches the
263
+ * network or the filesystem.
264
+ */
265
+ const safeName = resolveFilename(filename, url);
266
+ const fileExtension = extensionOf(safeName);
267
+ const failed = (outcome: FILE_SAVE_OUTCOMES, failureType?: string, fileSize = SIZE_UNAVAILABLE) => ({
268
+ outcome,
269
+ fileExtension,
270
+ fileSize,
271
+ ...(failureType ? { failureType } : {}),
272
+ });
273
+
274
+ if (!isFetchableUrl(url, devMode)) {
275
+ if (devMode) {
276
+ console.log(`${LOG_PREFIX} file.save rejected: unsupported URL scheme`);
277
+ }
278
+ return failed(FILE_SAVE_OUTCOMES.REJECTED);
279
+ }
280
+
281
+ const blobUtil = loadBlobUtil();
282
+ const picker = loadDocumentPicker();
283
+ if (!blobUtil || !picker) {
284
+ // Reachable only from a page that sent the message without gating on the capability list
285
+ if (devMode) {
286
+ console.warn(`${LOG_PREFIX} file.save received but the save packages are not usable in this build`);
287
+ }
288
+ return failed(FILE_SAVE_OUTCOMES.UNAVAILABLE);
289
+ }
290
+
291
+ /**
292
+ * A directory this SDK owns, unique per save.
293
+ *
294
+ * The Flutter SDK has to hunt for a free name because `file_picker` stages into the app
295
+ * documents directory under the page-chosen name and deletes whatever is already there. Nothing
296
+ * equivalent applies here: the destination path is ours to choose, so a page-supplied name can
297
+ * only ever land inside this directory, and two concurrent saves of the same name cannot meet.
298
+ * The name still has to survive `sanitize` because it is a path segment either way - and on iOS
299
+ * it is also the name the dialog pre-fills, which is taken from the source URI.
300
+ */
301
+ const stagingDirectory = `${blobUtil.fs.dirs.CacheDir}/glomopay-file-save/${Date.now()}-${++stagingCounter}`;
302
+ const stagingPath = `${stagingDirectory}/${safeName}`;
303
+
304
+ let cancelReason: CancelReason | null = null;
305
+ let stallTimer: ReturnType<typeof setTimeout> | undefined;
306
+
307
+ try {
308
+ await blobUtil.fs.mkdir(stagingDirectory);
309
+ } catch (error) {
310
+ return failed(FILE_SAVE_OUTCOMES.SAVE_FAILED, errorTypeOf(error));
311
+ }
312
+
313
+ try {
314
+ const task = blobUtil
315
+ .config({ path: stagingPath, timeout: CONNECT_TIMEOUT_MS, overwrite: true })
316
+ .fetch("GET", url);
317
+
318
+ const cancelWith = (reason: CancelReason) => {
319
+ if (cancelReason) {
320
+ return;
321
+ }
322
+ cancelReason = reason;
323
+ task.cancel();
324
+ };
325
+
326
+ const armStallTimer = () => {
327
+ if (stallTimer) {
328
+ clearTimeout(stallTimer);
329
+ }
330
+ stallTimer = setTimeout(
331
+ () => cancelWith({ outcome: FILE_SAVE_OUTCOMES.FETCH_FAILED, failureType: "stalled" }),
332
+ STALL_TIMEOUT_MS
333
+ );
334
+ };
335
+
336
+ armStallTimer();
337
+ task.progress({ count: -1, interval: 250 }, (received, total) => {
338
+ armStallTimer();
339
+
340
+ /**
341
+ * Content-Length first when the server sends one, so an oversized response is refused
342
+ * before any of it reaches disk; the running total is the backstop for chunked
343
+ * responses that send none.
344
+ */
345
+ const advertised = total > 0 ? total : SIZE_UNAVAILABLE;
346
+ if (advertised > FILE_SAVE_MAX_BYTES || received > FILE_SAVE_MAX_BYTES) {
347
+ cancelWith({
348
+ outcome: FILE_SAVE_OUTCOMES.TOO_LARGE,
349
+ size: advertised > 0 ? advertised : received,
350
+ });
351
+ }
352
+ });
353
+
354
+ const response = await task;
355
+
356
+ /**
357
+ * Disarmed here rather than only in `finally`, because `finally` does not run until the
358
+ * picker has resolved and the user may sit in it for minutes choosing a folder. A timer
359
+ * left armed across that would fire on a settled request - harmless, since blob-util
360
+ * replaces `cancel` with a no-op once the promise settles, but it is a live timer holding
361
+ * this closure for no reason.
362
+ */
363
+ if (stallTimer) {
364
+ clearTimeout(stallTimer);
365
+ stallTimer = undefined;
366
+ }
367
+
368
+ /**
369
+ * A cancel we asked for does not always reject. blob-util makes `cancel` a no-op once the
370
+ * promise has settled, so a response that crosses the cap and finishes inside the 250ms
371
+ * before the next progress tick resolves normally with the oversized body already on disk.
372
+ * Checked here as well as in the catch, or the cap silently does not apply in the one case
373
+ * it exists for - a chunked response whose Content-Length could not refuse it up front.
374
+ */
375
+ if (cancelReason) {
376
+ const reason: CancelReason = cancelReason;
377
+ return failed(reason.outcome, reason.failureType, reason.size ?? SIZE_UNAVAILABLE);
378
+ }
379
+
380
+ const info = response.info();
381
+
382
+ /**
383
+ * blob-util resolves on any status and writes the body regardless, so an error page is on
384
+ * disk at this point rather than the document
385
+ */
386
+ if (info.status !== 200) {
387
+ return failed(FILE_SAVE_OUTCOMES.FETCH_FAILED, `http_${info.status}`);
388
+ }
389
+
390
+ /**
391
+ * The scheme allowlist only ever saw the URL the page sent, and redirects are followed -
392
+ * OkHttp and NSURLSession both allow an https to http downgrade by default, so a signed
393
+ * link can land the document on a plaintext hop without isFetchableUrl being consulted
394
+ * again. Checking the chain after the fact cannot un-fetch those bytes, but it can decline
395
+ * to hand the user a document that travelled in the clear.
396
+ *
397
+ * The chain entries are full URLs, so they are signed links themselves: read here, never
398
+ * reported. The Flutter SDK has the same gap and no equivalent check.
399
+ */
400
+ const redirects = info.redirects;
401
+ if (
402
+ Array.isArray(redirects) &&
403
+ !redirects.every((hop) => typeof hop === "string" && isFetchableUrl(hop, devMode))
404
+ ) {
405
+ if (devMode) {
406
+ console.log(`${LOG_PREFIX} file.save rejected: redirected through an unsupported scheme`);
407
+ }
408
+ return failed(FILE_SAVE_OUTCOMES.REJECTED, "insecure_redirect");
409
+ }
410
+
411
+ const fileSize = await sizeOf(blobUtil, stagingPath);
412
+
413
+ try {
414
+ /**
415
+ * `copy: false` is iOS-only and is the default - the staged file is moved to where the
416
+ * user chose, which is what keeps a copy from being left behind in the app container.
417
+ * Android always copies and the cleanup below is what handles it there.
418
+ *
419
+ * `mimeType` is derived from the extension rather than forwarded from the page. Android
420
+ * puts it straight onto an `ACTION_CREATE_DOCUMENT` intent, and the page's field is
421
+ * untrusted; the Flutter SDK declines to forward the same field for the same reason.
422
+ */
423
+ await picker.saveDocuments({
424
+ sourceUris: [encodeURI(`file://${stagingPath}`)],
425
+ fileName: safeName,
426
+ mimeType: mimeTypeFor(fileExtension),
427
+ copy: false,
428
+ });
429
+
430
+ return { outcome: FILE_SAVE_OUTCOMES.SAVED, fileExtension, fileSize };
431
+ } catch (error) {
432
+ if (picker.isErrorWithCode(error) && error.code === picker.errorCodes.OPERATION_CANCELED) {
433
+ return { outcome: FILE_SAVE_OUTCOMES.DISMISSED, fileExtension, fileSize };
434
+ }
435
+
436
+ if (devMode) {
437
+ console.error(`${LOG_PREFIX} file.save dialog failed:`, errorTypeOf(error));
438
+ }
439
+ return failed(FILE_SAVE_OUTCOMES.SAVE_FAILED, errorTypeOf(error), fileSize);
440
+ }
441
+ } catch (error) {
442
+ // A cancel this code asked for rejects the fetch exactly as a transport failure does
443
+ if (cancelReason) {
444
+ const reason: CancelReason = cancelReason;
445
+ return failed(reason.outcome, reason.failureType, reason.size ?? SIZE_UNAVAILABLE);
446
+ }
447
+
448
+ if (devMode) {
449
+ console.error(`${LOG_PREFIX} file.save fetch failed:`, errorTypeOf(error));
450
+ }
451
+ return failed(FILE_SAVE_OUTCOMES.FETCH_FAILED, errorTypeOf(error));
452
+ } finally {
453
+ if (stallTimer) {
454
+ clearTimeout(stallTimer);
455
+ }
456
+
457
+ // Recursive on both platforms, and a no-op once iOS has moved the file out
458
+ try {
459
+ await blobUtil.fs.unlink(stagingDirectory);
460
+ } catch (error) {
461
+ if (devMode) {
462
+ console.error(`${LOG_PREFIX} file.save could not clear the staging directory:`, errorTypeOf(error));
463
+ }
464
+ }
465
+ }
466
+ }
467
+
468
+ /** Size of what was staged, or the sentinel. Never fatal - the document is already on disk. */
469
+ async function sizeOf(blobUtil: NonNullable<ReturnType<typeof loadBlobUtil>>, path: string): Promise<number> {
470
+ try {
471
+ const stat = await blobUtil.fs.stat(path);
472
+ const size = Number(stat.size);
473
+ return Number.isFinite(size) ? size : SIZE_UNAVAILABLE;
474
+ } catch {
475
+ return SIZE_UNAVAILABLE;
476
+ }
477
+ }
478
+
479
+ /**
480
+ * The name or code of a thrown value, never its message.
481
+ *
482
+ * RN's network errors and blob-util's own both interpolate the request URL into `message`, which is
483
+ * the signed link. A sentinel rather than an omission, so the field is always present and "could
484
+ * not tell" is something to filter on.
485
+ */
486
+ function errorTypeOf(error: unknown): string {
487
+ if (error && typeof error === "object") {
488
+ const candidate = error as { code?: unknown; name?: unknown };
489
+ if (typeof candidate.code === "string" && candidate.code.length > 0) {
490
+ return candidate.code;
491
+ }
492
+ if (typeof candidate.name === "string" && candidate.name.length > 0) {
493
+ return candidate.name;
494
+ }
495
+ }
496
+ return UNKNOWN_ERROR_TYPE;
497
+ }
@@ -1,13 +1,22 @@
1
- /** Unified v4 type definitions for GlomoCheckout */
1
+ /** Unified type definitions for GlomoCheckout */
2
2
 
3
3
  import { type GlomoServer } from "../config/base";
4
4
  import { type SdkError } from "../utils/analytics";
5
5
 
6
- /** The payload for a successful or failed payment (unified across LRS and standard) */
6
+ /**
7
+ * The payload for a successful or failed payment (unified across LRS and standard)
8
+ *
9
+ * `paymentId` and `signature` are optional as of v5. The checkout page can report a terminal
10
+ * outcome before the backend has resolved them - open banking completes on the bank's side first -
11
+ * and the SDK used to drop those outcomes entirely rather than report an incomplete one.
12
+ *
13
+ * With `strict` on, this lands as a compile error anywhere the value is forwarded to something
14
+ * expecting a `string`, which is the point: the narrowing check is the guardrail, not a nuisance.
15
+ */
7
16
  export interface GlomoCheckoutPayload {
8
17
  orderId: string;
9
- paymentId: string;
10
- signature: string;
18
+ paymentId?: string;
19
+ signature?: string;
11
20
  }
12
21
 
13
22
  /**
@@ -29,6 +38,44 @@ export interface GlomoUserJourneyCompletedPayload {
29
38
  transactionReference?: string;
30
39
  }
31
40
 
41
+ /**
42
+ * How a checkout ended, when it ended without a payment outcome.
43
+ *
44
+ * **Must stay exhaustive by construction.** Any future call site that terminates a checkout adds a
45
+ * member here rather than passing `undefined` or a catch-all - the whole value of "the payload
46
+ * always carries its source" is in the "always", and one lazy call site turns `source` into a field
47
+ * every merchant has to null-check.
48
+ *
49
+ * Deliberately does not include the back chevron over the bank flow. That returns the user to the
50
+ * main checkout with the session intact and the ability to pay, so reporting it as a termination
51
+ * would be false - and for a merchant who cancels the order on terminate it would cancel live
52
+ * orders every time a user backed out of one bank to try another.
53
+ */
54
+ export enum TERMINATION_SOURCES {
55
+ /** The checkout page's own close control, over the bridge as `checkout.closed` */
56
+ CHECKOUT_CLOSED = "checkout_closed",
57
+
58
+ /** Android hardware back or the back gesture */
59
+ DEVICE_BACK_BUTTON = "device_back_button",
60
+
61
+ /** iOS swipe-down on the pageSheet */
62
+ MODAL_DISMISS = "modal_dismiss",
63
+ }
64
+
65
+ /**
66
+ * The payload for the onPaymentTerminate callback.
67
+ *
68
+ * An object rather than a bare enum because every other callback in this SDK takes one, and because
69
+ * the source alone is not extensible: a later confirmation step would otherwise have to arrive as a
70
+ * second positional parameter, or silently change what an existing value means.
71
+ *
72
+ * The source describes **how** the checkout ended. The **why**, where there is one, already reached
73
+ * the host through the callback it got first.
74
+ */
75
+ export interface GlomoTerminationPayload {
76
+ source: TERMINATION_SOURCES;
77
+ }
78
+
32
79
  /** Unified checkout statuses - superset of LRS + standard statuses */
33
80
  export type CheckoutStatus =
34
81
  | "ready"
@@ -67,10 +114,19 @@ interface GlomoCheckoutBaseProps {
67
114
  onPaymentSuccess: (payload: GlomoCheckoutPayload) => void;
68
115
  onPaymentFailure: (payload: GlomoCheckoutPayload) => void;
69
116
  onConnectionError?: (error: unknown) => void;
70
- onPaymentTerminate?: () => void;
117
+ onPaymentTerminate?: (payload: GlomoTerminationPayload) => void;
71
118
  onSdkError: (error: Array<SdkError>) => void;
119
+ /**
120
+ * Optional callback for asynchronous payment journeys - bank transfers and pay via bank.
121
+ * Carries the journey type and the transfer details the page reported, not a confirmed payment.
122
+ *
123
+ * Accepted here because this component is a union over both flows, but **silently ignored on
124
+ * LRS orders**: LRS never produces an asynchronous journey, so nothing forwards it. If your
125
+ * orders are LRS-only you can leave it out entirely - supplying it is not an error, it simply
126
+ * never fires. Terminal LRS outcomes arrive through onPaymentSuccess and onPaymentFailure.
127
+ */
72
128
  onUserJourneyCompleted?: (payload: GlomoUserJourneyCompletedPayload) => void;
73
- onUserRefusedCameraPermissions?: () => void;
129
+ onUserRefusedDevicePermissions?: () => void;
74
130
  devMode?: boolean;
75
131
  }
76
132
 
@@ -2,7 +2,7 @@
2
2
 
3
3
  import { type GlomoServer } from "../config/base";
4
4
  import { type SdkError } from "../utils/analytics";
5
- import { type GlomoUserJourneyCompletedPayload } from "./checkout";
5
+ import { type GlomoTerminationPayload, type GlomoUserJourneyCompletedPayload } from "./checkout";
6
6
 
7
7
  /** Standard checkout statuses */
8
8
  export type StandardCheckoutStatus =
@@ -14,11 +14,11 @@ export type StandardCheckoutStatus =
14
14
  | "bank_transfer_submitted"
15
15
  | "pay_via_bank_completed";
16
16
 
17
- /** The payload for a successful or failed standard payment */
17
+ /** The payload for a successful or failed standard payment. See GlomoCheckoutPayload on the optional fields. */
18
18
  export interface GlomoStandardCheckoutPayload {
19
19
  orderId: string;
20
- paymentId: string;
21
- signature: string;
20
+ paymentId?: string;
21
+ signature?: string;
22
22
  }
23
23
 
24
24
  /**
@@ -39,9 +39,9 @@ export interface GlomoStandardCheckoutProps {
39
39
  onPaymentSuccess: (payload: GlomoStandardCheckoutPayload) => void;
40
40
  onPaymentFailure: (payload: GlomoStandardCheckoutPayload) => void;
41
41
  onConnectionError?: (error: unknown) => void;
42
- onPaymentTerminate?: () => void;
42
+ onPaymentTerminate?: (payload: GlomoTerminationPayload) => void;
43
43
  onSdkError: (error: Array<SdkError>) => void;
44
44
  onUserJourneyCompleted?: (payload: GlomoUserJourneyCompletedPayload) => void;
45
- onUserRefusedCameraPermissions?: () => void;
45
+ onUserRefusedDevicePermissions?: () => void;
46
46
  devMode?: boolean;
47
47
  }
@@ -2,7 +2,11 @@
2
2
 
3
3
  import { type GlomoServer } from "../config/base";
4
4
  import { type SdkError } from "../utils/analytics";
5
- import { type GlomoCheckoutPayload, type GlomoUserJourneyCompletedPayload } from "./checkout";
5
+ import {
6
+ type GlomoCheckoutPayload,
7
+ type GlomoTerminationPayload,
8
+ type GlomoUserJourneyCompletedPayload,
9
+ } from "./checkout";
6
10
  import { type StandardCheckoutStatus } from "./standard-checkout";
7
11
 
8
12
  /** Props for the subscriptions checkout component */
@@ -13,10 +17,10 @@ export interface GlomoSubscriptionsCheckoutProps {
13
17
  onPaymentSuccess: (payload: GlomoCheckoutPayload) => void;
14
18
  onPaymentFailure: (payload: GlomoCheckoutPayload) => void;
15
19
  onConnectionError?: (error: unknown) => void;
16
- onPaymentTerminate?: () => void;
20
+ onPaymentTerminate?: (payload: GlomoTerminationPayload) => void;
17
21
  onSdkError: (error: Array<SdkError>) => void;
18
22
  onUserJourneyCompleted?: (payload: GlomoUserJourneyCompletedPayload) => void;
19
- onUserRefusedCameraPermissions?: () => void;
23
+ onUserRefusedDevicePermissions?: () => void;
20
24
  devMode?: boolean;
21
25
  /** Internal prop for automated tests - forwarded to GlomoStandardCheckoutInternal via rest spread */
22
26
  _testInjectionScript?: string;
@@ -10,7 +10,7 @@ import { type GlomoLrsCheckoutRef } from "./glomo-lrs-checkout";
10
10
  import { type GlomoStandardCheckoutRef } from "./types/standard-checkout";
11
11
  import { type GlomoSubscriptionsCheckoutRef } from "./types/subscriptions-checkout";
12
12
  import { type OrderType, fetchOrderType } from "./services/order-type-fetcher";
13
- import { isValidPublicKey, isValidOrderId, safeCallback } from "./utils/validation";
13
+ import { isValidPublicKey, isValidOrderId, isMockModeKey, safeCallback } from "./utils/validation";
14
14
  import {
15
15
  type SdkError,
16
16
  trackOrderTypeDetectionStarted,
@@ -85,7 +85,7 @@ export function useGlomoCheckout(
85
85
 
86
86
  // One-time deprecation warning for direct merchant usage
87
87
  const suppressWarning = internalOptions?._suppressDeprecationWarning ?? false;
88
- const mockMode = publicKey?.toLowerCase().startsWith("test_") ?? false;
88
+ const mockMode = isMockModeKey(publicKey);
89
89
  const hasWarnedRef = useRef(false);
90
90
  useEffect(() => {
91
91
  if (!hasWarnedRef.current && !suppressWarning) {