@morefin/cashier-bootstrapper 0.1.8 → 0.1.9

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 CHANGED
@@ -85,9 +85,39 @@ new CashierBootstrapper('#cashier-root', {
85
85
  });
86
86
  ```
87
87
 
88
+ ### Transaction Result Callbacks
89
+
90
+ ```typescript
91
+ import { CashierBootstrapper } from '@morefin/cashier-bootstrapper';
92
+
93
+ new CashierBootstrapper('#cashier-root', {
94
+ properties: { environment: CASHIER_ENVIRONMENT },
95
+ callbacks: {
96
+ onSuccess: ({ status, transactionId }) => {
97
+ console.log('Cashier success callback', status, transactionId);
98
+ },
99
+ onFailure: ({ status, message, transactionId }) => {
100
+ console.log('Cashier failure callback', status, message, transactionId);
101
+ },
102
+ onCancel: ({ status, transactionId }) => {
103
+ console.log('Cashier cancel callback', status, transactionId);
104
+ },
105
+ onValidationFailed: ({ message }) => {
106
+ console.log('Cashier validation failed callback', message);
107
+ }
108
+ }
109
+ });
110
+ ```
111
+
112
+ Callback mapping:
113
+ - `onSuccess`: result status `APPROVED` or `DECLINED`
114
+ - `onFailure`: result status `INVALID` (or explicit cashier error event)
115
+ - `onCancel`: result status `CANCELLED`
116
+ - `onValidationFailed`: `/validate` request failed; `message` is forwarded from cashier
117
+
88
118
  ## Examples Folder
89
119
 
90
- - `examples/npm` contains a minimal app that installs the package from npm and bundles it with esbuild.
120
+ - Repository examples live in `../examples/cashier-bootstrapper` (outside this package directory), including `../examples/cashier-bootstrapper/npm`.
91
121
  - `src/example-usage.ts` contains additional snippets for reference.
92
122
 
93
123
  ## API
@@ -99,6 +129,7 @@ Iframe-based embed that loads the cashier URL and exposes runtime controls once
99
129
  **Parameters:**
100
130
  - `container: string | HTMLElement | null | undefined` - Where the iframe is appended. If omitted, `config.properties.container` is used (falling back to `document.body`).
101
131
  - `config?: CashierIframeConfig` - Request params and iframe properties. `properties.environment` is required.
132
+ - `config.callbacks?: CashierCallbacks` - Host callback handlers for cashier result events.
102
133
  - `onReady?: (api: CashierIframeApi) => void` - Called when the iframe loads; exposes helpers:
103
134
  - `api.setCss(css: string)` – inject CSS inside the cashier iframe
104
135
  - `api.updateData(data: object)` – post updated `APP_DATA` to the cashier
@@ -138,14 +169,23 @@ interface CashierIframeProperties {
138
169
  interface CashierIframeConfig {
139
170
  requestParams?: CashierRequestParams;
140
171
  properties?: CashierIframeProperties;
172
+ callbacks?: CashierCallbacks;
173
+ }
174
+
175
+ interface CashierResultCallbackPayload {
176
+ status?: string;
177
+ transactionId?: string;
178
+ message?: string;
141
179
  }
142
- ```
143
180
 
144
- ## Defaults
181
+ interface CashierValidationFailedPayload {
182
+ message: string;
183
+ }
145
184
 
146
- - Cashier path is fixed to `/cashier`
147
- - `environment: 'production'` resolves to `https://api.morefin.com`
148
- - `environment: 'uat'` resolves to `https://uat-api.morefin.com`
149
- - Iframe URL origin is validated to allow only `api.morefin.com` and `uat-api.morefin.com`
150
- - `iframe.width`/`iframe.height`: `100%`
151
- - `iframe.allow`: `geolocation *;camera *;payment *;clipboard-read *;clipboard-write *;autoplay *;microphone *;fullscreen *;accelerometer *;magnetometer *;gyroscope *;picture-in-picture *;otp-credentials *;`
185
+ interface CashierCallbacks {
186
+ onSuccess?: (payload: CashierResultCallbackPayload) => void;
187
+ onFailure?: (payload: CashierResultCallbackPayload) => void;
188
+ onCancel?: (payload: CashierResultCallbackPayload) => void;
189
+ onValidationFailed?: (payload: CashierValidationFailedPayload) => void;
190
+ }
191
+ ```
package/dist/index.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { CashierIframeApi, CashierIframeConfig } from './types';
2
- export type { CashierEnvironment, CashierRequestParams, CashierIframeApi, CashierIframeConfig, CashierIframeProperties } from './types';
2
+ export type { CashierCallbacks, CashierEnvironment, CashierRequestParams, CashierIframeApi, CashierIframeConfig, CashierIframeProperties, CashierResultCallbackPayload, CashierValidationFailedPayload } from './types';
3
3
  type FingerprintJSGlobal = {
4
4
  load: () => Promise<{
5
5
  get: () => Promise<{
@@ -14,16 +14,34 @@ declare global {
14
14
  }
15
15
  export declare class CashierBootstrapper {
16
16
  private iframe?;
17
+ private redirectOverlayElement?;
18
+ private redirectOverlayPanel?;
19
+ private redirectOverlayIframe?;
20
+ private overlayPositionSyncHandler?;
17
21
  private origin;
18
22
  private ready;
19
23
  private readonly fullConfig;
20
24
  private readonly host;
25
+ private readonly allowedHostnames;
21
26
  private readonly onReady?;
27
+ private readonly callbacks?;
28
+ private readonly messageListener;
22
29
  constructor(container: string | HTMLElement | null | undefined, config?: CashierIframeConfig, onReady?: (api: CashierIframeApi) => void);
23
30
  private bootstrapIframe;
24
31
  private createIframeShell;
25
32
  private handleLoad;
26
33
  private postMessage;
34
+ private handleIframeMessage;
35
+ private handleCashierResultEvent;
36
+ private handleCashierValidationFailedEvent;
37
+ private handleCashierRedirect;
38
+ private openProviderRedirectOverlay;
39
+ private closeProviderRedirectOverlay;
40
+ private syncProviderRedirectOverlayToCashierIframe;
41
+ private normalizeRedirectTarget;
42
+ private normalizeResultStatus;
43
+ private asString;
44
+ private invokeCallback;
27
45
  /**
28
46
  * API exposed to host pages for runtime control.
29
47
  */
package/dist/index.js CHANGED
@@ -1,11 +1,29 @@
1
1
  const DEFAULT_IFRAME_ALLOW = 'geolocation *;camera *;payment *;clipboard-read *;clipboard-write *;autoplay *;microphone *;fullscreen *;accelerometer *;magnetometer *;gyroscope *;picture-in-picture *;otp-credentials *;';
2
2
  const CASHIER_PATH = '/cashier';
3
3
  const FINGERPRINT_CDN = 'https://cdn.jsdelivr.net/npm/@fingerprintjs/fingerprintjs@4/dist/fp.min.js';
4
- const CASHIER_HOSTS = {
5
- production: 'https://api.morefin.com',
6
- uat: 'https://uat-api.morefin.com'
4
+ const CASHIER_REDIRECT_EVENT = 'CASHIER_REDIRECT';
5
+ const TOP_URL_REPLACE_EVENT = 'TOP_URL_REPLACE';
6
+ const CASHIER_IFRAME_OVERLAY_CLOSE_EVENT = 'CASHIER_IFRAME_OVERLAY_CLOSE';
7
+ const CASHIER_RESULT_EVENT = 'CASHIER_RESULT';
8
+ const CASHIER_VALIDATION_FAILED_EVENT = 'CASHIER_VALIDATION_FAILED';
9
+ const CASHIER_ENVIRONMENT_CONFIG = {
10
+ production: {
11
+ host: 'https://api.morefin.com',
12
+ allowedHostnames: ['api.morefin.com']
13
+ },
14
+ uat: {
15
+ host: 'https://uat-api.morefin.com',
16
+ allowedHostnames: ['uat-api.morefin.com']
17
+ },
18
+ local: {
19
+ host: 'http://localhost:7082',
20
+ allowedHostnames: ['localhost', '127.0.0.1']
21
+ },
22
+ local4200: {
23
+ host: 'http://localhost:4200',
24
+ allowedHostnames: ['localhost', '127.0.0.1']
25
+ }
7
26
  };
8
- const ALLOWED_CASHIER_DOMAINS = new Set(['api.morefin.com', 'uat-api.morefin.com']);
9
27
  let fpLoaderPromise;
10
28
  function loadFingerprintLibrary() {
11
29
  if (typeof window === 'undefined') {
@@ -62,7 +80,7 @@ function buildQueryString(requestParams, fingerprint) {
62
80
  }
63
81
  if (Array.isArray(requestParams.predefinedAmounts)) {
64
82
  requestParams.predefinedAmounts.forEach(amount => {
65
- query.append('predefined_amounts', String(amount));
83
+ query.append('predefinedAmounts', String(amount));
66
84
  });
67
85
  }
68
86
  if (requestParams.layout != null && requestParams.layout !== '' && requestParams.layout !== 'undefined') {
@@ -90,39 +108,43 @@ function resolveContainer(container) {
90
108
  }
91
109
  return document.body;
92
110
  }
93
- function resolveHost(environment) {
94
- const host = CASHIER_HOSTS[environment];
95
- if (!host) {
111
+ function resolveEnvironmentConfig(environment) {
112
+ const config = CASHIER_ENVIRONMENT_CONFIG[environment];
113
+ if (!config) {
96
114
  throw new Error(`Unsupported cashier environment "${environment}"`);
97
115
  }
98
- return host;
116
+ return config;
99
117
  }
100
- function parseAndVerifyCashierUrl(url) {
118
+ function parseAndVerifyCashierUrl(url, allowedHostnames) {
101
119
  const parsedUrl = new URL(url);
102
- if (!ALLOWED_CASHIER_DOMAINS.has(parsedUrl.hostname)) {
120
+ if (!allowedHostnames.includes(parsedUrl.hostname)) {
103
121
  throw new Error(`Cashier domain "${parsedUrl.hostname}" is not allowed`);
104
122
  }
105
123
  return parsedUrl;
106
124
  }
107
- function buildIframeUrl(host, requestParams, fingerprint) {
125
+ function buildIframeUrl(host, requestParams, allowedHostnames, fingerprint) {
108
126
  const trimmedHost = host.endsWith('/') ? host.slice(0, -1) : host;
109
127
  const url = `${trimmedHost}${CASHIER_PATH}${buildQueryString(requestParams, fingerprint)}`;
110
- parseAndVerifyCashierUrl(url);
128
+ parseAndVerifyCashierUrl(url, allowedHostnames);
111
129
  return url;
112
130
  }
113
- function getOriginFromUrl(url) {
114
- return parseAndVerifyCashierUrl(url).origin;
131
+ function getOriginFromUrl(url, allowedHostnames) {
132
+ return parseAndVerifyCashierUrl(url, allowedHostnames).origin;
115
133
  }
116
134
  export class CashierBootstrapper {
117
135
  constructor(container, config = {}, onReady) {
118
136
  this.origin = '*';
119
137
  this.ready = false;
138
+ this.messageListener = (event) => this.handleIframeMessage(event);
120
139
  this.onReady = onReady;
140
+ this.callbacks = config.callbacks;
121
141
  const environment = config.properties?.environment;
122
142
  if (!environment) {
123
143
  throw new Error('Cashier environment is required to bootstrap the iframe');
124
144
  }
125
- this.host = resolveHost(environment);
145
+ const environmentConfig = resolveEnvironmentConfig(environment);
146
+ this.host = environmentConfig.host;
147
+ this.allowedHostnames = environmentConfig.allowedHostnames;
126
148
  this.fullConfig = {
127
149
  requestParams: { ...config.requestParams },
128
150
  properties: {
@@ -138,12 +160,15 @@ export class CashierBootstrapper {
138
160
  };
139
161
  const target = resolveContainer(container ?? this.fullConfig.properties.container);
140
162
  this.iframe = this.createIframeShell(target);
163
+ if (typeof window !== 'undefined') {
164
+ window.addEventListener('message', this.messageListener);
165
+ }
141
166
  void this.bootstrapIframe();
142
167
  }
143
168
  async bootstrapIframe() {
144
169
  const fp = await generateFingerprint();
145
- const src = buildIframeUrl(this.host, this.fullConfig.requestParams, fp);
146
- this.origin = getOriginFromUrl(src);
170
+ const src = buildIframeUrl(this.host, this.fullConfig.requestParams, this.allowedHostnames, fp);
171
+ this.origin = getOriginFromUrl(src, this.allowedHostnames);
147
172
  if (this.iframe) {
148
173
  this.iframe.src = src;
149
174
  this.iframe.onload = () => this.handleLoad();
@@ -186,6 +211,221 @@ export class CashierBootstrapper {
186
211
  }
187
212
  this.iframe.contentWindow.postMessage({ eventType, payload }, this.origin);
188
213
  }
214
+ handleIframeMessage(event) {
215
+ const sourceIsMainCashierIframe = !!this.iframe?.contentWindow && event.source === this.iframe.contentWindow;
216
+ const sourceIsOverlayIframe = !!this.redirectOverlayIframe?.contentWindow && event.source === this.redirectOverlayIframe.contentWindow;
217
+ if (!sourceIsMainCashierIframe && !sourceIsOverlayIframe) {
218
+ return;
219
+ }
220
+ if (event.origin !== this.origin) {
221
+ return;
222
+ }
223
+ if (!event.data || typeof event.data !== 'object') {
224
+ return;
225
+ }
226
+ const { eventType, payload } = event.data;
227
+ if (eventType === CASHIER_IFRAME_OVERLAY_CLOSE_EVENT) {
228
+ if (sourceIsOverlayIframe) {
229
+ this.closeProviderRedirectOverlay();
230
+ }
231
+ return;
232
+ }
233
+ if (!sourceIsMainCashierIframe) {
234
+ return;
235
+ }
236
+ if (eventType === TOP_URL_REPLACE_EVENT) {
237
+ this.handleCashierRedirect({ url: payload?.url, target: 'window' });
238
+ return;
239
+ }
240
+ if (eventType === CASHIER_RESULT_EVENT) {
241
+ this.handleCashierResultEvent(payload);
242
+ return;
243
+ }
244
+ if (eventType === CASHIER_VALIDATION_FAILED_EVENT) {
245
+ this.handleCashierValidationFailedEvent(payload);
246
+ return;
247
+ }
248
+ if (eventType === CASHIER_REDIRECT_EVENT) {
249
+ this.handleCashierRedirect(payload);
250
+ }
251
+ }
252
+ handleCashierResultEvent(payload) {
253
+ if (!payload || typeof payload !== 'object') {
254
+ return;
255
+ }
256
+ const resultPayload = payload;
257
+ const status = this.normalizeResultStatus(resultPayload['status']);
258
+ const callbackPayload = {
259
+ status,
260
+ transactionId: this.asString(resultPayload['transactionId']),
261
+ message: this.asString(resultPayload['message'])
262
+ };
263
+ if (!status) {
264
+ return;
265
+ }
266
+ if (status === 'approved' || status === 'declined') {
267
+ this.invokeCallback(this.callbacks?.onSuccess, callbackPayload);
268
+ return;
269
+ }
270
+ if (status === 'cancelled') {
271
+ this.invokeCallback(this.callbacks?.onCancel, callbackPayload);
272
+ return;
273
+ }
274
+ if (status === 'invalid' || status === 'error') {
275
+ this.invokeCallback(this.callbacks?.onFailure, callbackPayload);
276
+ }
277
+ }
278
+ handleCashierValidationFailedEvent(payload) {
279
+ if (!payload || typeof payload !== 'object') {
280
+ return;
281
+ }
282
+ const validationPayload = payload;
283
+ const message = this.asString(validationPayload['message']) ?? 'Validation failed';
284
+ const callbackPayload = { message };
285
+ this.invokeCallback(this.callbacks?.onValidationFailed, callbackPayload);
286
+ }
287
+ handleCashierRedirect(payload) {
288
+ if (!payload || typeof payload !== 'object') {
289
+ return;
290
+ }
291
+ const redirectUrl = payload.url;
292
+ const redirectTarget = this.normalizeRedirectTarget(payload.target);
293
+ if (typeof redirectUrl !== 'string' || redirectUrl.trim() === '') {
294
+ return;
295
+ }
296
+ switch (redirectTarget) {
297
+ case 'iframe':
298
+ console.log('[CashierBootstrapper] Handling redirect target "iframe".', { redirectUrl });
299
+ if (!this.openProviderRedirectOverlay(redirectUrl) && this.iframe) {
300
+ console.warn('[CashierBootstrapper] Could not open provider overlay; falling back to redirect inside cashier iframe.');
301
+ this.iframe.src = redirectUrl;
302
+ }
303
+ break;
304
+ case 'tab':
305
+ console.log('[CashierBootstrapper] Handling redirect target "tab".', { redirectUrl });
306
+ window.open(redirectUrl, '_blank', 'noopener,noreferrer');
307
+ break;
308
+ case 'window':
309
+ default:
310
+ console.log('[CashierBootstrapper] Handling redirect target "window".', { redirectUrl });
311
+ window.location.assign(redirectUrl);
312
+ break;
313
+ }
314
+ }
315
+ openProviderRedirectOverlay(redirectUrl) {
316
+ if (typeof document === 'undefined' || typeof window === 'undefined') {
317
+ return false;
318
+ }
319
+ if (!this.iframe) {
320
+ return false;
321
+ }
322
+ this.closeProviderRedirectOverlay();
323
+ try {
324
+ const overlay = document.createElement('div');
325
+ overlay.setAttribute('data-cashier-provider-overlay', 'true');
326
+ overlay.style.position = 'fixed';
327
+ overlay.style.top = '0';
328
+ overlay.style.left = '0';
329
+ overlay.style.right = '0';
330
+ overlay.style.bottom = '0';
331
+ overlay.style.zIndex = '2147483000';
332
+ overlay.style.background = 'transparent';
333
+ overlay.style.pointerEvents = 'none';
334
+ const panel = document.createElement('div');
335
+ panel.style.position = 'fixed';
336
+ panel.style.top = '0';
337
+ panel.style.left = '0';
338
+ panel.style.width = '0';
339
+ panel.style.height = '0';
340
+ panel.style.background = '#fff';
341
+ panel.style.overflow = 'hidden';
342
+ panel.style.display = 'flex';
343
+ panel.style.flexDirection = 'column';
344
+ panel.style.pointerEvents = 'auto';
345
+ const iframe = document.createElement('iframe');
346
+ iframe.src = redirectUrl;
347
+ iframe.style.border = 'none';
348
+ iframe.style.width = '100%';
349
+ iframe.style.height = '100%';
350
+ iframe.allow = DEFAULT_IFRAME_ALLOW;
351
+ iframe.setAttribute('data-provider-redirect-iframe', 'true');
352
+ panel.appendChild(iframe);
353
+ overlay.appendChild(panel);
354
+ document.body.appendChild(overlay);
355
+ this.redirectOverlayElement = overlay;
356
+ this.redirectOverlayPanel = panel;
357
+ this.redirectOverlayIframe = iframe;
358
+ this.syncProviderRedirectOverlayToCashierIframe();
359
+ const sync = () => this.syncProviderRedirectOverlayToCashierIframe();
360
+ this.overlayPositionSyncHandler = sync;
361
+ window.addEventListener('resize', sync);
362
+ window.addEventListener('scroll', sync, true);
363
+ return true;
364
+ }
365
+ catch (err) {
366
+ console.warn('Failed to mount provider redirect iframe overlay.', err);
367
+ this.closeProviderRedirectOverlay();
368
+ return false;
369
+ }
370
+ }
371
+ closeProviderRedirectOverlay() {
372
+ if (typeof window !== 'undefined' && this.overlayPositionSyncHandler) {
373
+ window.removeEventListener('resize', this.overlayPositionSyncHandler);
374
+ window.removeEventListener('scroll', this.overlayPositionSyncHandler, true);
375
+ }
376
+ this.overlayPositionSyncHandler = undefined;
377
+ const overlay = this.redirectOverlayElement;
378
+ if (overlay && overlay.parentNode) {
379
+ overlay.parentNode.removeChild(overlay);
380
+ }
381
+ this.redirectOverlayElement = undefined;
382
+ this.redirectOverlayPanel = undefined;
383
+ this.redirectOverlayIframe = undefined;
384
+ }
385
+ syncProviderRedirectOverlayToCashierIframe() {
386
+ const panel = this.redirectOverlayPanel;
387
+ const iframe = this.iframe;
388
+ if (!panel || !iframe) {
389
+ return;
390
+ }
391
+ const rect = iframe.getBoundingClientRect();
392
+ panel.style.left = `${rect.left}px`;
393
+ panel.style.top = `${rect.top}px`;
394
+ panel.style.width = `${rect.width}px`;
395
+ panel.style.height = `${rect.height}px`;
396
+ panel.style.borderRadius = window.getComputedStyle(iframe).borderRadius || '0';
397
+ }
398
+ normalizeRedirectTarget(target) {
399
+ if (target === 'iframe' || target === 'tab' || target === 'window') {
400
+ return target;
401
+ }
402
+ return 'window';
403
+ }
404
+ normalizeResultStatus(status) {
405
+ if (typeof status !== 'string') {
406
+ return undefined;
407
+ }
408
+ const normalized = status.trim().toLowerCase();
409
+ return normalized || undefined;
410
+ }
411
+ asString(value) {
412
+ if (typeof value !== 'string') {
413
+ return undefined;
414
+ }
415
+ const normalized = value.trim();
416
+ return normalized || undefined;
417
+ }
418
+ invokeCallback(callback, payload) {
419
+ if (!callback) {
420
+ return;
421
+ }
422
+ try {
423
+ callback(payload);
424
+ }
425
+ catch (err) {
426
+ console.error('[CashierBootstrapper] Host callback threw an error.', err);
427
+ }
428
+ }
189
429
  /**
190
430
  * API exposed to host pages for runtime control.
191
431
  */
package/dist/types.d.ts CHANGED
@@ -10,7 +10,7 @@ export interface CashierRequestParams {
10
10
  layout?: string;
11
11
  transactionType?: string;
12
12
  }
13
- export type CashierEnvironment = 'production' | 'uat';
13
+ export type CashierEnvironment = 'production' | 'uat' | 'local' | 'local4200';
14
14
  /**
15
15
  * Bootstrap properties configuration
16
16
  */
@@ -81,6 +81,7 @@ export interface CashierIframeProperties extends BootstrapProperties {
81
81
  export interface CashierIframeConfig {
82
82
  requestParams?: CashierRequestParams;
83
83
  properties?: CashierIframeProperties;
84
+ callbacks?: CashierCallbacks;
84
85
  }
85
86
  /**
86
87
  * Minimal API surface returned to hosts after iframe creation.
@@ -92,3 +93,17 @@ export interface CashierIframeApi {
92
93
  pause: () => void;
93
94
  resume: () => void;
94
95
  }
96
+ export interface CashierResultCallbackPayload {
97
+ status?: string;
98
+ transactionId?: string;
99
+ message?: string;
100
+ }
101
+ export interface CashierValidationFailedPayload {
102
+ message: string;
103
+ }
104
+ export interface CashierCallbacks {
105
+ onSuccess?: (payload: CashierResultCallbackPayload) => void;
106
+ onFailure?: (payload: CashierResultCallbackPayload) => void;
107
+ onCancel?: (payload: CashierResultCallbackPayload) => void;
108
+ onValidationFailed?: (payload: CashierValidationFailedPayload) => void;
109
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@morefin/cashier-bootstrapper",
3
- "version": "0.1.8",
3
+ "version": "0.1.9",
4
4
  "description": "Bootstrap service for initializing cashier payment page data from API",
5
5
  "main": "dist/index.js",
6
6
  "module": "dist/index.js",