@ozura/elements 1.2.0 → 1.2.3
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 +105 -0
- package/dist/frame/element-frame.js +60 -4
- package/dist/frame/element-frame.js.map +1 -1
- package/dist/frame/tokenizer-frame.js +145 -61
- package/dist/frame/tokenizer-frame.js.map +1 -1
- package/dist/oz-elements.esm.js +221 -97
- package/dist/oz-elements.esm.js.map +1 -1
- package/dist/oz-elements.umd.js +221 -97
- package/dist/oz-elements.umd.js.map +1 -1
- package/dist/react/frame/elementFrame.d.ts +9 -0
- package/dist/react/frame/tokenizerFrame.d.ts +17 -1
- package/dist/react/index.cjs.js +221 -97
- package/dist/react/index.cjs.js.map +1 -1
- package/dist/react/index.esm.js +221 -97
- package/dist/react/index.esm.js.map +1 -1
- package/dist/react/sdk/OzElement.d.ts +4 -1
- package/dist/react/sdk/OzVault.d.ts +3 -9
- package/dist/react/sdk/createSessionFetcher.d.ts +2 -2
- package/dist/react/server/index.d.ts +26 -0
- package/dist/react/types/index.d.ts +37 -14
- package/dist/react/utils/billingUtils.d.ts +2 -1
- package/dist/react/vue/index.d.ts +88 -0
- package/dist/server/frame/elementFrame.d.ts +9 -0
- package/dist/server/frame/tokenizerFrame.d.ts +17 -1
- package/dist/server/index.cjs.js +77 -27
- package/dist/server/index.cjs.js.map +1 -1
- package/dist/server/index.esm.js +77 -27
- package/dist/server/index.esm.js.map +1 -1
- package/dist/server/sdk/OzElement.d.ts +4 -1
- package/dist/server/sdk/OzVault.d.ts +3 -9
- package/dist/server/sdk/createSessionFetcher.d.ts +2 -2
- package/dist/server/server/index.d.ts +26 -0
- package/dist/server/types/index.d.ts +37 -14
- package/dist/server/utils/billingUtils.d.ts +2 -1
- package/dist/server/vue/index.d.ts +88 -0
- package/dist/types/frame/elementFrame.d.ts +9 -0
- package/dist/types/frame/tokenizerFrame.d.ts +17 -1
- package/dist/types/sdk/OzElement.d.ts +4 -1
- package/dist/types/sdk/OzVault.d.ts +3 -9
- package/dist/types/sdk/createSessionFetcher.d.ts +2 -2
- package/dist/types/server/index.d.ts +26 -0
- package/dist/types/types/index.d.ts +37 -14
- package/dist/types/utils/billingUtils.d.ts +2 -1
- package/dist/types/vue/index.d.ts +88 -0
- package/dist/vue/frame/protocol.d.ts +12 -0
- package/dist/vue/index.cjs.js +2335 -0
- package/dist/vue/index.cjs.js.map +1 -0
- package/dist/vue/index.esm.js +2327 -0
- package/dist/vue/index.esm.js.map +1 -0
- package/dist/vue/sdk/OzElement.d.ts +99 -0
- package/dist/vue/sdk/OzVault.d.ts +250 -0
- package/dist/vue/sdk/createSessionFetcher.d.ts +29 -0
- package/dist/vue/sdk/errors.d.ts +65 -0
- package/dist/vue/sdk/index.d.ts +14 -0
- package/dist/vue/types/index.d.ts +667 -0
- package/dist/vue/utils/appearance.d.ts +22 -0
- package/dist/vue/utils/billingUtils.d.ts +61 -0
- package/dist/vue/utils/uuid.d.ts +12 -0
- package/dist/vue/vue/index.d.ts +88 -0
- package/package.json +15 -3
|
@@ -0,0 +1,2327 @@
|
|
|
1
|
+
import { defineComponent, shallowRef, ref, provide, onMounted, onUnmounted, inject, watch, h, computed } from 'vue';
|
|
2
|
+
|
|
3
|
+
const THEME_DEFAULT = {
|
|
4
|
+
base: {
|
|
5
|
+
color: '#1a1a2e',
|
|
6
|
+
fontSize: '16px',
|
|
7
|
+
fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif',
|
|
8
|
+
lineHeight: '1.5',
|
|
9
|
+
padding: '12px 14px',
|
|
10
|
+
backgroundColor: 'transparent',
|
|
11
|
+
caretColor: '#6366f1',
|
|
12
|
+
transition: 'color .15s ease',
|
|
13
|
+
},
|
|
14
|
+
focus: {
|
|
15
|
+
color: '#111827',
|
|
16
|
+
},
|
|
17
|
+
invalid: {
|
|
18
|
+
color: '#dc2626',
|
|
19
|
+
},
|
|
20
|
+
complete: {
|
|
21
|
+
color: '#16a34a',
|
|
22
|
+
},
|
|
23
|
+
placeholder: {
|
|
24
|
+
color: '#9ca3af',
|
|
25
|
+
},
|
|
26
|
+
};
|
|
27
|
+
const THEME_NIGHT = {
|
|
28
|
+
base: {
|
|
29
|
+
color: '#e5e7eb',
|
|
30
|
+
fontSize: '16px',
|
|
31
|
+
fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif',
|
|
32
|
+
lineHeight: '1.5',
|
|
33
|
+
padding: '12px 14px',
|
|
34
|
+
backgroundColor: 'transparent',
|
|
35
|
+
caretColor: '#818cf8',
|
|
36
|
+
transition: 'color .15s ease',
|
|
37
|
+
},
|
|
38
|
+
focus: {
|
|
39
|
+
color: '#f9fafb',
|
|
40
|
+
},
|
|
41
|
+
invalid: {
|
|
42
|
+
color: '#fca5a5',
|
|
43
|
+
},
|
|
44
|
+
complete: {
|
|
45
|
+
color: '#86efac',
|
|
46
|
+
},
|
|
47
|
+
placeholder: {
|
|
48
|
+
color: '#6b7280',
|
|
49
|
+
},
|
|
50
|
+
};
|
|
51
|
+
const THEME_FLAT = {
|
|
52
|
+
base: {
|
|
53
|
+
color: '#374151',
|
|
54
|
+
fontSize: '16px',
|
|
55
|
+
fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif',
|
|
56
|
+
lineHeight: '1.5',
|
|
57
|
+
padding: '12px 14px',
|
|
58
|
+
backgroundColor: 'transparent',
|
|
59
|
+
caretColor: '#6366f1',
|
|
60
|
+
transition: 'color .15s ease',
|
|
61
|
+
borderBottom: '2px solid #d1d5db',
|
|
62
|
+
borderRadius: '0px',
|
|
63
|
+
},
|
|
64
|
+
focus: {
|
|
65
|
+
color: '#111827',
|
|
66
|
+
borderBottom: '2px solid #6366f1',
|
|
67
|
+
},
|
|
68
|
+
invalid: {
|
|
69
|
+
color: '#dc2626',
|
|
70
|
+
borderBottom: '2px solid #dc2626',
|
|
71
|
+
},
|
|
72
|
+
complete: {
|
|
73
|
+
color: '#16a34a',
|
|
74
|
+
borderBottom: '2px solid #16a34a',
|
|
75
|
+
},
|
|
76
|
+
placeholder: {
|
|
77
|
+
color: '#9ca3af',
|
|
78
|
+
},
|
|
79
|
+
};
|
|
80
|
+
const THEMES = {
|
|
81
|
+
default: THEME_DEFAULT,
|
|
82
|
+
night: THEME_NIGHT,
|
|
83
|
+
flat: THEME_FLAT,
|
|
84
|
+
};
|
|
85
|
+
function variablesToStyle(vars) {
|
|
86
|
+
const base = {};
|
|
87
|
+
const focus = {};
|
|
88
|
+
const invalid = {};
|
|
89
|
+
const complete = {};
|
|
90
|
+
const placeholder = {};
|
|
91
|
+
if (vars.colorText)
|
|
92
|
+
base.color = vars.colorText;
|
|
93
|
+
if (vars.colorBackground)
|
|
94
|
+
base.backgroundColor = vars.colorBackground;
|
|
95
|
+
if (vars.fontFamily)
|
|
96
|
+
base.fontFamily = vars.fontFamily;
|
|
97
|
+
if (vars.fontSize)
|
|
98
|
+
base.fontSize = vars.fontSize;
|
|
99
|
+
if (vars.fontWeight)
|
|
100
|
+
base.fontWeight = vars.fontWeight;
|
|
101
|
+
if (vars.letterSpacing)
|
|
102
|
+
base.letterSpacing = vars.letterSpacing;
|
|
103
|
+
if (vars.lineHeight)
|
|
104
|
+
base.lineHeight = vars.lineHeight;
|
|
105
|
+
if (vars.padding)
|
|
106
|
+
base.padding = vars.padding;
|
|
107
|
+
if (vars.colorPrimary) {
|
|
108
|
+
focus.caretColor = vars.colorPrimary;
|
|
109
|
+
base.caretColor = vars.colorPrimary;
|
|
110
|
+
}
|
|
111
|
+
if (vars.colorDanger)
|
|
112
|
+
invalid.color = vars.colorDanger;
|
|
113
|
+
if (vars.colorSuccess)
|
|
114
|
+
complete.color = vars.colorSuccess;
|
|
115
|
+
if (vars.colorPlaceholder)
|
|
116
|
+
placeholder.color = vars.colorPlaceholder;
|
|
117
|
+
return Object.assign(Object.assign(Object.assign(Object.assign(Object.assign({}, (Object.keys(base).length > 0 ? { base } : {})), (Object.keys(focus).length > 0 ? { focus } : {})), (Object.keys(invalid).length > 0 ? { invalid } : {})), (Object.keys(complete).length > 0 ? { complete } : {})), (Object.keys(placeholder).length > 0 ? { placeholder } : {}));
|
|
118
|
+
}
|
|
119
|
+
function mergeStyleConfigs(a, b) {
|
|
120
|
+
return {
|
|
121
|
+
base: Object.assign(Object.assign({}, a.base), b.base),
|
|
122
|
+
focus: Object.assign(Object.assign({}, a.focus), b.focus),
|
|
123
|
+
invalid: Object.assign(Object.assign({}, a.invalid), b.invalid),
|
|
124
|
+
complete: Object.assign(Object.assign({}, a.complete), b.complete),
|
|
125
|
+
placeholder: Object.assign(Object.assign({}, a.placeholder), b.placeholder),
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
/**
|
|
129
|
+
* Resolves an `Appearance` config into a flat `ElementStyleConfig`.
|
|
130
|
+
* Resolution order: theme defaults → variable overrides.
|
|
131
|
+
* The returned config is then used as the "base appearance" that
|
|
132
|
+
* per-element `style` overrides merge on top of.
|
|
133
|
+
*
|
|
134
|
+
* @remarks
|
|
135
|
+
* - `appearance: undefined` → no styles injected (element iframes use their
|
|
136
|
+
* own minimal built-in defaults).
|
|
137
|
+
* - `appearance: {}` or `appearance: { variables: {...} }` without an explicit
|
|
138
|
+
* `theme` → the `'default'` theme is used as the base. Omitting `theme`
|
|
139
|
+
* does NOT mean "no theme" — it means `theme: 'default'`. To opt out of
|
|
140
|
+
* the preset themes entirely, use per-element `style` overrides without
|
|
141
|
+
* passing an `appearance` prop at all.
|
|
142
|
+
*/
|
|
143
|
+
function resolveAppearance(appearance) {
|
|
144
|
+
var _a, _b;
|
|
145
|
+
if (!appearance)
|
|
146
|
+
return undefined;
|
|
147
|
+
const theme = (_b = THEMES[(_a = appearance.theme) !== null && _a !== void 0 ? _a : 'default']) !== null && _b !== void 0 ? _b : THEMES.default;
|
|
148
|
+
if (!appearance.variables)
|
|
149
|
+
return theme;
|
|
150
|
+
const varStyles = variablesToStyle(appearance.variables);
|
|
151
|
+
return mergeStyleConfigs(theme, varStyles);
|
|
152
|
+
}
|
|
153
|
+
/**
|
|
154
|
+
* Merges a resolved appearance with per-element style overrides.
|
|
155
|
+
* Element styles always win over appearance styles.
|
|
156
|
+
*/
|
|
157
|
+
function mergeAppearanceWithElementStyle(appearance, elementStyle) {
|
|
158
|
+
if (!appearance && !elementStyle)
|
|
159
|
+
return undefined;
|
|
160
|
+
if (!appearance)
|
|
161
|
+
return elementStyle;
|
|
162
|
+
if (!elementStyle)
|
|
163
|
+
return appearance;
|
|
164
|
+
return mergeStyleConfigs(appearance, elementStyle);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* errors.ts — error types and normalisation for OzElements.
|
|
169
|
+
*
|
|
170
|
+
* Three normalisation functions:
|
|
171
|
+
* - normalizeVaultError — maps raw vault /tokenize errors to user-facing messages (card flows)
|
|
172
|
+
* - normalizeBankVaultError — maps raw vault /tokenize errors to user-facing messages (bank/ACH flows)
|
|
173
|
+
* - normalizeCardSaleError — maps raw cardSale API errors to user-facing messages
|
|
174
|
+
*
|
|
175
|
+
* Error keys in normalizeCardSaleError are taken directly from checkout's
|
|
176
|
+
* errorMapping.ts so the same error strings produce the same user-facing copy.
|
|
177
|
+
*/
|
|
178
|
+
const OZ_ERROR_CODES = new Set(['network', 'timeout', 'auth', 'validation', 'server', 'config', 'unknown']);
|
|
179
|
+
/** Returns true and narrows to OzErrorCode when `value` is a valid member of the union. */
|
|
180
|
+
function isOzErrorCode(value) {
|
|
181
|
+
return typeof value === 'string' && OZ_ERROR_CODES.has(value);
|
|
182
|
+
}
|
|
183
|
+
class OzError extends Error {
|
|
184
|
+
constructor(message, raw, errorCode) {
|
|
185
|
+
super(message);
|
|
186
|
+
this.name = 'OzError';
|
|
187
|
+
this.raw = raw !== null && raw !== void 0 ? raw : message;
|
|
188
|
+
this.errorCode = errorCode !== null && errorCode !== void 0 ? errorCode : 'unknown';
|
|
189
|
+
this.retryable = this.errorCode === 'network' || this.errorCode === 'timeout' || this.errorCode === 'server';
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
/** Shared patterns that apply to both card and bank vault errors. */
|
|
193
|
+
function normalizeCommonVaultError(msg) {
|
|
194
|
+
if (msg.includes('api key') || msg.includes('unauthorized') || msg.includes('authentication') || msg.includes('forbidden')) {
|
|
195
|
+
return 'Authentication failed. Check your vault API key configuration.';
|
|
196
|
+
}
|
|
197
|
+
if (msg.includes('network') || msg.includes('failed to fetch') || msg.includes('networkerror')) {
|
|
198
|
+
return 'A network error occurred. Please check your connection and try again.';
|
|
199
|
+
}
|
|
200
|
+
if (msg.includes('timeout') || msg.includes('timed out')) {
|
|
201
|
+
return 'The request timed out. Please try again.';
|
|
202
|
+
}
|
|
203
|
+
if (msg.includes('http 5') || /\b5\d{2}\b/.test(msg)) {
|
|
204
|
+
return 'A server error occurred. Please try again shortly.';
|
|
205
|
+
}
|
|
206
|
+
return null;
|
|
207
|
+
}
|
|
208
|
+
/**
|
|
209
|
+
* Maps a raw vault /tokenize error string to a user-facing message for card flows.
|
|
210
|
+
* Falls back to the original string if no pattern matches.
|
|
211
|
+
*/
|
|
212
|
+
function normalizeVaultError(raw) {
|
|
213
|
+
const msg = raw.toLowerCase();
|
|
214
|
+
if (msg.includes('card number') || msg.includes('invalid card') || msg.includes('luhn')) {
|
|
215
|
+
return 'The card number is invalid. Please check and try again.';
|
|
216
|
+
}
|
|
217
|
+
if (msg.includes('expir')) {
|
|
218
|
+
return 'The card expiration date is invalid or the card has expired.';
|
|
219
|
+
}
|
|
220
|
+
if (msg.includes('cvv') || msg.includes('cvc') || msg.includes('security code')) {
|
|
221
|
+
return 'The CVV code is invalid. Please check and try again.';
|
|
222
|
+
}
|
|
223
|
+
if (msg.includes('insufficient funds')) {
|
|
224
|
+
return 'Your card has insufficient funds. Please use a different card.';
|
|
225
|
+
}
|
|
226
|
+
if (msg.includes('declined') || msg.includes('do not honor')) {
|
|
227
|
+
return 'Your card was declined. Please try a different card or contact your bank.';
|
|
228
|
+
}
|
|
229
|
+
const common = normalizeCommonVaultError(msg);
|
|
230
|
+
if (common)
|
|
231
|
+
return common;
|
|
232
|
+
return raw;
|
|
233
|
+
}
|
|
234
|
+
/**
|
|
235
|
+
* Maps a raw vault /tokenize error string to a user-facing message for bank (ACH) flows.
|
|
236
|
+
* Uses bank-specific pattern matching so card-specific messages are never shown for
|
|
237
|
+
* bank errors. Falls back to the original string if no pattern matches.
|
|
238
|
+
*/
|
|
239
|
+
function normalizeBankVaultError(raw) {
|
|
240
|
+
const msg = raw.toLowerCase();
|
|
241
|
+
if (msg.includes('account number') || msg.includes('account_number') || msg.includes('invalid account')) {
|
|
242
|
+
return 'The bank account number is invalid. Please check and try again.';
|
|
243
|
+
}
|
|
244
|
+
if (msg.includes('routing number') || msg.includes('routing_number') || msg.includes('invalid routing') || /\baba\b/.test(msg)) {
|
|
245
|
+
return 'The routing number is invalid. Please check and try again.';
|
|
246
|
+
}
|
|
247
|
+
const common = normalizeCommonVaultError(msg);
|
|
248
|
+
if (common)
|
|
249
|
+
return common;
|
|
250
|
+
return raw;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* Generates a RFC 4122 v4 UUID.
|
|
255
|
+
*
|
|
256
|
+
* Uses `crypto.randomUUID()` when available (Chrome 92+, Firefox 95+,
|
|
257
|
+
* Safari 15.4+, Node 14.17+) and falls back to `crypto.getRandomValues()`
|
|
258
|
+
* for older environments (Safari 14, some embedded WebViews, older Node).
|
|
259
|
+
*
|
|
260
|
+
* Both paths use the same CSPRNG — the difference is only in API surface.
|
|
261
|
+
*
|
|
262
|
+
* @internal
|
|
263
|
+
*/
|
|
264
|
+
function uuid() {
|
|
265
|
+
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
|
|
266
|
+
return crypto.randomUUID();
|
|
267
|
+
}
|
|
268
|
+
// Fallback: build UUID v4 from random bytes
|
|
269
|
+
const bytes = new Uint8Array(16);
|
|
270
|
+
crypto.getRandomValues(bytes);
|
|
271
|
+
bytes[6] = (bytes[6] & 0x0f) | 0x40; // version 4
|
|
272
|
+
bytes[8] = (bytes[8] & 0x3f) | 0x80; // variant RFC 4122
|
|
273
|
+
const hex = Array.from(bytes, b => b.toString(16).padStart(2, '0')).join('');
|
|
274
|
+
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
const BLOCKED_CSS_PATTERNS = /url\s*\(|expression\s*\(|javascript\s*:|vbscript\s*:|@import|behavior\s*:|binding\s*:|-moz-binding|-webkit-binding|<\s*script|<\s*style|\\[0-9a-fA-F]|var\s*\(/i;
|
|
278
|
+
const CSS_BREAKOUT = /[{};<>]/;
|
|
279
|
+
const MAX_CSS_VALUE_LEN = 200;
|
|
280
|
+
function sanitizeStyleObj(obj) {
|
|
281
|
+
if (!obj)
|
|
282
|
+
return obj;
|
|
283
|
+
const clean = {};
|
|
284
|
+
for (const [k, v] of Object.entries(obj)) {
|
|
285
|
+
if (v === undefined) {
|
|
286
|
+
clean[k] = v;
|
|
287
|
+
continue;
|
|
288
|
+
}
|
|
289
|
+
if (typeof v !== 'string' || v.length > MAX_CSS_VALUE_LEN || BLOCKED_CSS_PATTERNS.test(v) || CSS_BREAKOUT.test(v))
|
|
290
|
+
continue;
|
|
291
|
+
clean[k] = v;
|
|
292
|
+
}
|
|
293
|
+
return clean;
|
|
294
|
+
}
|
|
295
|
+
function sanitizeStyles(style) {
|
|
296
|
+
if (!style)
|
|
297
|
+
return style;
|
|
298
|
+
return {
|
|
299
|
+
base: sanitizeStyleObj(style.base),
|
|
300
|
+
focus: sanitizeStyleObj(style.focus),
|
|
301
|
+
invalid: sanitizeStyleObj(style.invalid),
|
|
302
|
+
complete: sanitizeStyleObj(style.complete),
|
|
303
|
+
placeholder: sanitizeStyleObj(style.placeholder),
|
|
304
|
+
};
|
|
305
|
+
}
|
|
306
|
+
function sanitizeOptions(options) {
|
|
307
|
+
var _a;
|
|
308
|
+
const result = Object.assign(Object.assign({}, options), { placeholder: (_a = options.placeholder) === null || _a === void 0 ? void 0 : _a.slice(0, 100) });
|
|
309
|
+
// Coerce to boolean so a string "false" (truthy in JS) does not accidentally
|
|
310
|
+
// disable the input when the SDK is consumed from plain JavaScript.
|
|
311
|
+
if (options.disabled !== undefined) {
|
|
312
|
+
result.disabled = Boolean(options.disabled);
|
|
313
|
+
}
|
|
314
|
+
// Only set style when provided; omitting it avoids clobbering existing style
|
|
315
|
+
// when merging (e.g. update({ placeholder: 'new' }) must not overwrite style with undefined).
|
|
316
|
+
if (options.style !== undefined) {
|
|
317
|
+
result.style = sanitizeStyles(options.style);
|
|
318
|
+
}
|
|
319
|
+
return result;
|
|
320
|
+
}
|
|
321
|
+
/**
|
|
322
|
+
* A proxy for one Ozura iframe element. Merchants interact with this object;
|
|
323
|
+
* it never holds raw card data — all sensitive values live in the iframe.
|
|
324
|
+
*/
|
|
325
|
+
class OzElement {
|
|
326
|
+
constructor(elementType, options, vaultId, frameBaseUrl, fonts = [], appearanceStyle, onDestroy, debug = false) {
|
|
327
|
+
this.iframe = null;
|
|
328
|
+
this._frameWindow = null;
|
|
329
|
+
this._ready = false;
|
|
330
|
+
this._destroyed = false;
|
|
331
|
+
this._loadTimer = null;
|
|
332
|
+
this.pendingMessages = [];
|
|
333
|
+
this.handlers = new Map();
|
|
334
|
+
this.debug = false;
|
|
335
|
+
this.elementType = elementType;
|
|
336
|
+
this.options = sanitizeOptions(options);
|
|
337
|
+
this.vaultId = vaultId;
|
|
338
|
+
this.frameBaseUrl = frameBaseUrl;
|
|
339
|
+
this.frameOrigin = new URL(frameBaseUrl).origin;
|
|
340
|
+
this.fonts = fonts;
|
|
341
|
+
this.appearanceStyle = appearanceStyle;
|
|
342
|
+
this.frameId = `oz-${elementType}-${uuid()}`;
|
|
343
|
+
this._onDestroy = onDestroy;
|
|
344
|
+
this.debug = debug;
|
|
345
|
+
}
|
|
346
|
+
/** The element type this proxy represents. */
|
|
347
|
+
get type() {
|
|
348
|
+
return this.elementType;
|
|
349
|
+
}
|
|
350
|
+
/** True once the element iframe has loaded and signalled ready. */
|
|
351
|
+
get isReady() {
|
|
352
|
+
return this._ready;
|
|
353
|
+
}
|
|
354
|
+
/**
|
|
355
|
+
* Mounts the element iframe into a container.
|
|
356
|
+
* Accepts either a CSS selector string or a direct HTMLElement reference
|
|
357
|
+
* (useful when integrating with React refs).
|
|
358
|
+
*/
|
|
359
|
+
mount(target) {
|
|
360
|
+
var _a, _b;
|
|
361
|
+
if (this._destroyed)
|
|
362
|
+
throw new OzError('Cannot mount a destroyed element.');
|
|
363
|
+
if (this.iframe)
|
|
364
|
+
this.unmount();
|
|
365
|
+
const container = typeof target === 'string'
|
|
366
|
+
? document.querySelector(target)
|
|
367
|
+
: target;
|
|
368
|
+
if (!container)
|
|
369
|
+
throw new OzError(typeof target === 'string'
|
|
370
|
+
? `Mount target not found — no element matches "${target}"`
|
|
371
|
+
: 'Mount target not found — the provided HTMLElement is null or undefined');
|
|
372
|
+
const iframe = document.createElement('iframe');
|
|
373
|
+
iframe.setAttribute('frameborder', '0');
|
|
374
|
+
iframe.setAttribute('scrolling', 'no');
|
|
375
|
+
iframe.setAttribute('allowtransparency', 'true');
|
|
376
|
+
iframe.style.cssText = 'border:none;width:100%;height:46px;display:block;overflow:hidden;';
|
|
377
|
+
iframe.title = `Secure ${(_a = {
|
|
378
|
+
cardNumber: 'card number',
|
|
379
|
+
expirationDate: 'expiration date',
|
|
380
|
+
cvv: 'CVV',
|
|
381
|
+
accountNumber: 'account number',
|
|
382
|
+
routingNumber: 'routing number',
|
|
383
|
+
}[this.elementType]) !== null && _a !== void 0 ? _a : this.elementType} input`;
|
|
384
|
+
// sandbox="allow-scripts" gives correct iframe isolation:
|
|
385
|
+
// - Scripts run (allow-scripts), so the field JS executes normally.
|
|
386
|
+
// - NO allow-same-origin: the frame cannot access window.parent's DOM,
|
|
387
|
+
// localStorage, or cookies — prevents sandbox escape even if served
|
|
388
|
+
// from the same origin.
|
|
389
|
+
// - NO allow-top-navigation: a rogue/compromised element frame cannot
|
|
390
|
+
// navigate window.top (clickjacking prevention).
|
|
391
|
+
// - NO allow-forms / allow-popups: reduces attack surface.
|
|
392
|
+
// Field values are delivered via postMessage, so no parent access is
|
|
393
|
+
// needed — allow-scripts alone is sufficient.
|
|
394
|
+
iframe.setAttribute('sandbox', 'allow-scripts');
|
|
395
|
+
// Use hash instead of query string — survives clean-URL redirects from static servers.
|
|
396
|
+
// parentOrigin lets the frame target postMessage to the merchant origin instead of '*'.
|
|
397
|
+
const parentOrigin = typeof window !== 'undefined' ? window.location.origin : '';
|
|
398
|
+
const src = `${this.frameBaseUrl}/frame/element-frame.html#type=${this.elementType}&vaultId=${encodeURIComponent(this.vaultId)}&frameId=${encodeURIComponent(this.frameId)}${parentOrigin ? `&parentOrigin=${encodeURIComponent(parentOrigin)}` : ''}`;
|
|
399
|
+
iframe.src = src;
|
|
400
|
+
container.appendChild(iframe);
|
|
401
|
+
this.iframe = iframe;
|
|
402
|
+
this._frameWindow = iframe.contentWindow;
|
|
403
|
+
const timeout = (_b = this.options.loadTimeoutMs) !== null && _b !== void 0 ? _b : 10000;
|
|
404
|
+
this._loadTimer = setTimeout(() => {
|
|
405
|
+
if (!this._ready && !this._destroyed) {
|
|
406
|
+
this.emit('loaderror', { elementType: this.elementType, error: `${this.elementType} iframe failed to load within ${timeout}ms` });
|
|
407
|
+
}
|
|
408
|
+
}, timeout);
|
|
409
|
+
}
|
|
410
|
+
/**
|
|
411
|
+
* Subscribe to an element event. Returns `this` for chaining.
|
|
412
|
+
* @param event - Event name: `'change'`, `'focus'`, `'blur'`, `'ready'`, or `'loaderror'`.
|
|
413
|
+
* @param callback - Handler invoked with the event payload.
|
|
414
|
+
*/
|
|
415
|
+
on(event, callback) {
|
|
416
|
+
if (this._destroyed)
|
|
417
|
+
return this;
|
|
418
|
+
if (!this.handlers.has(event))
|
|
419
|
+
this.handlers.set(event, []);
|
|
420
|
+
this.handlers.get(event).push(callback);
|
|
421
|
+
return this;
|
|
422
|
+
}
|
|
423
|
+
/**
|
|
424
|
+
* Remove a previously registered event handler.
|
|
425
|
+
* Has no effect if the handler is not registered.
|
|
426
|
+
*/
|
|
427
|
+
off(event, callback) {
|
|
428
|
+
const list = this.handlers.get(event);
|
|
429
|
+
if (list) {
|
|
430
|
+
const idx = list.indexOf(callback);
|
|
431
|
+
if (idx !== -1)
|
|
432
|
+
list.splice(idx, 1);
|
|
433
|
+
}
|
|
434
|
+
return this;
|
|
435
|
+
}
|
|
436
|
+
/**
|
|
437
|
+
* Subscribe to an event for a single invocation. The handler is automatically
|
|
438
|
+
* removed after it fires once.
|
|
439
|
+
*/
|
|
440
|
+
once(event, callback) {
|
|
441
|
+
const wrapper = (payload) => {
|
|
442
|
+
this.off(event, wrapper);
|
|
443
|
+
callback(payload);
|
|
444
|
+
};
|
|
445
|
+
return this.on(event, wrapper);
|
|
446
|
+
}
|
|
447
|
+
/**
|
|
448
|
+
* Dynamically update element options (placeholder, style, etc.) without
|
|
449
|
+
* re-mounting the iframe. Only the provided keys are merged; omitted keys
|
|
450
|
+
* retain their current values.
|
|
451
|
+
*/
|
|
452
|
+
update(options) {
|
|
453
|
+
if (this._destroyed)
|
|
454
|
+
return;
|
|
455
|
+
const safe = sanitizeOptions(options);
|
|
456
|
+
// Re-merge vault appearance when style is updated so focus/invalid/complete/
|
|
457
|
+
// placeholder buckets from the theme are not stripped by a partial style object.
|
|
458
|
+
if (safe.style !== undefined) {
|
|
459
|
+
safe.style = mergeAppearanceWithElementStyle(this.appearanceStyle, safe.style);
|
|
460
|
+
}
|
|
461
|
+
this.options = Object.assign(Object.assign({}, this.options), safe);
|
|
462
|
+
this.post({ type: 'OZ_UPDATE', options: safe });
|
|
463
|
+
}
|
|
464
|
+
/** Clear the current field value without removing the element from the DOM. */
|
|
465
|
+
clear() {
|
|
466
|
+
if (this._destroyed)
|
|
467
|
+
return;
|
|
468
|
+
this.post({ type: 'OZ_CLEAR' });
|
|
469
|
+
}
|
|
470
|
+
/**
|
|
471
|
+
* Removes the iframe from the DOM and resets internal state.
|
|
472
|
+
* Called automatically by `OzVault.destroy()`. Safe to call manually
|
|
473
|
+
* for partial teardown (e.g. swapping payment method in a SPA).
|
|
474
|
+
*/
|
|
475
|
+
unmount() {
|
|
476
|
+
var _a;
|
|
477
|
+
if (this._destroyed)
|
|
478
|
+
return;
|
|
479
|
+
if (this._loadTimer) {
|
|
480
|
+
clearTimeout(this._loadTimer);
|
|
481
|
+
this._loadTimer = null;
|
|
482
|
+
}
|
|
483
|
+
(_a = this.iframe) === null || _a === void 0 ? void 0 : _a.remove();
|
|
484
|
+
this.iframe = null;
|
|
485
|
+
this._frameWindow = null;
|
|
486
|
+
this._ready = false;
|
|
487
|
+
this.pendingMessages = [];
|
|
488
|
+
}
|
|
489
|
+
/** Programmatically focus this element's input. Used internally for auto-advance. */
|
|
490
|
+
focus() {
|
|
491
|
+
if (this._destroyed)
|
|
492
|
+
return;
|
|
493
|
+
this.post({ type: 'OZ_FOCUS_REQUEST' });
|
|
494
|
+
}
|
|
495
|
+
/** Programmatically blur this element's input. */
|
|
496
|
+
blur() {
|
|
497
|
+
if (this._destroyed)
|
|
498
|
+
return;
|
|
499
|
+
this.post({ type: 'OZ_BLUR_REQUEST' });
|
|
500
|
+
}
|
|
501
|
+
/**
|
|
502
|
+
* Permanently destroys this element: unmounts it, clears all event handlers,
|
|
503
|
+
* and prevents future use. Distinct from `unmount()` which allows re-mounting.
|
|
504
|
+
*/
|
|
505
|
+
destroy() {
|
|
506
|
+
var _a;
|
|
507
|
+
this.unmount();
|
|
508
|
+
this.handlers.clear();
|
|
509
|
+
this._destroyed = true;
|
|
510
|
+
// Notify OzVault so it can prune the stale frameId entry from its elements
|
|
511
|
+
// and completionState maps. Without this, manually calling el.destroy() leaks
|
|
512
|
+
// map entries that grow unboundedly in SPA scenarios with repeated mount/unmount.
|
|
513
|
+
(_a = this._onDestroy) === null || _a === void 0 ? void 0 : _a.call(this);
|
|
514
|
+
}
|
|
515
|
+
// ─── Called by OzVault ───────────────────────────────────────────────────
|
|
516
|
+
/**
|
|
517
|
+
* Sends OZ_BEGIN_COLLECT to the element iframe, transferring `port` so the
|
|
518
|
+
* iframe can post its field value directly to the tokenizer without going
|
|
519
|
+
* through the merchant page (no named-window lookup required).
|
|
520
|
+
* @internal
|
|
521
|
+
*/
|
|
522
|
+
beginCollect(requestId, port) {
|
|
523
|
+
if (this._destroyed)
|
|
524
|
+
return;
|
|
525
|
+
this.sendWithTransfer({ type: 'OZ_BEGIN_COLLECT', requestId }, [port]);
|
|
526
|
+
}
|
|
527
|
+
/**
|
|
528
|
+
* Tell a CVV element how many digits to expect. Called automatically when card brand changes.
|
|
529
|
+
* @internal
|
|
530
|
+
*/
|
|
531
|
+
setCvvLength(length) {
|
|
532
|
+
if (this._destroyed)
|
|
533
|
+
return;
|
|
534
|
+
this.post({ type: 'OZ_SET_CVV_LENGTH', length });
|
|
535
|
+
}
|
|
536
|
+
/** @internal */
|
|
537
|
+
handleMessage(msg) {
|
|
538
|
+
var _a, _b;
|
|
539
|
+
if (this._destroyed)
|
|
540
|
+
return;
|
|
541
|
+
switch (msg.type) {
|
|
542
|
+
case 'OZ_FRAME_READY': {
|
|
543
|
+
this._ready = true;
|
|
544
|
+
if (this._loadTimer) {
|
|
545
|
+
clearTimeout(this._loadTimer);
|
|
546
|
+
this._loadTimer = null;
|
|
547
|
+
}
|
|
548
|
+
this._frameWindow = (_b = (_a = this.iframe) === null || _a === void 0 ? void 0 : _a.contentWindow) !== null && _b !== void 0 ? _b : null;
|
|
549
|
+
const mergedOptions = Object.assign(Object.assign({}, this.options), { style: mergeAppearanceWithElementStyle(this.appearanceStyle, this.options.style) });
|
|
550
|
+
this.post(Object.assign({ type: 'OZ_INIT', elementType: this.elementType, options: sanitizeOptions(mergedOptions), frameId: this.frameId, debug: this.debug }, (this.fonts.length > 0 ? { fonts: this.fonts } : {})));
|
|
551
|
+
this.pendingMessages.forEach(m => this.send(m));
|
|
552
|
+
this.pendingMessages = [];
|
|
553
|
+
this.emit('ready', undefined);
|
|
554
|
+
// Warn if the mount container collapses to zero height — the input will
|
|
555
|
+
// be invisible but functional, which is hard to debug. Check after one
|
|
556
|
+
// animation frame so the browser has completed layout. Guard against
|
|
557
|
+
// non-rendering environments (jsdom, SSR) where all rects are zero.
|
|
558
|
+
if (typeof requestAnimationFrame !== 'undefined') {
|
|
559
|
+
requestAnimationFrame(() => {
|
|
560
|
+
const inRealBrowser = document.documentElement.getBoundingClientRect().height > 0;
|
|
561
|
+
if (inRealBrowser && this.iframe && this.iframe.getBoundingClientRect().height === 0) {
|
|
562
|
+
console.warn(`[OzElement] "${this.elementType}" mounted but has zero height. ` +
|
|
563
|
+
`Check that the container has a visible height (not overflow:hidden or height:0).`);
|
|
564
|
+
}
|
|
565
|
+
});
|
|
566
|
+
}
|
|
567
|
+
break;
|
|
568
|
+
}
|
|
569
|
+
case 'OZ_CHANGE':
|
|
570
|
+
this.emit('change', {
|
|
571
|
+
empty: msg.empty,
|
|
572
|
+
complete: msg.complete,
|
|
573
|
+
valid: msg.valid,
|
|
574
|
+
cardBrand: msg.cardBrand,
|
|
575
|
+
month: msg.month,
|
|
576
|
+
year: msg.year,
|
|
577
|
+
error: msg.error,
|
|
578
|
+
});
|
|
579
|
+
break;
|
|
580
|
+
case 'OZ_FOCUS':
|
|
581
|
+
this.emit('focus', undefined);
|
|
582
|
+
break;
|
|
583
|
+
case 'OZ_BLUR':
|
|
584
|
+
this.emit('blur', {
|
|
585
|
+
empty: msg.empty,
|
|
586
|
+
complete: msg.complete,
|
|
587
|
+
valid: msg.valid,
|
|
588
|
+
error: msg.error,
|
|
589
|
+
});
|
|
590
|
+
break;
|
|
591
|
+
case 'OZ_RESIZE':
|
|
592
|
+
if (this.iframe) {
|
|
593
|
+
// Clamp to a sensible range to prevent a rogue or compromised frame
|
|
594
|
+
// from zeroing out the input or stretching the layout.
|
|
595
|
+
const h = typeof msg.height === 'number' ? msg.height : 0;
|
|
596
|
+
if (h > 0 && h <= 300) {
|
|
597
|
+
this.iframe.style.height = `${h}px`;
|
|
598
|
+
}
|
|
599
|
+
}
|
|
600
|
+
break;
|
|
601
|
+
}
|
|
602
|
+
}
|
|
603
|
+
// ─── Internal ────────────────────────────────────────────────────────────
|
|
604
|
+
emit(event, payload) {
|
|
605
|
+
const list = this.handlers.get(event);
|
|
606
|
+
if (!list)
|
|
607
|
+
return;
|
|
608
|
+
[...list].forEach(fn => {
|
|
609
|
+
try {
|
|
610
|
+
fn(payload);
|
|
611
|
+
}
|
|
612
|
+
catch (err) {
|
|
613
|
+
console.error(`[OzElement] Unhandled error in '${event}' listener:`, err);
|
|
614
|
+
}
|
|
615
|
+
});
|
|
616
|
+
}
|
|
617
|
+
post(data) {
|
|
618
|
+
const msg = Object.assign({ __oz: true, vaultId: this.vaultId }, data);
|
|
619
|
+
if (!this._ready) {
|
|
620
|
+
this.pendingMessages.push(msg);
|
|
621
|
+
}
|
|
622
|
+
else {
|
|
623
|
+
this.send(msg);
|
|
624
|
+
}
|
|
625
|
+
}
|
|
626
|
+
send(msg) {
|
|
627
|
+
var _a;
|
|
628
|
+
(_a = this._frameWindow) === null || _a === void 0 ? void 0 : _a.postMessage(msg, this.frameOrigin);
|
|
629
|
+
}
|
|
630
|
+
/** Posts a message with transferable objects (e.g. MessagePort). Bypasses the
|
|
631
|
+
* pending-message queue — only call when the frame is already ready. */
|
|
632
|
+
sendWithTransfer(data, transfer) {
|
|
633
|
+
if (this._destroyed)
|
|
634
|
+
return;
|
|
635
|
+
if (!this._frameWindow) {
|
|
636
|
+
console.error('[OzElement] sendWithTransfer called before frame window is available — port will not be transferred. This is a bug.');
|
|
637
|
+
return;
|
|
638
|
+
}
|
|
639
|
+
const msg = Object.assign({ __oz: true, vaultId: this.vaultId }, data);
|
|
640
|
+
this._frameWindow.postMessage(msg, this.frameOrigin, transfer);
|
|
641
|
+
}
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
/**
|
|
645
|
+
* billingUtils.ts — billing detail validation and normalization.
|
|
646
|
+
*
|
|
647
|
+
* Mirrors the validation in checkout/page.tsx (pre-flight checks before cardSale)
|
|
648
|
+
* so that billing data passed to createToken() is guaranteed schema-compliant and
|
|
649
|
+
* ready to forward directly to the Ozura Pay API cardSale endpoint.
|
|
650
|
+
*
|
|
651
|
+
* All string fields enforced to 1–50 characters (cardSale schema constraint).
|
|
652
|
+
* State is normalized to 2-letter abbreviation for US and CA.
|
|
653
|
+
* Phone must be E.164 format (matches checkout's formatPhoneForAPI output).
|
|
654
|
+
*/
|
|
655
|
+
// ─── Email ────────────────────────────────────────────────────────────────────
|
|
656
|
+
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
|
657
|
+
/** Returns true when the email is syntactically valid and ≤50 characters. */
|
|
658
|
+
function validateEmail(email) {
|
|
659
|
+
return EMAIL_RE.test(email) && email.length <= 50;
|
|
660
|
+
}
|
|
661
|
+
// ─── Phone ───────────────────────────────────────────────────────────────────
|
|
662
|
+
/**
|
|
663
|
+
* Validates E.164 phone format: starts with +, 1–3 digit country code,
|
|
664
|
+
* followed by 7–12 digits, max 15 digits total (E.164 spec cap = 16 chars
|
|
665
|
+
* including the leading +).
|
|
666
|
+
*
|
|
667
|
+
* Matches the output of checkout's formatPhoneForAPI() function.
|
|
668
|
+
* Examples: "+15551234567", "+447911123456", "+61412345678"
|
|
669
|
+
*/
|
|
670
|
+
function validateE164Phone(phone) {
|
|
671
|
+
return /^\+[1-9]\d{6,14}$/.test(phone);
|
|
672
|
+
}
|
|
673
|
+
// ─── Field length ─────────────────────────────────────────────────────────────
|
|
674
|
+
/** Returns true when the string is non-empty and ≤50 characters (cardSale schema). */
|
|
675
|
+
function isValidBillingField(value) {
|
|
676
|
+
return value.length > 0 && value.length <= 50;
|
|
677
|
+
}
|
|
678
|
+
// ─── US state normalization ───────────────────────────────────────────────────
|
|
679
|
+
// Mirrors checkout's convertStateToAbbreviation() so the same input variants work.
|
|
680
|
+
const US_STATES = {
|
|
681
|
+
alabama: 'AL', alaska: 'AK', arizona: 'AZ', arkansas: 'AR',
|
|
682
|
+
california: 'CA', colorado: 'CO', connecticut: 'CT', delaware: 'DE',
|
|
683
|
+
'district of columbia': 'DC', florida: 'FL', georgia: 'GA', hawaii: 'HI',
|
|
684
|
+
idaho: 'ID', illinois: 'IL', indiana: 'IN', iowa: 'IA', kansas: 'KS',
|
|
685
|
+
kentucky: 'KY', louisiana: 'LA', maine: 'ME', maryland: 'MD',
|
|
686
|
+
massachusetts: 'MA', michigan: 'MI', minnesota: 'MN', mississippi: 'MS',
|
|
687
|
+
missouri: 'MO', montana: 'MT', nebraska: 'NE', nevada: 'NV',
|
|
688
|
+
'new hampshire': 'NH', 'new jersey': 'NJ', 'new mexico': 'NM', 'new york': 'NY',
|
|
689
|
+
'north carolina': 'NC', 'north dakota': 'ND', ohio: 'OH', oklahoma: 'OK',
|
|
690
|
+
oregon: 'OR', pennsylvania: 'PA', 'rhode island': 'RI', 'south carolina': 'SC',
|
|
691
|
+
'south dakota': 'SD', tennessee: 'TN', texas: 'TX', utah: 'UT',
|
|
692
|
+
vermont: 'VT', virginia: 'VA', washington: 'WA', 'west virginia': 'WV',
|
|
693
|
+
wisconsin: 'WI', wyoming: 'WY',
|
|
694
|
+
// US territories
|
|
695
|
+
'puerto rico': 'PR', guam: 'GU', 'virgin islands': 'VI',
|
|
696
|
+
'us virgin islands': 'VI', 'u.s. virgin islands': 'VI',
|
|
697
|
+
'american samoa': 'AS', 'northern mariana islands': 'MP',
|
|
698
|
+
'commonwealth of the northern mariana islands': 'MP',
|
|
699
|
+
// Military / diplomatic addresses
|
|
700
|
+
'armed forces europe': 'AE', 'armed forces pacific': 'AP',
|
|
701
|
+
'armed forces americas': 'AA',
|
|
702
|
+
};
|
|
703
|
+
const US_ABBREVS = new Set(Object.values(US_STATES));
|
|
704
|
+
const CA_PROVINCES = {
|
|
705
|
+
alberta: 'AB', 'british columbia': 'BC', manitoba: 'MB', 'new brunswick': 'NB',
|
|
706
|
+
'newfoundland and labrador': 'NL', 'nova scotia': 'NS', ontario: 'ON',
|
|
707
|
+
'prince edward island': 'PE', quebec: 'QC', saskatchewan: 'SK',
|
|
708
|
+
'northwest territories': 'NT', nunavut: 'NU', yukon: 'YT',
|
|
709
|
+
};
|
|
710
|
+
const CA_ABBREVS = new Set(Object.values(CA_PROVINCES));
|
|
711
|
+
/**
|
|
712
|
+
* Converts a full US state or Canadian province name to its 2-letter abbreviation.
|
|
713
|
+
* If already a valid abbreviation (case-insensitive), returns it uppercased.
|
|
714
|
+
* For non-US/CA countries, returns the input uppercased unchanged.
|
|
715
|
+
*
|
|
716
|
+
* Matches checkout's convertStateToAbbreviation() behaviour exactly.
|
|
717
|
+
*/
|
|
718
|
+
function normalizeState(state, country) {
|
|
719
|
+
var _a, _b;
|
|
720
|
+
const upper = state.trim().toUpperCase();
|
|
721
|
+
const lower = state.trim().toLowerCase();
|
|
722
|
+
if (country === 'US') {
|
|
723
|
+
if (US_ABBREVS.has(upper))
|
|
724
|
+
return upper;
|
|
725
|
+
return (_a = US_STATES[lower]) !== null && _a !== void 0 ? _a : upper;
|
|
726
|
+
}
|
|
727
|
+
if (country === 'CA') {
|
|
728
|
+
if (CA_ABBREVS.has(upper))
|
|
729
|
+
return upper;
|
|
730
|
+
return (_b = CA_PROVINCES[lower]) !== null && _b !== void 0 ? _b : upper;
|
|
731
|
+
}
|
|
732
|
+
return upper;
|
|
733
|
+
}
|
|
734
|
+
// ─── Postal code validation ───────────────────────────────────────────────────
|
|
735
|
+
const POSTAL_PATTERNS = {
|
|
736
|
+
US: /^\d{5}(-?\d{4})?$/, // 5-digit or ZIP+4 (with or without hyphen)
|
|
737
|
+
CA: /^[A-Za-z]\d[A-Za-z]\s?\d[A-Za-z]\d$/, // A1A 1A1
|
|
738
|
+
GB: /^[A-Za-z]{1,2}\d[A-Za-z\d]?\s?\d[A-Za-z]{2}$/,
|
|
739
|
+
DE: /^\d{5}$/,
|
|
740
|
+
FR: /^\d{5}$/,
|
|
741
|
+
ES: /^\d{5}$/,
|
|
742
|
+
IT: /^\d{5}$/,
|
|
743
|
+
AU: /^\d{4}$/,
|
|
744
|
+
NL: /^\d{4}\s?[A-Za-z]{2}$/,
|
|
745
|
+
BR: /^\d{5}-?\d{3}$/,
|
|
746
|
+
JP: /^\d{3}-?\d{4}$/,
|
|
747
|
+
IN: /^\d{6}$/,
|
|
748
|
+
};
|
|
749
|
+
/**
|
|
750
|
+
* Validates a postal/ZIP code against country-specific format rules.
|
|
751
|
+
* For countries without a specific pattern, falls back to generic 1–50 char check.
|
|
752
|
+
*/
|
|
753
|
+
function validatePostalCode(zip, country) {
|
|
754
|
+
if (!zip || zip.length === 0)
|
|
755
|
+
return { valid: false, error: 'Postal code is required' };
|
|
756
|
+
if (zip.length > 50)
|
|
757
|
+
return { valid: false, error: 'Postal code must be 50 characters or fewer' };
|
|
758
|
+
const pattern = POSTAL_PATTERNS[country.toUpperCase()];
|
|
759
|
+
if (pattern && !pattern.test(zip)) {
|
|
760
|
+
return { valid: false, error: `Invalid postal code format for ${country.toUpperCase()}` };
|
|
761
|
+
}
|
|
762
|
+
return { valid: true };
|
|
763
|
+
}
|
|
764
|
+
/**
|
|
765
|
+
* Validates and normalizes billing details against the Ozura cardSale API schema.
|
|
766
|
+
*
|
|
767
|
+
* Rules applied (same as checkout's pre-flight validation in page.tsx):
|
|
768
|
+
* - firstName, lastName: required, 1–50 chars
|
|
769
|
+
* - email: optional; if provided, must be valid format and ≤50 chars
|
|
770
|
+
* - phone: optional; if provided, must be E.164 and ≤50 chars
|
|
771
|
+
* - address fields: if address is provided, line1/city/state/zip/country are
|
|
772
|
+
* required (1–50 chars each); line2 is optional and omitted from normalized
|
|
773
|
+
* output if blank (cardSale schema: minLength 1 if present)
|
|
774
|
+
* - state: normalized to 2-letter abbreviation for US and CA
|
|
775
|
+
*/
|
|
776
|
+
function validateBilling(billing) {
|
|
777
|
+
var _a, _b, _c, _d, _e, _f, _g, _h, _j, _k, _l, _m, _o, _p, _q, _r, _s, _t, _u, _v;
|
|
778
|
+
const errors = [];
|
|
779
|
+
const firstName = (_b = (_a = billing.firstName) === null || _a === void 0 ? void 0 : _a.trim()) !== null && _b !== void 0 ? _b : '';
|
|
780
|
+
const lastName = (_d = (_c = billing.lastName) === null || _c === void 0 ? void 0 : _c.trim()) !== null && _d !== void 0 ? _d : '';
|
|
781
|
+
const email = (_f = (_e = billing.email) === null || _e === void 0 ? void 0 : _e.trim()) !== null && _f !== void 0 ? _f : '';
|
|
782
|
+
const phone = (_h = (_g = billing.phone) === null || _g === void 0 ? void 0 : _g.trim()) !== null && _h !== void 0 ? _h : '';
|
|
783
|
+
if (!isValidBillingField(firstName)) {
|
|
784
|
+
errors.push('billing.firstName must be 1–50 characters');
|
|
785
|
+
}
|
|
786
|
+
if (!isValidBillingField(lastName)) {
|
|
787
|
+
errors.push('billing.lastName must be 1–50 characters');
|
|
788
|
+
}
|
|
789
|
+
if (email && !validateEmail(email)) {
|
|
790
|
+
errors.push('billing.email must be a valid address (max 50 characters)');
|
|
791
|
+
}
|
|
792
|
+
if (phone && !validateE164Phone(phone)) {
|
|
793
|
+
errors.push('billing.phone must be E.164 format, e.g. "+15551234567" (max 50 characters)');
|
|
794
|
+
}
|
|
795
|
+
let normalizedAddress;
|
|
796
|
+
if (billing.address) {
|
|
797
|
+
const a = billing.address;
|
|
798
|
+
const country = (_k = (_j = a.country) === null || _j === void 0 ? void 0 : _j.trim().toUpperCase()) !== null && _k !== void 0 ? _k : '';
|
|
799
|
+
const line1 = (_m = (_l = a.line1) === null || _l === void 0 ? void 0 : _l.trim()) !== null && _m !== void 0 ? _m : '';
|
|
800
|
+
const line2 = (_p = (_o = a.line2) === null || _o === void 0 ? void 0 : _o.trim()) !== null && _p !== void 0 ? _p : '';
|
|
801
|
+
const city = (_r = (_q = a.city) === null || _q === void 0 ? void 0 : _q.trim()) !== null && _r !== void 0 ? _r : '';
|
|
802
|
+
const zip = (_t = (_s = a.zip) === null || _s === void 0 ? void 0 : _s.trim()) !== null && _t !== void 0 ? _t : '';
|
|
803
|
+
const state = normalizeState((_v = (_u = a.state) === null || _u === void 0 ? void 0 : _u.trim()) !== null && _v !== void 0 ? _v : '', country);
|
|
804
|
+
if (!isValidBillingField(line1))
|
|
805
|
+
errors.push('billing.address.line1 must be 1–50 characters');
|
|
806
|
+
if (line2 && !isValidBillingField(line2))
|
|
807
|
+
errors.push('billing.address.line2 must be 1–50 characters if provided');
|
|
808
|
+
if (!isValidBillingField(city))
|
|
809
|
+
errors.push('billing.address.city must be 1–50 characters');
|
|
810
|
+
if (!isValidBillingField(state)) {
|
|
811
|
+
errors.push('billing.address.state must be 1–50 characters');
|
|
812
|
+
}
|
|
813
|
+
else if (country === 'US' && !US_ABBREVS.has(state)) {
|
|
814
|
+
errors.push(`billing.address.state "${state}" is not a recognized US state or territory abbreviation (e.g. "CA", "NY", "PR")`);
|
|
815
|
+
}
|
|
816
|
+
else if (country === 'CA' && !CA_ABBREVS.has(state)) {
|
|
817
|
+
errors.push(`billing.address.state "${state}" is not a recognized Canadian province or territory abbreviation (e.g. "ON", "BC", "QC")`);
|
|
818
|
+
}
|
|
819
|
+
// cardSale backend uses strict enum validation on country — must be exactly 2 uppercase letters
|
|
820
|
+
if (!/^[A-Z]{2}$/.test(country)) {
|
|
821
|
+
errors.push('billing.address.country must be a 2-letter ISO 3166-1 alpha-2 code (e.g. "US", "CA", "GB")');
|
|
822
|
+
}
|
|
823
|
+
if (!isValidBillingField(zip)) {
|
|
824
|
+
errors.push('billing.address.zip must be 1–50 characters');
|
|
825
|
+
}
|
|
826
|
+
else if (/^[A-Z]{2}$/.test(country)) {
|
|
827
|
+
const postalResult = validatePostalCode(zip, country);
|
|
828
|
+
if (!postalResult.valid) {
|
|
829
|
+
errors.push(`billing.address.zip: ${postalResult.error}`);
|
|
830
|
+
}
|
|
831
|
+
}
|
|
832
|
+
normalizedAddress = Object.assign(Object.assign({ line1 }, (line2 ? { line2 } : {})), { city,
|
|
833
|
+
state,
|
|
834
|
+
zip,
|
|
835
|
+
country });
|
|
836
|
+
}
|
|
837
|
+
const normalized = Object.assign(Object.assign(Object.assign({ firstName,
|
|
838
|
+
lastName }, (email ? { email } : {})), (phone ? { phone } : {})), (normalizedAddress ? { address: normalizedAddress } : {}));
|
|
839
|
+
return { valid: errors.length === 0, errors, normalized };
|
|
840
|
+
}
|
|
841
|
+
|
|
842
|
+
/**
|
|
843
|
+
* Shared postMessage protocol constants.
|
|
844
|
+
*
|
|
845
|
+
* PROTOCOL_VERSION must be incremented any time a breaking change is made to
|
|
846
|
+
* the postMessage message shape (new required fields, renamed types, removed
|
|
847
|
+
* fields, changed semantics). The SDK reads this value from OZ_FRAME_READY
|
|
848
|
+
* messages and warns when the frame and SDK are out of sync.
|
|
849
|
+
*
|
|
850
|
+
* Non-breaking additions (new optional fields, new message types that old
|
|
851
|
+
* frames can safely ignore) do NOT require a version bump.
|
|
852
|
+
*/
|
|
853
|
+
const PROTOCOL_VERSION = 1;
|
|
854
|
+
|
|
855
|
+
/**
|
|
856
|
+
* Creates a `getSessionKey` callback for `OzVault.create()` and `<OzElements>`.
|
|
857
|
+
*
|
|
858
|
+
* This is the recommended way to wire the SDK to your backend session endpoint.
|
|
859
|
+
* If you don't need custom headers or auth logic, pass `sessionUrl` directly to
|
|
860
|
+
* `OzVault.create()` or `<OzElements>` — it calls this helper internally.
|
|
861
|
+
*
|
|
862
|
+
* The callback POSTs `{ sessionId }` to `url` and reads `sessionKey` (or the
|
|
863
|
+
* legacy `waxKey`) from the JSON response, so it is compatible with both the
|
|
864
|
+
* new `createSessionMiddleware` and the old `createMintWaxMiddleware` backends.
|
|
865
|
+
*
|
|
866
|
+
* Each call enforces a **10-second timeout**. On pure network failures
|
|
867
|
+
* (offline, DNS, connection refused) the request is retried **once after 750ms**.
|
|
868
|
+
* HTTP 4xx/5xx errors are never retried — they indicate misconfiguration.
|
|
869
|
+
*
|
|
870
|
+
* @param url - Absolute or relative URL of your session endpoint, e.g. `'/api/oz-session'`.
|
|
871
|
+
*
|
|
872
|
+
* @example
|
|
873
|
+
* // Simplest — just pass sessionUrl, no need to call this directly
|
|
874
|
+
* const vault = await OzVault.create({ pubKey: 'pk_prod_...', sessionUrl: '/api/oz-session' });
|
|
875
|
+
*
|
|
876
|
+
* @example
|
|
877
|
+
* // Manual — use when you need custom headers
|
|
878
|
+
* const vault = await OzVault.create({
|
|
879
|
+
* pubKey: 'pk_prod_...',
|
|
880
|
+
* getSessionKey: createSessionFetcher('/api/oz-session'),
|
|
881
|
+
* });
|
|
882
|
+
*/
|
|
883
|
+
function createSessionFetcher(url) {
|
|
884
|
+
const TIMEOUT_MS = 10000;
|
|
885
|
+
// Each attempt gets its own AbortController so a timeout on attempt 1 does
|
|
886
|
+
// not bleed into the retry.
|
|
887
|
+
const attemptFetch = (sessionId) => {
|
|
888
|
+
const controller = new AbortController();
|
|
889
|
+
const timer = setTimeout(() => controller.abort(), TIMEOUT_MS);
|
|
890
|
+
return fetch(url, {
|
|
891
|
+
method: 'POST',
|
|
892
|
+
headers: { 'Content-Type': 'application/json' },
|
|
893
|
+
body: JSON.stringify({ sessionId }),
|
|
894
|
+
signal: controller.signal,
|
|
895
|
+
}).finally(() => clearTimeout(timer));
|
|
896
|
+
};
|
|
897
|
+
return async (sessionId) => {
|
|
898
|
+
let res;
|
|
899
|
+
try {
|
|
900
|
+
res = await attemptFetch(sessionId);
|
|
901
|
+
}
|
|
902
|
+
catch (firstErr) {
|
|
903
|
+
// Timeout/abort — don't retry, we already waited the full duration.
|
|
904
|
+
if (firstErr instanceof Error && (firstErr.name === 'AbortError' || firstErr.name === 'TimeoutError')) {
|
|
905
|
+
throw new OzError(`Session endpoint timed out after ${TIMEOUT_MS / 1000}s (${url})`, undefined, 'timeout');
|
|
906
|
+
}
|
|
907
|
+
// Pure network error — retry once after a short pause.
|
|
908
|
+
await new Promise(resolve => setTimeout(resolve, 750));
|
|
909
|
+
try {
|
|
910
|
+
res = await attemptFetch(sessionId);
|
|
911
|
+
}
|
|
912
|
+
catch (retryErr) {
|
|
913
|
+
const msg = retryErr instanceof Error ? retryErr.message : 'Network error';
|
|
914
|
+
throw new OzError(`Could not reach session endpoint (${url}): ${msg}`, undefined, 'network');
|
|
915
|
+
}
|
|
916
|
+
}
|
|
917
|
+
// Parse JSON separately from the ok-check so that a non-JSON error body
|
|
918
|
+
// (HTML error page, WAF block, CDN 503) produces the right error code.
|
|
919
|
+
// Previously res.json() was attempted before res.ok was checked; a parse
|
|
920
|
+
// failure on a 5xx HTML body would fall through as {} and produce a
|
|
921
|
+
// misleading 'validation' code when the real cause is a server/network issue.
|
|
922
|
+
let data = {};
|
|
923
|
+
try {
|
|
924
|
+
data = await res.json();
|
|
925
|
+
}
|
|
926
|
+
catch (_a) {
|
|
927
|
+
if (!res.ok) {
|
|
928
|
+
throw new OzError(`Session endpoint returned HTTP ${res.status} with a non-JSON body`, undefined, res.status >= 500 ? 'server' : res.status === 401 || res.status === 403 ? 'auth' : 'validation');
|
|
929
|
+
}
|
|
930
|
+
// HTTP 200 but body isn't JSON — this is a misconfigured session endpoint.
|
|
931
|
+
throw new OzError('Session endpoint returned HTTP 200 but the response body is not valid JSON. Check your /api/oz-session implementation.', undefined, 'validation');
|
|
932
|
+
}
|
|
933
|
+
if (!res.ok) {
|
|
934
|
+
throw new OzError(typeof data.error === 'string' && data.error
|
|
935
|
+
? data.error
|
|
936
|
+
: `Session endpoint returned HTTP ${res.status}`, undefined, res.status >= 500 ? 'server' : res.status === 401 || res.status === 403 ? 'auth' : 'validation');
|
|
937
|
+
}
|
|
938
|
+
// Accept both new `sessionKey` and legacy `waxKey` for backward compatibility
|
|
939
|
+
// with backends that haven't migrated to createSessionMiddleware yet.
|
|
940
|
+
const key = (typeof data.sessionKey === 'string' ? data.sessionKey : '') ||
|
|
941
|
+
(typeof data.waxKey === 'string' ? data.waxKey : '');
|
|
942
|
+
if (!key.trim()) {
|
|
943
|
+
throw new OzError('Session endpoint response is missing sessionKey. Check your /api/oz-session implementation.', undefined, 'validation');
|
|
944
|
+
}
|
|
945
|
+
return key;
|
|
946
|
+
};
|
|
947
|
+
}
|
|
948
|
+
|
|
949
|
+
function isCardMetadata(v) {
|
|
950
|
+
if (!v || typeof v !== 'object')
|
|
951
|
+
return false;
|
|
952
|
+
const r = v;
|
|
953
|
+
return (typeof r.last4 === 'string' &&
|
|
954
|
+
typeof r.brand === 'string' &&
|
|
955
|
+
typeof r.expMonth === 'string' &&
|
|
956
|
+
typeof r.expYear === 'string');
|
|
957
|
+
}
|
|
958
|
+
function isBankAccountMetadata(v) {
|
|
959
|
+
if (!v || typeof v !== 'object')
|
|
960
|
+
return false;
|
|
961
|
+
const r = v;
|
|
962
|
+
return typeof r.last4 === 'string' && typeof r.routingNumberLast4 === 'string';
|
|
963
|
+
}
|
|
964
|
+
const DEFAULT_FRAME_BASE_URL = "https://elements.ozura.com";
|
|
965
|
+
/**
|
|
966
|
+
* The main entry point for OzElements. Creates and manages iframe-based
|
|
967
|
+
* card input elements that keep raw card data isolated from the merchant page.
|
|
968
|
+
*
|
|
969
|
+
* Use the static `OzVault.create()` factory — do not call `new OzVault()` directly.
|
|
970
|
+
*
|
|
971
|
+
* @example
|
|
972
|
+
* // Recommended — pass sessionUrl and let the SDK call your backend automatically
|
|
973
|
+
* const vault = await OzVault.create({
|
|
974
|
+
* pubKey: 'pk_prod_...', // or 'pk_test_...' for test mode
|
|
975
|
+
* sessionUrl: '/api/oz-session', // backend endpoint that calls ozura.createSession()
|
|
976
|
+
* });
|
|
977
|
+
* const cardNum = vault.createElement('cardNumber');
|
|
978
|
+
* cardNum.mount('#card-number');
|
|
979
|
+
* const { token, cvcSession } = await vault.createToken({
|
|
980
|
+
* billing: { firstName: 'Jane', lastName: 'Doe' },
|
|
981
|
+
* });
|
|
982
|
+
*/
|
|
983
|
+
class OzVault {
|
|
984
|
+
/**
|
|
985
|
+
* Internal constructor — use `OzVault.create()` instead.
|
|
986
|
+
* The constructor mounts the tokenizer iframe immediately so it can start
|
|
987
|
+
* loading in parallel while `fetchWaxKey` is being awaited.
|
|
988
|
+
* @internal
|
|
989
|
+
*/
|
|
990
|
+
constructor(options, waxKey, tokenizationSessionId) {
|
|
991
|
+
var _a, _b, _c, _d, _e;
|
|
992
|
+
this.elements = new Map();
|
|
993
|
+
this.elementsByType = new Map();
|
|
994
|
+
this.bankElementsByType = new Map();
|
|
995
|
+
this.tokenizeResolvers = new Map();
|
|
996
|
+
this.bankTokenizeResolvers = new Map();
|
|
997
|
+
// Track completion state per element for auto-advance (only fire on transition)
|
|
998
|
+
this.completionState = new Map();
|
|
999
|
+
this.tokenizerFrame = null;
|
|
1000
|
+
this.tokenizerWindow = null;
|
|
1001
|
+
this.tokenizerReady = false;
|
|
1002
|
+
this._tokenizing = null;
|
|
1003
|
+
this._destroyed = false;
|
|
1004
|
+
// Incremented every time reset() cancels an active tokenization so that
|
|
1005
|
+
// any in-flight wax-key refresh retry can detect it was superseded.
|
|
1006
|
+
this._resetCount = 0;
|
|
1007
|
+
// Tracks successful tokenizations against the per-key call budget so the SDK
|
|
1008
|
+
// can proactively refresh the wax key after it has been consumed rather than
|
|
1009
|
+
// waiting for the next createToken() call to fail.
|
|
1010
|
+
this._tokenizeSuccessCount = 0;
|
|
1011
|
+
this._pendingMount = null;
|
|
1012
|
+
this._storedFetchWaxKey = null;
|
|
1013
|
+
this._waxRefreshing = null;
|
|
1014
|
+
this.loadErrorTimeoutId = null;
|
|
1015
|
+
// Proactive wax refresh on visibility restore after long idle
|
|
1016
|
+
this._hiddenAt = null;
|
|
1017
|
+
this.waxKey = waxKey;
|
|
1018
|
+
this.tokenizationSessionId = tokenizationSessionId;
|
|
1019
|
+
this.pubKey = options.pubKey;
|
|
1020
|
+
this.frameBaseUrl = options.frameBaseUrl || DEFAULT_FRAME_BASE_URL;
|
|
1021
|
+
// Validate immediately after assignment
|
|
1022
|
+
if (!this.frameBaseUrl.startsWith('https://') &&
|
|
1023
|
+
!this.frameBaseUrl.startsWith('http://localhost')) {
|
|
1024
|
+
throw new OzError('frameBaseUrl must use HTTPS (http://localhost is allowed for local development)', undefined, 'config');
|
|
1025
|
+
}
|
|
1026
|
+
this.frameOrigin = new URL(this.frameBaseUrl).origin;
|
|
1027
|
+
this.fonts = (_a = options.fonts) !== null && _a !== void 0 ? _a : [];
|
|
1028
|
+
this.resolvedAppearance = resolveAppearance(options.appearance);
|
|
1029
|
+
this.vaultId = `vault-${uuid()}`;
|
|
1030
|
+
// sessionLimit takes precedence over legacy maxTokenizeCalls.
|
|
1031
|
+
// null means unlimited — use Infinity so the ">=" check never triggers.
|
|
1032
|
+
const rawLimit = options.sessionLimit !== undefined
|
|
1033
|
+
? options.sessionLimit
|
|
1034
|
+
: ((_b = options.maxTokenizeCalls) !== null && _b !== void 0 ? _b : 3);
|
|
1035
|
+
this._maxTokenizeCalls = rawLimit === null ? Infinity : rawLimit;
|
|
1036
|
+
this._debug = (_c = options.debug) !== null && _c !== void 0 ? _c : false;
|
|
1037
|
+
this.boundHandleMessage = this.handleMessage.bind(this);
|
|
1038
|
+
window.addEventListener('message', this.boundHandleMessage);
|
|
1039
|
+
this.boundHandleVisibility = this.handleVisibilityChange.bind(this);
|
|
1040
|
+
document.addEventListener('visibilitychange', this.boundHandleVisibility);
|
|
1041
|
+
this.mountTokenizerFrame();
|
|
1042
|
+
if (options.onLoadError) {
|
|
1043
|
+
const timeout = (_d = options.loadTimeoutMs) !== null && _d !== void 0 ? _d : 10000;
|
|
1044
|
+
this.loadErrorTimeoutId = setTimeout(() => {
|
|
1045
|
+
this.loadErrorTimeoutId = null;
|
|
1046
|
+
if (!this._destroyed && !this.tokenizerReady) {
|
|
1047
|
+
options.onLoadError();
|
|
1048
|
+
}
|
|
1049
|
+
}, timeout);
|
|
1050
|
+
}
|
|
1051
|
+
// onSessionRefresh takes precedence over legacy onWaxRefresh
|
|
1052
|
+
this._onWaxRefresh = (_e = options.onSessionRefresh) !== null && _e !== void 0 ? _e : options.onWaxRefresh;
|
|
1053
|
+
this._onReady = options.onReady;
|
|
1054
|
+
this.log('vault created', { vaultId: this.vaultId, frameBaseUrl: this.frameBaseUrl, maxTokenizeCalls: this._maxTokenizeCalls });
|
|
1055
|
+
}
|
|
1056
|
+
/**
|
|
1057
|
+
* Creates and returns a ready `OzVault` instance.
|
|
1058
|
+
*
|
|
1059
|
+
* Internally this:
|
|
1060
|
+
* 1. Generates a session UUID.
|
|
1061
|
+
* 2. Starts loading the hidden tokenizer iframe immediately.
|
|
1062
|
+
* 3. Fetches a session key from your backend concurrently — either via
|
|
1063
|
+
* `sessionUrl` (simplest), `getSessionKey` (custom headers/auth), or the
|
|
1064
|
+
* deprecated `fetchWaxKey` callback.
|
|
1065
|
+
* 4. Resolves with the vault instance once the session key is stored. The iframe
|
|
1066
|
+
* has been loading the whole time, so `isReady` may already be true or
|
|
1067
|
+
* will fire shortly after.
|
|
1068
|
+
*
|
|
1069
|
+
* The returned vault is ready to create elements immediately. `createToken()`
|
|
1070
|
+
* additionally requires `vault.isReady` (tokenizer iframe loaded).
|
|
1071
|
+
*
|
|
1072
|
+
* @throws {OzError} if the session fetch fails, times out, or returns an empty string.
|
|
1073
|
+
*/
|
|
1074
|
+
static async create(options, signal) {
|
|
1075
|
+
if (!options.pubKey || !options.pubKey.trim()) {
|
|
1076
|
+
throw new OzError('pubKey is required in options. Obtain your public key from the Ozura admin.');
|
|
1077
|
+
}
|
|
1078
|
+
// Normalize the session callback. Priority: sessionUrl > getSessionKey > fetchWaxKey (deprecated).
|
|
1079
|
+
// This allows merchants to use the clean new API without touching legacy code.
|
|
1080
|
+
let resolvedFetchKey;
|
|
1081
|
+
if (options.sessionUrl) {
|
|
1082
|
+
resolvedFetchKey = createSessionFetcher(options.sessionUrl);
|
|
1083
|
+
}
|
|
1084
|
+
else if (typeof options.getSessionKey === 'function') {
|
|
1085
|
+
resolvedFetchKey = options.getSessionKey;
|
|
1086
|
+
}
|
|
1087
|
+
else if (typeof options.fetchWaxKey === 'function') {
|
|
1088
|
+
resolvedFetchKey = options.fetchWaxKey;
|
|
1089
|
+
}
|
|
1090
|
+
else {
|
|
1091
|
+
throw new OzError('A session URL or callback is required. Pass sessionUrl, getSessionKey, or fetchWaxKey to OzVault.create().');
|
|
1092
|
+
}
|
|
1093
|
+
const tokenizationSessionId = uuid();
|
|
1094
|
+
// Construct the vault immediately — this mounts the tokenizer iframe so it
|
|
1095
|
+
// starts loading while the session fetch is in flight.
|
|
1096
|
+
const vault = new OzVault(options, '', tokenizationSessionId);
|
|
1097
|
+
// If the caller provides an AbortSignal (e.g. React useEffect cleanup),
|
|
1098
|
+
// destroy the vault immediately on abort so the tokenizer iframe and message
|
|
1099
|
+
// listener are removed synchronously rather than waiting for the session fetch
|
|
1100
|
+
// to settle. This eliminates the brief double-iframe window in React StrictMode.
|
|
1101
|
+
const onAbort = () => vault.destroy();
|
|
1102
|
+
signal === null || signal === void 0 ? void 0 : signal.addEventListener('abort', onAbort, { once: true });
|
|
1103
|
+
let waxKey;
|
|
1104
|
+
try {
|
|
1105
|
+
waxKey = await resolvedFetchKey(tokenizationSessionId);
|
|
1106
|
+
}
|
|
1107
|
+
catch (err) {
|
|
1108
|
+
signal === null || signal === void 0 ? void 0 : signal.removeEventListener('abort', onAbort);
|
|
1109
|
+
vault.destroy();
|
|
1110
|
+
if (signal === null || signal === void 0 ? void 0 : signal.aborted)
|
|
1111
|
+
throw new OzError('OzVault.create() was cancelled.');
|
|
1112
|
+
// Preserve errorCode/retryable from OzError (e.g. timeout/network from createSessionFetcher)
|
|
1113
|
+
// so callers can distinguish transient failures from config errors.
|
|
1114
|
+
const originalCode = err instanceof OzError ? err.errorCode : undefined;
|
|
1115
|
+
const msg = err instanceof Error ? err.message : 'Unknown error';
|
|
1116
|
+
throw new OzError(`Session fetch threw an error: ${msg}`, undefined, originalCode);
|
|
1117
|
+
}
|
|
1118
|
+
signal === null || signal === void 0 ? void 0 : signal.removeEventListener('abort', onAbort);
|
|
1119
|
+
if (signal === null || signal === void 0 ? void 0 : signal.aborted) {
|
|
1120
|
+
vault.destroy();
|
|
1121
|
+
throw new OzError('OzVault.create() was cancelled.');
|
|
1122
|
+
}
|
|
1123
|
+
if (typeof waxKey !== 'string' || !waxKey.trim()) {
|
|
1124
|
+
vault.destroy();
|
|
1125
|
+
throw new OzError('Session fetch returned an empty key. Check your session endpoint response — it must return { sessionKey: "..." }.');
|
|
1126
|
+
}
|
|
1127
|
+
// Static methods can access private fields of instances of the same class.
|
|
1128
|
+
vault.waxKey = waxKey;
|
|
1129
|
+
vault._storedFetchWaxKey = resolvedFetchKey;
|
|
1130
|
+
// If the tokenizer iframe fired OZ_FRAME_READY before fetchWaxKey resolved,
|
|
1131
|
+
// the OZ_INIT sent at that point had an empty waxKey. Send a follow-up now
|
|
1132
|
+
// so the tokenizer has the key stored before any createToken() call.
|
|
1133
|
+
if (vault.tokenizerReady) {
|
|
1134
|
+
vault.sendToTokenizer({ type: 'OZ_INIT', frameId: '__tokenizer__', waxKey, debug: vault._debug });
|
|
1135
|
+
}
|
|
1136
|
+
vault.log('wax key received — vault ready');
|
|
1137
|
+
return vault;
|
|
1138
|
+
}
|
|
1139
|
+
/**
|
|
1140
|
+
* True once the hidden tokenizer iframe has loaded and signalled ready.
|
|
1141
|
+
* Use this to gate the pay button when building custom UIs without React.
|
|
1142
|
+
* React consumers should use the `ready` value returned by `useOzElements()`.
|
|
1143
|
+
*
|
|
1144
|
+
* Once `true`, remains `true` for the lifetime of this vault instance.
|
|
1145
|
+
* It only reverts to `false` after `vault.destroy()` is called, at which
|
|
1146
|
+
* point the vault is unusable and a new instance must be created.
|
|
1147
|
+
*
|
|
1148
|
+
* @remarks
|
|
1149
|
+
* This tracks **tokenizer readiness only** — it says nothing about whether
|
|
1150
|
+
* the individual element iframes (card number, CVV, etc.) have loaded.
|
|
1151
|
+
* A vault can be `isReady === true` while elements are still mounting.
|
|
1152
|
+
* To gate a submit button correctly in vanilla JS, wait for every element's
|
|
1153
|
+
* `'ready'` event in addition to this flag. In React, use the `ready` value
|
|
1154
|
+
* from `useOzElements()` instead, which combines both checks automatically.
|
|
1155
|
+
*/
|
|
1156
|
+
get isReady() {
|
|
1157
|
+
return this.tokenizerReady;
|
|
1158
|
+
}
|
|
1159
|
+
/**
|
|
1160
|
+
* Number of successful tokenize calls made against the current wax key.
|
|
1161
|
+
*
|
|
1162
|
+
* Resets to `0` each time the wax key is refreshed (proactively or reactively).
|
|
1163
|
+
* Useful in vanilla JS integrations to display "attempts remaining" UI.
|
|
1164
|
+
* In React, use `tokenizeCount` from `useOzElements()` instead.
|
|
1165
|
+
*
|
|
1166
|
+
* @example
|
|
1167
|
+
* const remaining = 3 - vault.tokenizeCount;
|
|
1168
|
+
* payButton.textContent = remaining > 0 ? `Pay (${remaining} attempts left)` : 'Pay';
|
|
1169
|
+
*/
|
|
1170
|
+
get tokenizeCount() {
|
|
1171
|
+
return this._tokenizeSuccessCount;
|
|
1172
|
+
}
|
|
1173
|
+
/**
|
|
1174
|
+
* Creates a new OzElement of the given type. Call `.mount(selector)` on the
|
|
1175
|
+
* returned element to attach it to the DOM.
|
|
1176
|
+
*/
|
|
1177
|
+
createElement(type, options = {}) {
|
|
1178
|
+
return this.createElementInto(this.elementsByType, type, options);
|
|
1179
|
+
}
|
|
1180
|
+
/** Returns the existing element of the given type, or null if none has been created. */
|
|
1181
|
+
getElement(type) {
|
|
1182
|
+
var _a;
|
|
1183
|
+
return (_a = this.elementsByType.get(type)) !== null && _a !== void 0 ? _a : null;
|
|
1184
|
+
}
|
|
1185
|
+
/**
|
|
1186
|
+
* Creates a bank account input element (accountNumber or routingNumber).
|
|
1187
|
+
* Call `.mount(selector)` on the returned element to attach it to the DOM.
|
|
1188
|
+
*
|
|
1189
|
+
* @example
|
|
1190
|
+
* const accountEl = vault.createBankElement('accountNumber');
|
|
1191
|
+
* const routingEl = vault.createBankElement('routingNumber');
|
|
1192
|
+
* accountEl.mount('#account-number');
|
|
1193
|
+
* routingEl.mount('#routing-number');
|
|
1194
|
+
*/
|
|
1195
|
+
createBankElement(type, options = {}) {
|
|
1196
|
+
return this.createElementInto(this.bankElementsByType, type, options);
|
|
1197
|
+
}
|
|
1198
|
+
/** Returns the existing bank element of the given type, or null if none has been created. */
|
|
1199
|
+
getBankElement(type) {
|
|
1200
|
+
var _a;
|
|
1201
|
+
return (_a = this.bankElementsByType.get(type)) !== null && _a !== void 0 ? _a : null;
|
|
1202
|
+
}
|
|
1203
|
+
createElementInto(typeMap, type, options) {
|
|
1204
|
+
if (this._destroyed) {
|
|
1205
|
+
throw new OzError('Cannot create elements on a destroyed vault. Call await OzVault.create() to get a new instance.');
|
|
1206
|
+
}
|
|
1207
|
+
if (this._tokenizing) {
|
|
1208
|
+
throw new OzError('Cannot create or replace elements while a tokenization is in progress. Wait for the active createToken() / createBankToken() call to settle first.');
|
|
1209
|
+
}
|
|
1210
|
+
const existing = typeMap.get(type);
|
|
1211
|
+
if (existing) {
|
|
1212
|
+
this.elements.delete(existing.frameId);
|
|
1213
|
+
this.completionState.delete(existing.frameId);
|
|
1214
|
+
existing.destroy();
|
|
1215
|
+
}
|
|
1216
|
+
const el = new OzElement(type, options, this.vaultId, this.frameBaseUrl, this.fonts, this.resolvedAppearance, () => {
|
|
1217
|
+
// Prune vault-level maps when the element is manually destroyed so they
|
|
1218
|
+
// don't grow unboundedly in SPA scenarios with repeated mount/unmount cycles.
|
|
1219
|
+
this.elements.delete(el.frameId);
|
|
1220
|
+
this.completionState.delete(el.frameId);
|
|
1221
|
+
}, this._debug);
|
|
1222
|
+
this.elements.set(el.frameId, el);
|
|
1223
|
+
typeMap.set(type, el);
|
|
1224
|
+
return el;
|
|
1225
|
+
}
|
|
1226
|
+
/**
|
|
1227
|
+
* Tokenizes mounted bank account elements. Raw account and routing numbers
|
|
1228
|
+
* never leave the Ozura-origin iframes — the tokenizer iframe POSTs directly
|
|
1229
|
+
* to the vault API.
|
|
1230
|
+
*
|
|
1231
|
+
* Returns a token that can be used with any ACH-capable payment processor.
|
|
1232
|
+
*
|
|
1233
|
+
* **Note:** OzuraPay does not currently support bank account payments.
|
|
1234
|
+
* Use this token with your own ACH processor backend.
|
|
1235
|
+
*
|
|
1236
|
+
* @example
|
|
1237
|
+
* const { token, bank } = await vault.createBankToken({
|
|
1238
|
+
* firstName: 'Jane',
|
|
1239
|
+
* lastName: 'Smith',
|
|
1240
|
+
* });
|
|
1241
|
+
*/
|
|
1242
|
+
async createBankToken(options) {
|
|
1243
|
+
var _a, _b;
|
|
1244
|
+
if (this._destroyed) {
|
|
1245
|
+
throw new OzError('Cannot tokenize on a destroyed vault. Call await OzVault.create() to get a new instance.');
|
|
1246
|
+
}
|
|
1247
|
+
if (!this.tokenizerReady) {
|
|
1248
|
+
throw new OzError('Vault not ready. Ensure the page is fully loaded before calling createBankToken.');
|
|
1249
|
+
}
|
|
1250
|
+
if (this._tokenizing) {
|
|
1251
|
+
throw new OzError(this._tokenizing === 'card'
|
|
1252
|
+
? 'A card tokenization is already in progress. Wait for it to complete before calling createBankToken().'
|
|
1253
|
+
: 'A bank tokenization is already in progress. Wait for it to complete before calling createBankToken() again.');
|
|
1254
|
+
}
|
|
1255
|
+
if (!((_a = options.firstName) === null || _a === void 0 ? void 0 : _a.trim())) {
|
|
1256
|
+
throw new OzError('firstName is required for bank account tokenization.');
|
|
1257
|
+
}
|
|
1258
|
+
if (!((_b = options.lastName) === null || _b === void 0 ? void 0 : _b.trim())) {
|
|
1259
|
+
throw new OzError('lastName is required for bank account tokenization.');
|
|
1260
|
+
}
|
|
1261
|
+
if (options.firstName.trim().length > 50) {
|
|
1262
|
+
throw new OzError('firstName must be 50 characters or fewer.');
|
|
1263
|
+
}
|
|
1264
|
+
if (options.lastName.trim().length > 50) {
|
|
1265
|
+
throw new OzError('lastName must be 50 characters or fewer.');
|
|
1266
|
+
}
|
|
1267
|
+
const accountEl = this.bankElementsByType.get('accountNumber');
|
|
1268
|
+
const routingEl = this.bankElementsByType.get('routingNumber');
|
|
1269
|
+
const accountReady = !!accountEl && this.elements.has(accountEl.frameId) && accountEl.isReady;
|
|
1270
|
+
const routingReady = !!routingEl && this.elements.has(routingEl.frameId) && routingEl.isReady;
|
|
1271
|
+
if (!accountReady && !routingReady) {
|
|
1272
|
+
throw new OzError('No bank elements are mounted and ready. Mount accountNumber and routingNumber elements before calling createBankToken.');
|
|
1273
|
+
}
|
|
1274
|
+
if (!accountReady) {
|
|
1275
|
+
throw new OzError('accountNumber element is not mounted or not ready. Mount both accountNumber and routingNumber elements before calling createBankToken.');
|
|
1276
|
+
}
|
|
1277
|
+
if (!routingReady) {
|
|
1278
|
+
throw new OzError('routingNumber element is not mounted or not ready. Mount both accountNumber and routingNumber elements before calling createBankToken.');
|
|
1279
|
+
}
|
|
1280
|
+
const readyBankElements = [accountEl, routingEl];
|
|
1281
|
+
this._tokenizing = 'bank';
|
|
1282
|
+
const requestId = `req-${uuid()}`;
|
|
1283
|
+
this.log('createBankToken() called');
|
|
1284
|
+
return new Promise((resolve, reject) => {
|
|
1285
|
+
const resetCountAtStart = this._resetCount;
|
|
1286
|
+
const cleanup = () => {
|
|
1287
|
+
if (this._resetCount === resetCountAtStart)
|
|
1288
|
+
this._tokenizing = null;
|
|
1289
|
+
};
|
|
1290
|
+
this.bankTokenizeResolvers.set(requestId, {
|
|
1291
|
+
resolve: (v) => { cleanup(); resolve(v); },
|
|
1292
|
+
reject: (e) => { cleanup(); reject(e); },
|
|
1293
|
+
firstName: options.firstName.trim(),
|
|
1294
|
+
lastName: options.lastName.trim(),
|
|
1295
|
+
readyElements: readyBankElements,
|
|
1296
|
+
fieldCount: readyBankElements.length,
|
|
1297
|
+
});
|
|
1298
|
+
try {
|
|
1299
|
+
const bankChannels = readyBankElements.map(() => new MessageChannel());
|
|
1300
|
+
const bankTokenizeStartMs = Date.now();
|
|
1301
|
+
this.sendToTokenizer({
|
|
1302
|
+
type: 'OZ_BANK_TOKENIZE',
|
|
1303
|
+
requestId,
|
|
1304
|
+
tokenizationSessionId: this.tokenizationSessionId,
|
|
1305
|
+
pubKey: this.pubKey,
|
|
1306
|
+
firstName: options.firstName.trim(),
|
|
1307
|
+
lastName: options.lastName.trim(),
|
|
1308
|
+
fieldCount: readyBankElements.length,
|
|
1309
|
+
}, bankChannels.map(ch => ch.port1));
|
|
1310
|
+
this.log('OZ_BANK_TOKENIZE sent', { requestIdPrefix: `${requestId.slice(0, 12)}...`, fieldCount: readyBankElements.length });
|
|
1311
|
+
readyBankElements.forEach((el, i) => {
|
|
1312
|
+
this.log('OZ_BEGIN_COLLECT dispatched', { type: el.type, requestIdPrefix: `${requestId.slice(0, 12)}...` });
|
|
1313
|
+
el.beginCollect(requestId, bankChannels[i].port2);
|
|
1314
|
+
});
|
|
1315
|
+
const bankTimeoutId = setTimeout(() => {
|
|
1316
|
+
if (this.bankTokenizeResolvers.has(requestId)) {
|
|
1317
|
+
this.bankTokenizeResolvers.delete(requestId);
|
|
1318
|
+
this.sendToTokenizer({ type: 'OZ_TOKENIZE_CANCEL', requestId });
|
|
1319
|
+
cleanup();
|
|
1320
|
+
reject(new OzError('Bank tokenization timed out after 30 seconds', undefined, 'timeout'));
|
|
1321
|
+
}
|
|
1322
|
+
}, 30000);
|
|
1323
|
+
const bankPendingEntry = this.bankTokenizeResolvers.get(requestId);
|
|
1324
|
+
if (bankPendingEntry) {
|
|
1325
|
+
bankPendingEntry.timeoutId = bankTimeoutId;
|
|
1326
|
+
bankPendingEntry.tokenizeStartMs = bankTokenizeStartMs;
|
|
1327
|
+
}
|
|
1328
|
+
}
|
|
1329
|
+
catch (err) {
|
|
1330
|
+
this.bankTokenizeResolvers.delete(requestId);
|
|
1331
|
+
cleanup();
|
|
1332
|
+
reject(err instanceof OzError ? err : new OzError('Bank tokenization failed to start'));
|
|
1333
|
+
}
|
|
1334
|
+
});
|
|
1335
|
+
}
|
|
1336
|
+
/**
|
|
1337
|
+
* Tokenizes all mounted elements. Raw card data never leaves the Ozura-origin
|
|
1338
|
+
* iframes — the tokenizer iframe POSTs directly to the vault API.
|
|
1339
|
+
*
|
|
1340
|
+
* Returns a token and cvcSession that can be passed to the Ozura Pay API.
|
|
1341
|
+
*/
|
|
1342
|
+
async createToken(options = {}) {
|
|
1343
|
+
var _a, _b;
|
|
1344
|
+
if (this._destroyed) {
|
|
1345
|
+
throw new OzError('Cannot tokenize on a destroyed vault. Call await OzVault.create() to get a new instance.');
|
|
1346
|
+
}
|
|
1347
|
+
if (!this.tokenizerReady) {
|
|
1348
|
+
throw new OzError('Vault not ready. Ensure the page is fully loaded before calling createToken.');
|
|
1349
|
+
}
|
|
1350
|
+
if (this._tokenizing) {
|
|
1351
|
+
throw new OzError(this._tokenizing === 'bank'
|
|
1352
|
+
? 'A bank tokenization is already in progress. Wait for it to complete before calling createToken().'
|
|
1353
|
+
: 'A card tokenization is already in progress. Wait for it to complete before calling createToken() again.');
|
|
1354
|
+
}
|
|
1355
|
+
// All synchronous validation runs before _tokenizing is set so these throws
|
|
1356
|
+
// need no manual cleanup — _tokenizing is still null when they fire.
|
|
1357
|
+
// Collect all card elements that have been created (mounted or not) so we
|
|
1358
|
+
// can require every created field to be ready before proceeding. An element
|
|
1359
|
+
// that was created but whose iframe hasn't loaded yet will never send
|
|
1360
|
+
// OZ_FIELD_VALUE — tokenizing without it would silently submit an empty or
|
|
1361
|
+
// incomplete card and produce an opaque vault rejection.
|
|
1362
|
+
// Bank elements (accountNumber/routingNumber) share this.elements but live
|
|
1363
|
+
// in elementsByType under bank-only keys, so they are excluded by the Set.
|
|
1364
|
+
const cardElements = [...this.elementsByType.values()];
|
|
1365
|
+
if (cardElements.length === 0) {
|
|
1366
|
+
throw new OzError('No card elements have been created. Call vault.createElement() for each field before calling createToken.');
|
|
1367
|
+
}
|
|
1368
|
+
const notReady = cardElements.filter(el => !el.isReady);
|
|
1369
|
+
if (notReady.length > 0) {
|
|
1370
|
+
throw new OzError(`Not all elements are ready. Wait for all fields to finish loading before calling createToken. ` +
|
|
1371
|
+
`Not yet ready: ${notReady.map(el => el.type).join(', ')}.`);
|
|
1372
|
+
}
|
|
1373
|
+
const readyElements = cardElements;
|
|
1374
|
+
// Validate billing details if provided and extract firstName/lastName for the vault payload.
|
|
1375
|
+
// billing.firstName/lastName take precedence over the deprecated top-level params.
|
|
1376
|
+
let normalizedBilling;
|
|
1377
|
+
let firstName = ((_a = options.firstName) !== null && _a !== void 0 ? _a : '').trim();
|
|
1378
|
+
let lastName = ((_b = options.lastName) !== null && _b !== void 0 ? _b : '').trim();
|
|
1379
|
+
if (options.billing) {
|
|
1380
|
+
const result = validateBilling(options.billing);
|
|
1381
|
+
if (!result.valid) {
|
|
1382
|
+
throw new OzError(`Invalid billing details: ${result.errors.join('; ')}`);
|
|
1383
|
+
}
|
|
1384
|
+
normalizedBilling = result.normalized;
|
|
1385
|
+
firstName = normalizedBilling.firstName;
|
|
1386
|
+
lastName = normalizedBilling.lastName;
|
|
1387
|
+
}
|
|
1388
|
+
else {
|
|
1389
|
+
if (firstName.length > 50) {
|
|
1390
|
+
throw new OzError('firstName must be 50 characters or fewer.');
|
|
1391
|
+
}
|
|
1392
|
+
if (lastName.length > 50) {
|
|
1393
|
+
throw new OzError('lastName must be 50 characters or fewer.');
|
|
1394
|
+
}
|
|
1395
|
+
}
|
|
1396
|
+
this._tokenizing = 'card';
|
|
1397
|
+
const requestId = `req-${uuid()}`;
|
|
1398
|
+
this.log('createToken() called', {
|
|
1399
|
+
requestIdPrefix: requestId.slice(0, 12),
|
|
1400
|
+
fields: readyElements.map(el => el.type),
|
|
1401
|
+
billingPresent: Boolean(options.billing),
|
|
1402
|
+
});
|
|
1403
|
+
return new Promise((resolve, reject) => {
|
|
1404
|
+
// Capture the reset generation so cleanup() only zeros _tokenizing when it
|
|
1405
|
+
// still belongs to this invocation — not a newer one that started after a reset.
|
|
1406
|
+
const resetCountAtStart = this._resetCount;
|
|
1407
|
+
const cleanup = () => {
|
|
1408
|
+
if (this._resetCount === resetCountAtStart)
|
|
1409
|
+
this._tokenizing = null;
|
|
1410
|
+
};
|
|
1411
|
+
this.tokenizeResolvers.set(requestId, {
|
|
1412
|
+
resolve: (v) => { cleanup(); resolve(v); },
|
|
1413
|
+
reject: (e) => { cleanup(); reject(e); },
|
|
1414
|
+
billing: normalizedBilling,
|
|
1415
|
+
firstName,
|
|
1416
|
+
lastName,
|
|
1417
|
+
readyElements,
|
|
1418
|
+
fieldCount: readyElements.length,
|
|
1419
|
+
});
|
|
1420
|
+
try {
|
|
1421
|
+
// Tell tokenizer frame to expect N field values, then tokenize
|
|
1422
|
+
const cardChannels = readyElements.map(() => new MessageChannel());
|
|
1423
|
+
const tokenizeStartMs = Date.now();
|
|
1424
|
+
this.sendToTokenizer({
|
|
1425
|
+
type: 'OZ_TOKENIZE',
|
|
1426
|
+
requestId,
|
|
1427
|
+
tokenizationSessionId: this.tokenizationSessionId,
|
|
1428
|
+
pubKey: this.pubKey,
|
|
1429
|
+
firstName,
|
|
1430
|
+
lastName,
|
|
1431
|
+
fieldCount: readyElements.length,
|
|
1432
|
+
}, cardChannels.map(ch => ch.port1));
|
|
1433
|
+
this.log('OZ_TOKENIZE sent', { requestIdPrefix: `${requestId.slice(0, 12)}...`, fieldCount: readyElements.length });
|
|
1434
|
+
// Store start time for elapsed-ms logging on result
|
|
1435
|
+
const cardEntry = this.tokenizeResolvers.get(requestId);
|
|
1436
|
+
if (cardEntry)
|
|
1437
|
+
cardEntry.tokenizeStartMs = tokenizeStartMs;
|
|
1438
|
+
// Tell each ready element frame to send its raw value to the tokenizer
|
|
1439
|
+
readyElements.forEach((el, i) => {
|
|
1440
|
+
this.log('OZ_BEGIN_COLLECT dispatched', { type: el.type, requestIdPrefix: `${requestId.slice(0, 12)}...` });
|
|
1441
|
+
el.beginCollect(requestId, cardChannels[i].port2);
|
|
1442
|
+
});
|
|
1443
|
+
const cardTimeoutId = setTimeout(() => {
|
|
1444
|
+
if (this.tokenizeResolvers.has(requestId)) {
|
|
1445
|
+
this.tokenizeResolvers.delete(requestId);
|
|
1446
|
+
this.sendToTokenizer({ type: 'OZ_TOKENIZE_CANCEL', requestId });
|
|
1447
|
+
cleanup();
|
|
1448
|
+
reject(new OzError('Tokenization timed out after 30 seconds', undefined, 'timeout'));
|
|
1449
|
+
}
|
|
1450
|
+
}, 30000);
|
|
1451
|
+
const cardPendingEntry = this.tokenizeResolvers.get(requestId);
|
|
1452
|
+
if (cardPendingEntry)
|
|
1453
|
+
cardPendingEntry.timeoutId = cardTimeoutId;
|
|
1454
|
+
}
|
|
1455
|
+
catch (err) {
|
|
1456
|
+
this.tokenizeResolvers.delete(requestId);
|
|
1457
|
+
cleanup();
|
|
1458
|
+
reject(err instanceof OzError ? err : new OzError('Tokenization failed to start'));
|
|
1459
|
+
}
|
|
1460
|
+
});
|
|
1461
|
+
}
|
|
1462
|
+
/**
|
|
1463
|
+
* Clears all mounted element fields without tearing down the vault.
|
|
1464
|
+
*
|
|
1465
|
+
* Call this after a failed payment (e.g. card declined) to let the customer
|
|
1466
|
+
* re-enter their details. The vault instance, tokenizer iframe, wax key, and
|
|
1467
|
+
* tokenization budget counter are all preserved — no network calls are made.
|
|
1468
|
+
*
|
|
1469
|
+
* **Wax key session model:** by design, one wax key covers the full checkout
|
|
1470
|
+
* session. The default `max_tokenize_calls: 3` supports two declined attempts
|
|
1471
|
+
* and one final attempt on the same key. Do not call `vault.destroy()` and
|
|
1472
|
+
* recreate the vault between declines — that unnecessarily re-mints the key
|
|
1473
|
+
* and discards the remaining budget.
|
|
1474
|
+
*
|
|
1475
|
+
* @example
|
|
1476
|
+
* try {
|
|
1477
|
+
* const { token, cvcSession } = await vault.createToken({ billing });
|
|
1478
|
+
* await chargeCard(token, cvcSession);
|
|
1479
|
+
* } catch (err) {
|
|
1480
|
+
* vault.reset(); // clear fields; let customer re-enter
|
|
1481
|
+
* showError(err.message);
|
|
1482
|
+
* }
|
|
1483
|
+
*/
|
|
1484
|
+
reset() {
|
|
1485
|
+
if (this._destroyed)
|
|
1486
|
+
return;
|
|
1487
|
+
const cancelling = Boolean(this._tokenizing);
|
|
1488
|
+
this.log('reset() called', { tokenizing: this._tokenizing, cancelling });
|
|
1489
|
+
if (this._tokenizing) {
|
|
1490
|
+
this._tokenizing = null;
|
|
1491
|
+
this._resetCount++;
|
|
1492
|
+
this.tokenizeResolvers.forEach(({ reject, timeoutId }, requestId) => {
|
|
1493
|
+
if (timeoutId != null)
|
|
1494
|
+
clearTimeout(timeoutId);
|
|
1495
|
+
this.log('OZ_TOKENIZE_CANCEL sent (reset)', { requestIdPrefix: `${requestId.slice(0, 12)}...` });
|
|
1496
|
+
this.sendToTokenizer({ type: 'OZ_TOKENIZE_CANCEL', requestId });
|
|
1497
|
+
reject(new OzError('Vault was reset while tokenization was in progress.'));
|
|
1498
|
+
});
|
|
1499
|
+
this.tokenizeResolvers.clear();
|
|
1500
|
+
this.bankTokenizeResolvers.forEach(({ reject, timeoutId }, requestId) => {
|
|
1501
|
+
if (timeoutId != null)
|
|
1502
|
+
clearTimeout(timeoutId);
|
|
1503
|
+
this.log('OZ_TOKENIZE_CANCEL sent (reset, bank)', { requestIdPrefix: `${requestId.slice(0, 12)}...` });
|
|
1504
|
+
this.sendToTokenizer({ type: 'OZ_TOKENIZE_CANCEL', requestId });
|
|
1505
|
+
reject(new OzError('Vault was reset while tokenization was in progress.'));
|
|
1506
|
+
});
|
|
1507
|
+
this.bankTokenizeResolvers.clear();
|
|
1508
|
+
}
|
|
1509
|
+
// Clear field values in all mounted element iframes
|
|
1510
|
+
this.elementsByType.forEach(el => el.clear());
|
|
1511
|
+
this.bankElementsByType.forEach(el => el.clear());
|
|
1512
|
+
// Reset per-element completion state so auto-advance starts fresh on re-entry
|
|
1513
|
+
for (const frameId of this.completionState.keys()) {
|
|
1514
|
+
this.completionState.set(frameId, false);
|
|
1515
|
+
}
|
|
1516
|
+
// NOTE: _tokenizeSuccessCount is intentionally NOT reset.
|
|
1517
|
+
// It reflects real server-side wax key budget consumption. Zeroing it
|
|
1518
|
+
// would desync the proactive refresh logic from the vault's state and
|
|
1519
|
+
// risk triggering a mid-session re-mint on what should be a clean retry.
|
|
1520
|
+
}
|
|
1521
|
+
/**
|
|
1522
|
+
* Tears down the vault: removes all element iframes, the tokenizer iframe,
|
|
1523
|
+
* and the global message listener. Call this when the checkout component
|
|
1524
|
+
* unmounts (e.g. in React's useEffect cleanup or a SPA route change).
|
|
1525
|
+
*/
|
|
1526
|
+
destroy() {
|
|
1527
|
+
var _a;
|
|
1528
|
+
if (this._destroyed)
|
|
1529
|
+
return;
|
|
1530
|
+
this._destroyed = true;
|
|
1531
|
+
this.log('destroy() called');
|
|
1532
|
+
window.removeEventListener('message', this.boundHandleMessage);
|
|
1533
|
+
document.removeEventListener('visibilitychange', this.boundHandleVisibility);
|
|
1534
|
+
if (this._pendingMount) {
|
|
1535
|
+
document.removeEventListener('DOMContentLoaded', this._pendingMount);
|
|
1536
|
+
this._pendingMount = null;
|
|
1537
|
+
}
|
|
1538
|
+
if (this.loadErrorTimeoutId != null) {
|
|
1539
|
+
clearTimeout(this.loadErrorTimeoutId);
|
|
1540
|
+
this.loadErrorTimeoutId = null;
|
|
1541
|
+
}
|
|
1542
|
+
// Reject any pending tokenize promises so callers aren't left hanging
|
|
1543
|
+
this._tokenizing = null;
|
|
1544
|
+
this.tokenizeResolvers.forEach(({ reject, timeoutId }, requestId) => {
|
|
1545
|
+
if (timeoutId != null)
|
|
1546
|
+
clearTimeout(timeoutId);
|
|
1547
|
+
this.sendToTokenizer({ type: 'OZ_TOKENIZE_CANCEL', requestId });
|
|
1548
|
+
reject(new OzError('Vault destroyed before tokenization completed.'));
|
|
1549
|
+
});
|
|
1550
|
+
this.tokenizeResolvers.clear();
|
|
1551
|
+
this.bankTokenizeResolvers.forEach(({ reject, timeoutId }, requestId) => {
|
|
1552
|
+
if (timeoutId != null)
|
|
1553
|
+
clearTimeout(timeoutId);
|
|
1554
|
+
this.sendToTokenizer({ type: 'OZ_TOKENIZE_CANCEL', requestId });
|
|
1555
|
+
reject(new OzError('Vault destroyed before bank tokenization completed.'));
|
|
1556
|
+
});
|
|
1557
|
+
this.bankTokenizeResolvers.clear();
|
|
1558
|
+
this.elements.forEach(el => el.destroy());
|
|
1559
|
+
this.elements.clear();
|
|
1560
|
+
this.elementsByType.clear();
|
|
1561
|
+
this.bankElementsByType.clear();
|
|
1562
|
+
this.completionState.clear();
|
|
1563
|
+
this._tokenizeSuccessCount = 0;
|
|
1564
|
+
(_a = this.tokenizerFrame) === null || _a === void 0 ? void 0 : _a.remove();
|
|
1565
|
+
this.tokenizerFrame = null;
|
|
1566
|
+
this.tokenizerWindow = null;
|
|
1567
|
+
this.tokenizerReady = false;
|
|
1568
|
+
}
|
|
1569
|
+
// ─── Private ─────────────────────────────────────────────────────────────
|
|
1570
|
+
/**
|
|
1571
|
+
* Proactively re-mints the wax key when the page becomes visible again after
|
|
1572
|
+
* a long idle period. Wax keys have a fixed TTL (~30 minutes); a user who
|
|
1573
|
+
* leaves the tab in the background and returns could have an expired key.
|
|
1574
|
+
* Rather than waiting for a failed tokenization to trigger the reactive
|
|
1575
|
+
* refresh path, this pre-empts the failure when the vault is idle.
|
|
1576
|
+
*
|
|
1577
|
+
* Threshold: 20 minutes hidden. Chosen to be comfortably inside the ~30m TTL
|
|
1578
|
+
* while avoiding spurious refreshes for brief tab-switches.
|
|
1579
|
+
*/
|
|
1580
|
+
handleVisibilityChange() {
|
|
1581
|
+
if (this._destroyed)
|
|
1582
|
+
return;
|
|
1583
|
+
const REFRESH_THRESHOLD_MS = 20 * 60 * 1000; // 20 minutes
|
|
1584
|
+
if (document.hidden) {
|
|
1585
|
+
this._hiddenAt = Date.now();
|
|
1586
|
+
this.log('tab hidden');
|
|
1587
|
+
}
|
|
1588
|
+
else {
|
|
1589
|
+
const hiddenMs = this._hiddenAt !== null ? Date.now() - this._hiddenAt : 0;
|
|
1590
|
+
const willRefresh = (this._hiddenAt !== null &&
|
|
1591
|
+
hiddenMs >= REFRESH_THRESHOLD_MS &&
|
|
1592
|
+
Boolean(this._storedFetchWaxKey) &&
|
|
1593
|
+
!this._tokenizing &&
|
|
1594
|
+
!this._waxRefreshing);
|
|
1595
|
+
this.log('tab visible', { hiddenMs, willRefresh });
|
|
1596
|
+
if (willRefresh) {
|
|
1597
|
+
this.refreshWaxKey().catch((err) => {
|
|
1598
|
+
// Proactive refresh failure is non-fatal — the reactive path on the
|
|
1599
|
+
// next createToken() call will handle it, including the auth retry.
|
|
1600
|
+
console.warn('[OzVault] Proactive wax key refresh failed:', err instanceof Error ? err.message : err);
|
|
1601
|
+
});
|
|
1602
|
+
}
|
|
1603
|
+
this._hiddenAt = null;
|
|
1604
|
+
}
|
|
1605
|
+
}
|
|
1606
|
+
// ─── Debug ───────────────────────────────────────────────────────────────
|
|
1607
|
+
/**
|
|
1608
|
+
* Emits a `[OzVault]`-prefixed entry to `console.log`. No-op when `debug` is
|
|
1609
|
+
* not set. Never called with sensitive values — callers use presence flags only.
|
|
1610
|
+
*/
|
|
1611
|
+
log(message, data) {
|
|
1612
|
+
if (!this._debug)
|
|
1613
|
+
return;
|
|
1614
|
+
if (data !== undefined) {
|
|
1615
|
+
console.log(`[OzVault] ${message}`, data);
|
|
1616
|
+
}
|
|
1617
|
+
else {
|
|
1618
|
+
console.log(`[OzVault] ${message}`);
|
|
1619
|
+
}
|
|
1620
|
+
}
|
|
1621
|
+
/**
|
|
1622
|
+
* Returns a plain-object snapshot of the vault's current internal state.
|
|
1623
|
+
* Safe to attach to bug reports — no wax keys, tokens, or billing data included.
|
|
1624
|
+
*
|
|
1625
|
+
* Available on all vault instances regardless of whether `debug` was enabled.
|
|
1626
|
+
*
|
|
1627
|
+
* @example
|
|
1628
|
+
* console.log(vault.debugState());
|
|
1629
|
+
* // {
|
|
1630
|
+
* // vaultId: 'vault-abc123',
|
|
1631
|
+
* // isReady: true,
|
|
1632
|
+
* // tokenizing: null,
|
|
1633
|
+
* // destroyed: false,
|
|
1634
|
+
* // waxKeyPresent: true,
|
|
1635
|
+
* // elements: ['cardNumber', 'expirationDate', 'cvv'],
|
|
1636
|
+
* // ...
|
|
1637
|
+
* // }
|
|
1638
|
+
*/
|
|
1639
|
+
debugState() {
|
|
1640
|
+
return {
|
|
1641
|
+
vaultId: this.vaultId,
|
|
1642
|
+
isReady: this.tokenizerReady,
|
|
1643
|
+
tokenizing: this._tokenizing,
|
|
1644
|
+
destroyed: this._destroyed,
|
|
1645
|
+
waxKeyPresent: Boolean(this.waxKey),
|
|
1646
|
+
tokenizeSuccessCount: this._tokenizeSuccessCount,
|
|
1647
|
+
maxTokenizeCalls: this._maxTokenizeCalls,
|
|
1648
|
+
resetCount: this._resetCount,
|
|
1649
|
+
elements: [...this.elementsByType.keys()],
|
|
1650
|
+
bankElements: [...this.bankElementsByType.keys()],
|
|
1651
|
+
completionState: Object.fromEntries([...this.completionState.entries()].map(([id, v]) => [id.slice(0, 8), v])),
|
|
1652
|
+
pendingTokenizations: this.tokenizeResolvers.size,
|
|
1653
|
+
pendingBankTokenizations: this.bankTokenizeResolvers.size,
|
|
1654
|
+
};
|
|
1655
|
+
}
|
|
1656
|
+
mountTokenizerFrame() {
|
|
1657
|
+
const mount = () => {
|
|
1658
|
+
this._pendingMount = null;
|
|
1659
|
+
const iframe = document.createElement('iframe');
|
|
1660
|
+
iframe.style.cssText = 'position:absolute;top:-9999px;left:-9999px;width:1px;height:1px;';
|
|
1661
|
+
iframe.setAttribute('aria-hidden', 'true');
|
|
1662
|
+
iframe.tabIndex = -1;
|
|
1663
|
+
const parentOrigin = typeof window !== 'undefined' ? window.location.origin : '';
|
|
1664
|
+
iframe.src = `${this.frameBaseUrl}/frame/tokenizer-frame.html#vaultId=${encodeURIComponent(this.vaultId)}${parentOrigin ? `&parentOrigin=${encodeURIComponent(parentOrigin)}` : ''}`;
|
|
1665
|
+
document.body.appendChild(iframe);
|
|
1666
|
+
this.tokenizerFrame = iframe;
|
|
1667
|
+
this.log('mounting tokenizer iframe');
|
|
1668
|
+
};
|
|
1669
|
+
if (document.readyState === 'loading') {
|
|
1670
|
+
this._pendingMount = mount;
|
|
1671
|
+
document.addEventListener('DOMContentLoaded', mount);
|
|
1672
|
+
}
|
|
1673
|
+
else {
|
|
1674
|
+
mount();
|
|
1675
|
+
}
|
|
1676
|
+
}
|
|
1677
|
+
handleMessage(event) {
|
|
1678
|
+
var _a, _b;
|
|
1679
|
+
if (this._destroyed)
|
|
1680
|
+
return;
|
|
1681
|
+
// Only accept messages from our frame origin (defense in depth; prevents
|
|
1682
|
+
// arbitrary pages from injecting OZ_TOKEN_RESULT etc. with a guessed vaultId).
|
|
1683
|
+
if (event.origin !== this.frameOrigin)
|
|
1684
|
+
return;
|
|
1685
|
+
const msg = event.data;
|
|
1686
|
+
if (!msg || msg.__oz !== true || msg.vaultId !== this.vaultId)
|
|
1687
|
+
return;
|
|
1688
|
+
// Route tokenizer messages
|
|
1689
|
+
if (event.source === ((_a = this.tokenizerFrame) === null || _a === void 0 ? void 0 : _a.contentWindow)) {
|
|
1690
|
+
this.handleTokenizerMessage(msg);
|
|
1691
|
+
return;
|
|
1692
|
+
}
|
|
1693
|
+
// OZ_TOKEN_ERROR can arrive from element frames when the MessagePort
|
|
1694
|
+
// transferred in OZ_BEGIN_COLLECT was dropped by the browser (e.g. the
|
|
1695
|
+
// frame navigated). These carry a requestId but no frameId — route them
|
|
1696
|
+
// through handleTokenizerMessage so the pending promise is rejected
|
|
1697
|
+
// immediately rather than waiting for the 30 s collect timeout.
|
|
1698
|
+
if (msg.type === 'OZ_TOKEN_ERROR') {
|
|
1699
|
+
this.handleTokenizerMessage(msg);
|
|
1700
|
+
return;
|
|
1701
|
+
}
|
|
1702
|
+
// Route to the matching element
|
|
1703
|
+
const frameId = msg.frameId;
|
|
1704
|
+
if (frameId) {
|
|
1705
|
+
const el = this.elements.get(frameId);
|
|
1706
|
+
if (el) {
|
|
1707
|
+
// Reset stale completion state when an element iframe re-loads (e.g. after
|
|
1708
|
+
// unmount() + mount() in a SPA). Without this, wasComplete stays true from
|
|
1709
|
+
// the previous session and justCompleted never fires, breaking auto-advance.
|
|
1710
|
+
if (msg.type === 'OZ_FRAME_READY') {
|
|
1711
|
+
this.completionState.set(frameId, false);
|
|
1712
|
+
if (msg.__ozVersion !== PROTOCOL_VERSION) {
|
|
1713
|
+
console.warn(`[OzVault] Protocol version mismatch on element frame "${frameId}" — ` +
|
|
1714
|
+
`SDK expects v${PROTOCOL_VERSION}, frame reported v${typeof msg.__ozVersion === 'number' ? msg.__ozVersion : '(none)'}. ` +
|
|
1715
|
+
'Deploy the matching frame assets to elements.ozura.com and purge the Azure CDN cache.');
|
|
1716
|
+
}
|
|
1717
|
+
this.log('element iframe ready', { type: el.type, frameIdPrefix: frameId.slice(0, 8) });
|
|
1718
|
+
}
|
|
1719
|
+
// Relay debug/warning messages from element iframes into the parent
|
|
1720
|
+
// DevTools console. Element frames run in cross-origin iframes whose
|
|
1721
|
+
// console context is invisible to developers without a frame selector switch.
|
|
1722
|
+
// Errors are always surfaced (genuine failures). Warnings are only emitted
|
|
1723
|
+
// when debug mode is on — CSS var() and font-host warnings fire on every
|
|
1724
|
+
// style update and would pollute production consoles otherwise.
|
|
1725
|
+
if (msg.type === 'OZ_DEBUG_LOG') {
|
|
1726
|
+
const level = typeof msg.level === 'string' ? msg.level : 'warn';
|
|
1727
|
+
const message = typeof msg.message === 'string' ? msg.message : String((_b = msg.message) !== null && _b !== void 0 ? _b : '');
|
|
1728
|
+
if (level === 'error') {
|
|
1729
|
+
console.error(`[OzVault:${el.type}] ${message}`);
|
|
1730
|
+
}
|
|
1731
|
+
else if (this._debug) {
|
|
1732
|
+
console.warn(`[OzVault:${el.type}] ${message}`);
|
|
1733
|
+
}
|
|
1734
|
+
return;
|
|
1735
|
+
}
|
|
1736
|
+
// Intercept OZ_CHANGE before forwarding — handle auto-advance and CVV sync
|
|
1737
|
+
if (msg.type === 'OZ_CHANGE') {
|
|
1738
|
+
this.handleElementChange(msg, el);
|
|
1739
|
+
}
|
|
1740
|
+
el.handleMessage(msg);
|
|
1741
|
+
}
|
|
1742
|
+
}
|
|
1743
|
+
}
|
|
1744
|
+
/**
|
|
1745
|
+
* Handles side-effects that the SDK manages internally when a field changes:
|
|
1746
|
+
* - CVV length sync when card brand changes
|
|
1747
|
+
* - Auto-advance focus when a field completes
|
|
1748
|
+
*/
|
|
1749
|
+
handleElementChange(msg, el) {
|
|
1750
|
+
var _a, _b, _c;
|
|
1751
|
+
const complete = msg.complete;
|
|
1752
|
+
const valid = msg.valid;
|
|
1753
|
+
const wasComplete = (_a = this.completionState.get(el.frameId)) !== null && _a !== void 0 ? _a : false;
|
|
1754
|
+
this.completionState.set(el.frameId, complete && valid);
|
|
1755
|
+
// Require valid too — avoids advancing at 13 digits for unknown-brand cards
|
|
1756
|
+
// where isComplete() fires before the user has finished typing.
|
|
1757
|
+
const justCompleted = complete && valid && !wasComplete;
|
|
1758
|
+
this.log('field changed', { type: el.type, complete, valid, justCompleted });
|
|
1759
|
+
// Sync CVV length when card brand changes
|
|
1760
|
+
if (el.type === 'cardNumber') {
|
|
1761
|
+
const brand = msg.cardBrand;
|
|
1762
|
+
const cvvEl = this.elementsByType.get('cvv');
|
|
1763
|
+
if (cvvEl && brand) {
|
|
1764
|
+
cvvEl.setCvvLength(brand === 'amex' ? 4 : 3);
|
|
1765
|
+
}
|
|
1766
|
+
}
|
|
1767
|
+
// Auto-advance focus on completion
|
|
1768
|
+
if (justCompleted) {
|
|
1769
|
+
if (el.type === 'cardNumber') {
|
|
1770
|
+
this.log('auto-advance', { from: 'cardNumber', to: 'expirationDate' });
|
|
1771
|
+
(_b = this.elementsByType.get('expirationDate')) === null || _b === void 0 ? void 0 : _b.focus();
|
|
1772
|
+
}
|
|
1773
|
+
else if (el.type === 'expirationDate') {
|
|
1774
|
+
this.log('auto-advance', { from: 'expirationDate', to: 'cvv' });
|
|
1775
|
+
(_c = this.elementsByType.get('cvv')) === null || _c === void 0 ? void 0 : _c.focus();
|
|
1776
|
+
}
|
|
1777
|
+
}
|
|
1778
|
+
}
|
|
1779
|
+
handleTokenizerMessage(msg) {
|
|
1780
|
+
var _a, _b, _c, _d, _e;
|
|
1781
|
+
switch (msg.type) {
|
|
1782
|
+
case 'OZ_FRAME_READY':
|
|
1783
|
+
if (msg.__ozVersion !== PROTOCOL_VERSION) {
|
|
1784
|
+
console.warn(`[OzVault] Protocol version mismatch — SDK expects v${PROTOCOL_VERSION}, ` +
|
|
1785
|
+
`tokenizer frame reported v${typeof msg.__ozVersion === 'number' ? msg.__ozVersion : '(none)'}. ` +
|
|
1786
|
+
'This usually means the deployed frame files are stale. ' +
|
|
1787
|
+
'Deploy the matching frame assets to elements.ozura.com and purge the Azure CDN cache.');
|
|
1788
|
+
}
|
|
1789
|
+
this.tokenizerReady = true;
|
|
1790
|
+
if (this.loadErrorTimeoutId != null) {
|
|
1791
|
+
clearTimeout(this.loadErrorTimeoutId);
|
|
1792
|
+
this.loadErrorTimeoutId = null;
|
|
1793
|
+
}
|
|
1794
|
+
this.tokenizerWindow = (_b = (_a = this.tokenizerFrame) === null || _a === void 0 ? void 0 : _a.contentWindow) !== null && _b !== void 0 ? _b : null;
|
|
1795
|
+
// Deliver the wax key via OZ_INIT so the tokenizer stores it internally.
|
|
1796
|
+
// If waxKey is still empty (fetchWaxKey hasn't resolved yet), it will be
|
|
1797
|
+
// sent again from create() once the key is available.
|
|
1798
|
+
this.sendToTokenizer(Object.assign(Object.assign({ type: 'OZ_INIT', frameId: '__tokenizer__' }, (this.waxKey ? { waxKey: this.waxKey } : {})), { debug: this._debug }));
|
|
1799
|
+
(_c = this._onReady) === null || _c === void 0 ? void 0 : _c.call(this);
|
|
1800
|
+
this.log('tokenizer iframe ready', { protocolVersion: (_d = msg.__ozVersion) !== null && _d !== void 0 ? _d : null });
|
|
1801
|
+
this.log('vault state', this.debugState());
|
|
1802
|
+
break;
|
|
1803
|
+
case 'OZ_TOKEN_RESULT': {
|
|
1804
|
+
if (typeof msg.requestId !== 'string' || !msg.requestId) {
|
|
1805
|
+
console.error('[OzVault] OZ_TOKEN_RESULT missing requestId — discarding message.');
|
|
1806
|
+
break;
|
|
1807
|
+
}
|
|
1808
|
+
const pending = this.tokenizeResolvers.get(msg.requestId);
|
|
1809
|
+
if (pending) {
|
|
1810
|
+
this.tokenizeResolvers.delete(msg.requestId);
|
|
1811
|
+
if (pending.timeoutId != null)
|
|
1812
|
+
clearTimeout(pending.timeoutId);
|
|
1813
|
+
const token = msg.token;
|
|
1814
|
+
if (typeof token !== 'string' || !token) {
|
|
1815
|
+
pending.reject(new OzError('Vault returned a token result with a missing or empty token — possible vault API change.', undefined, 'server'));
|
|
1816
|
+
break;
|
|
1817
|
+
}
|
|
1818
|
+
const card = isCardMetadata(msg.card) ? msg.card : undefined;
|
|
1819
|
+
const cvcSession = typeof msg.cvcSession === 'string' && msg.cvcSession ? msg.cvcSession : undefined;
|
|
1820
|
+
if (!cvcSession) {
|
|
1821
|
+
pending.reject(new OzError('Vault returned a token result without a cvcSession — possible vault misconfiguration or API change.', undefined, 'server'));
|
|
1822
|
+
break;
|
|
1823
|
+
}
|
|
1824
|
+
pending.resolve(Object.assign(Object.assign({ token,
|
|
1825
|
+
cvcSession }, (card ? { card } : {})), (pending.billing ? { billing: pending.billing } : {})));
|
|
1826
|
+
this.log('token received', {
|
|
1827
|
+
requestIdPrefix: msg.requestId.slice(0, 12),
|
|
1828
|
+
elapsedMs: pending.tokenizeStartMs != null ? Date.now() - pending.tokenizeStartMs : null,
|
|
1829
|
+
tokenPresent: true,
|
|
1830
|
+
cvcSessionPresent: true,
|
|
1831
|
+
cardMetadataPresent: Boolean(card),
|
|
1832
|
+
});
|
|
1833
|
+
// Increment the per-key success counter and proactively refresh once
|
|
1834
|
+
// the budget is exhausted so the next createToken() call uses a fresh
|
|
1835
|
+
// key without waiting for a vault rejection.
|
|
1836
|
+
this._tokenizeSuccessCount++;
|
|
1837
|
+
if (this._tokenizeSuccessCount >= this._maxTokenizeCalls) {
|
|
1838
|
+
this.log('proactive session key refresh triggered', { tokenizeSuccessCount: this._tokenizeSuccessCount, maxTokenizeCalls: this._maxTokenizeCalls });
|
|
1839
|
+
this.refreshWaxKey().catch((err) => {
|
|
1840
|
+
console.warn('[OzVault] Post-budget session key refresh failed:', err instanceof Error ? err.message : err);
|
|
1841
|
+
});
|
|
1842
|
+
}
|
|
1843
|
+
}
|
|
1844
|
+
break;
|
|
1845
|
+
}
|
|
1846
|
+
case 'OZ_TOKEN_ERROR': {
|
|
1847
|
+
if (typeof msg.requestId !== 'string' || !msg.requestId) {
|
|
1848
|
+
console.error('[OzVault] OZ_TOKEN_ERROR missing requestId — discarding message.');
|
|
1849
|
+
break;
|
|
1850
|
+
}
|
|
1851
|
+
const raw = typeof msg.error === 'string' ? msg.error : '';
|
|
1852
|
+
const errorCode = isOzErrorCode(msg.errorCode) ? msg.errorCode : 'unknown';
|
|
1853
|
+
const pending = this.tokenizeResolvers.get(msg.requestId);
|
|
1854
|
+
if (pending) {
|
|
1855
|
+
this.tokenizeResolvers.delete(msg.requestId);
|
|
1856
|
+
if (pending.timeoutId != null)
|
|
1857
|
+
clearTimeout(pending.timeoutId);
|
|
1858
|
+
const willRefresh = this.isRefreshableAuthError(errorCode, raw) && !pending.retried && Boolean(this._storedFetchWaxKey);
|
|
1859
|
+
this.log('token error', { requestIdPrefix: msg.requestId.slice(0, 12), errorCode, willRefresh });
|
|
1860
|
+
// Auto-refresh: if the wax key expired or was consumed and we haven't
|
|
1861
|
+
// already retried for this request, transparently re-mint and retry.
|
|
1862
|
+
if (willRefresh) {
|
|
1863
|
+
const resetCountAtRetry = this._resetCount;
|
|
1864
|
+
this.refreshWaxKey().then(() => {
|
|
1865
|
+
if (this._destroyed) {
|
|
1866
|
+
pending.reject(new OzError('Vault destroyed during wax key refresh.'));
|
|
1867
|
+
return;
|
|
1868
|
+
}
|
|
1869
|
+
if (this._resetCount !== resetCountAtRetry) {
|
|
1870
|
+
// reset() was called while the wax key was refreshing — the fields
|
|
1871
|
+
// have been cleared and _tokenizing was zeroed. Reject the original
|
|
1872
|
+
// promise so it doesn't stay pending, and bail out without starting
|
|
1873
|
+
// a new retry (which would tokenize against empty fields).
|
|
1874
|
+
pending.reject(new OzError('Vault was reset while tokenization was in progress.'));
|
|
1875
|
+
return;
|
|
1876
|
+
}
|
|
1877
|
+
// Verify all elements from the original call are still mounted and
|
|
1878
|
+
// ready. If any were destroyed (e.g. the merchant called
|
|
1879
|
+
// createElement() or destroy() during the async refresh), the
|
|
1880
|
+
// beginCollect() calls below would silently no-op — the tokenizer
|
|
1881
|
+
// would wait forever for field values that never arrive. Reject
|
|
1882
|
+
// immediately with a clear message instead of hanging 30 seconds.
|
|
1883
|
+
const allCardElementsStillReady = pending.readyElements.every(el => this.elements.has(el.frameId) && el.isReady);
|
|
1884
|
+
if (!allCardElementsStillReady) {
|
|
1885
|
+
pending.reject(new OzError('Card fields changed during session refresh. Please re-enter your card details.'));
|
|
1886
|
+
return;
|
|
1887
|
+
}
|
|
1888
|
+
const newRequestId = `req-${uuid()}`;
|
|
1889
|
+
// _tokenizing is still 'card' (cleanup() hasn't been called yet)
|
|
1890
|
+
this.tokenizeResolvers.set(newRequestId, Object.assign(Object.assign({}, pending), { retried: true }));
|
|
1891
|
+
try {
|
|
1892
|
+
const retryCardChannels = pending.readyElements.map(() => new MessageChannel());
|
|
1893
|
+
this.sendToTokenizer({
|
|
1894
|
+
type: 'OZ_TOKENIZE',
|
|
1895
|
+
requestId: newRequestId,
|
|
1896
|
+
tokenizationSessionId: this.tokenizationSessionId,
|
|
1897
|
+
pubKey: this.pubKey,
|
|
1898
|
+
firstName: pending.firstName,
|
|
1899
|
+
lastName: pending.lastName,
|
|
1900
|
+
fieldCount: pending.fieldCount,
|
|
1901
|
+
}, retryCardChannels.map(ch => ch.port1));
|
|
1902
|
+
pending.readyElements.forEach((el, i) => {
|
|
1903
|
+
this.log('OZ_BEGIN_COLLECT dispatched (retry)', { type: el.type, requestIdPrefix: `${newRequestId.slice(0, 12)}...` });
|
|
1904
|
+
el.beginCollect(newRequestId, retryCardChannels[i].port2);
|
|
1905
|
+
});
|
|
1906
|
+
const retryCardTimeoutId = setTimeout(() => {
|
|
1907
|
+
if (this.tokenizeResolvers.has(newRequestId)) {
|
|
1908
|
+
this.tokenizeResolvers.delete(newRequestId);
|
|
1909
|
+
this.sendToTokenizer({ type: 'OZ_TOKENIZE_CANCEL', requestId: newRequestId });
|
|
1910
|
+
pending.reject(new OzError('Tokenization timed out after wax key refresh.', undefined, 'timeout'));
|
|
1911
|
+
}
|
|
1912
|
+
}, 30000);
|
|
1913
|
+
const retryCardEntry = this.tokenizeResolvers.get(newRequestId);
|
|
1914
|
+
if (retryCardEntry)
|
|
1915
|
+
retryCardEntry.timeoutId = retryCardTimeoutId;
|
|
1916
|
+
}
|
|
1917
|
+
catch (setupErr) {
|
|
1918
|
+
this.tokenizeResolvers.delete(newRequestId);
|
|
1919
|
+
pending.reject(setupErr instanceof OzError ? setupErr : new OzError('Retry tokenization failed to start'));
|
|
1920
|
+
}
|
|
1921
|
+
}).catch((refreshErr) => {
|
|
1922
|
+
const errMsg = refreshErr instanceof Error ? refreshErr.message : 'Wax key refresh failed';
|
|
1923
|
+
pending.reject(new OzError(errMsg, undefined, 'auth'));
|
|
1924
|
+
});
|
|
1925
|
+
break;
|
|
1926
|
+
}
|
|
1927
|
+
pending.reject(new OzError(normalizeVaultError(raw), raw, errorCode));
|
|
1928
|
+
}
|
|
1929
|
+
else {
|
|
1930
|
+
// Also check bank resolvers — both card and bank errors use OZ_TOKEN_ERROR.
|
|
1931
|
+
// Use else-if rather than sequential checks so a UUID collision (however
|
|
1932
|
+
// improbable) can never trigger double-rejection of two unrelated resolvers.
|
|
1933
|
+
const bankPending = this.bankTokenizeResolvers.get(msg.requestId);
|
|
1934
|
+
if (bankPending) {
|
|
1935
|
+
this.bankTokenizeResolvers.delete(msg.requestId);
|
|
1936
|
+
if (bankPending.timeoutId != null)
|
|
1937
|
+
clearTimeout(bankPending.timeoutId);
|
|
1938
|
+
if (this.isRefreshableAuthError(errorCode, raw) && !bankPending.retried && this._storedFetchWaxKey) {
|
|
1939
|
+
const resetCountAtRetry = this._resetCount;
|
|
1940
|
+
this.refreshWaxKey().then(() => {
|
|
1941
|
+
if (this._destroyed) {
|
|
1942
|
+
bankPending.reject(new OzError('Vault destroyed during wax key refresh.'));
|
|
1943
|
+
return;
|
|
1944
|
+
}
|
|
1945
|
+
if (this._resetCount !== resetCountAtRetry) {
|
|
1946
|
+
bankPending.reject(new OzError('Vault was reset while tokenization was in progress.'));
|
|
1947
|
+
return;
|
|
1948
|
+
}
|
|
1949
|
+
// Same stale-element guard as the card retry path above.
|
|
1950
|
+
const allBankElementsStillReady = bankPending.readyElements.every(el => this.elements.has(el.frameId) && el.isReady);
|
|
1951
|
+
if (!allBankElementsStillReady) {
|
|
1952
|
+
bankPending.reject(new OzError('Bank fields changed during session refresh. Please re-enter your account details.'));
|
|
1953
|
+
return;
|
|
1954
|
+
}
|
|
1955
|
+
const newRequestId = `req-${uuid()}`;
|
|
1956
|
+
this.bankTokenizeResolvers.set(newRequestId, Object.assign(Object.assign({}, bankPending), { retried: true }));
|
|
1957
|
+
try {
|
|
1958
|
+
const retryBankChannels = bankPending.readyElements.map(() => new MessageChannel());
|
|
1959
|
+
this.sendToTokenizer({
|
|
1960
|
+
type: 'OZ_BANK_TOKENIZE',
|
|
1961
|
+
requestId: newRequestId,
|
|
1962
|
+
tokenizationSessionId: this.tokenizationSessionId,
|
|
1963
|
+
pubKey: this.pubKey,
|
|
1964
|
+
firstName: bankPending.firstName,
|
|
1965
|
+
lastName: bankPending.lastName,
|
|
1966
|
+
fieldCount: bankPending.fieldCount,
|
|
1967
|
+
}, retryBankChannels.map(ch => ch.port1));
|
|
1968
|
+
bankPending.readyElements.forEach((el, i) => {
|
|
1969
|
+
this.log('OZ_BEGIN_COLLECT dispatched (retry)', { type: el.type, requestIdPrefix: `${newRequestId.slice(0, 12)}...` });
|
|
1970
|
+
el.beginCollect(newRequestId, retryBankChannels[i].port2);
|
|
1971
|
+
});
|
|
1972
|
+
const retryBankTimeoutId = setTimeout(() => {
|
|
1973
|
+
if (this.bankTokenizeResolvers.has(newRequestId)) {
|
|
1974
|
+
this.bankTokenizeResolvers.delete(newRequestId);
|
|
1975
|
+
this.sendToTokenizer({ type: 'OZ_TOKENIZE_CANCEL', requestId: newRequestId });
|
|
1976
|
+
bankPending.reject(new OzError('Bank tokenization timed out after wax key refresh.', undefined, 'timeout'));
|
|
1977
|
+
}
|
|
1978
|
+
}, 30000);
|
|
1979
|
+
const retryBankEntry = this.bankTokenizeResolvers.get(newRequestId);
|
|
1980
|
+
if (retryBankEntry)
|
|
1981
|
+
retryBankEntry.timeoutId = retryBankTimeoutId;
|
|
1982
|
+
}
|
|
1983
|
+
catch (setupErr) {
|
|
1984
|
+
this.bankTokenizeResolvers.delete(newRequestId);
|
|
1985
|
+
bankPending.reject(setupErr instanceof OzError ? setupErr : new OzError('Retry bank tokenization failed to start'));
|
|
1986
|
+
}
|
|
1987
|
+
}).catch((refreshErr) => {
|
|
1988
|
+
const errMsg = refreshErr instanceof Error ? refreshErr.message : 'Wax key refresh failed';
|
|
1989
|
+
bankPending.reject(new OzError(errMsg, undefined, 'auth'));
|
|
1990
|
+
});
|
|
1991
|
+
break;
|
|
1992
|
+
}
|
|
1993
|
+
bankPending.reject(new OzError(normalizeBankVaultError(raw), raw, errorCode));
|
|
1994
|
+
}
|
|
1995
|
+
} // end else (bank path)
|
|
1996
|
+
break;
|
|
1997
|
+
}
|
|
1998
|
+
case 'OZ_BANK_TOKEN_RESULT': {
|
|
1999
|
+
if (typeof msg.requestId !== 'string' || !msg.requestId) {
|
|
2000
|
+
console.error('[OzVault] OZ_BANK_TOKEN_RESULT missing requestId — discarding message.');
|
|
2001
|
+
break;
|
|
2002
|
+
}
|
|
2003
|
+
const pending = this.bankTokenizeResolvers.get(msg.requestId);
|
|
2004
|
+
if (pending) {
|
|
2005
|
+
this.bankTokenizeResolvers.delete(msg.requestId);
|
|
2006
|
+
if (pending.timeoutId != null)
|
|
2007
|
+
clearTimeout(pending.timeoutId);
|
|
2008
|
+
const token = msg.token;
|
|
2009
|
+
if (typeof token !== 'string' || !token) {
|
|
2010
|
+
pending.reject(new OzError('Vault returned a bank token result with a missing or empty token — possible vault API change.', undefined, 'server'));
|
|
2011
|
+
break;
|
|
2012
|
+
}
|
|
2013
|
+
const bank = isBankAccountMetadata(msg.bank) ? msg.bank : undefined;
|
|
2014
|
+
pending.resolve(Object.assign({ token }, (bank ? { bank } : {})));
|
|
2015
|
+
this.log('bank token received', {
|
|
2016
|
+
elapsedMs: pending.tokenizeStartMs != null ? Date.now() - pending.tokenizeStartMs : null,
|
|
2017
|
+
tokenPresent: true,
|
|
2018
|
+
bankMetadataPresent: Boolean(bank),
|
|
2019
|
+
});
|
|
2020
|
+
// Same proactive refresh logic as card tokenization.
|
|
2021
|
+
this._tokenizeSuccessCount++;
|
|
2022
|
+
if (this._tokenizeSuccessCount >= this._maxTokenizeCalls) {
|
|
2023
|
+
this.log('proactive session key refresh triggered', { tokenizeSuccessCount: this._tokenizeSuccessCount, maxTokenizeCalls: this._maxTokenizeCalls });
|
|
2024
|
+
this.refreshWaxKey().catch((err) => {
|
|
2025
|
+
console.warn('[OzVault] Post-budget session key refresh failed:', err instanceof Error ? err.message : err);
|
|
2026
|
+
});
|
|
2027
|
+
}
|
|
2028
|
+
}
|
|
2029
|
+
break;
|
|
2030
|
+
}
|
|
2031
|
+
case 'OZ_DEBUG_LOG': {
|
|
2032
|
+
// Relay warnings/errors from the tokenizer iframe into the parent page's
|
|
2033
|
+
// DevTools console. The tokenizer runs in a cross-origin iframe whose
|
|
2034
|
+
// console context is invisible to most developers unless they manually
|
|
2035
|
+
// switch the DevTools frame selector.
|
|
2036
|
+
// Errors are always surfaced; warnings only in debug mode to avoid
|
|
2037
|
+
// polluting production consoles.
|
|
2038
|
+
const level = typeof msg.level === 'string' ? msg.level : 'warn';
|
|
2039
|
+
const message = typeof msg.message === 'string' ? msg.message : String((_e = msg.message) !== null && _e !== void 0 ? _e : '');
|
|
2040
|
+
if (level === 'error') {
|
|
2041
|
+
console.error(`[OzVault:tokenizer] ${message}`);
|
|
2042
|
+
}
|
|
2043
|
+
else if (this._debug) {
|
|
2044
|
+
console.warn(`[OzVault:tokenizer] ${message}`);
|
|
2045
|
+
}
|
|
2046
|
+
break;
|
|
2047
|
+
}
|
|
2048
|
+
}
|
|
2049
|
+
}
|
|
2050
|
+
/**
|
|
2051
|
+
* Returns true when an OZ_TOKEN_ERROR should trigger a wax key refresh.
|
|
2052
|
+
*
|
|
2053
|
+
* Primary path: vault returns 401/403 → errorCode 'auth'.
|
|
2054
|
+
* Defensive path: vault returns 400 → errorCode 'validation', but the raw
|
|
2055
|
+
* message contains wax-key-specific language (consumed, expired, invalid key,
|
|
2056
|
+
* etc.). This avoids a hard dependency on the vault returning a unified HTTP
|
|
2057
|
+
* status for consumed-key vs expired-key failures — both should refresh.
|
|
2058
|
+
*
|
|
2059
|
+
* Deliberately excludes 'network', 'timeout', and 'server' codes (transient
|
|
2060
|
+
* errors are already retried in fetchWithRetry) and 'unknown' (too broad).
|
|
2061
|
+
*/
|
|
2062
|
+
isRefreshableAuthError(errorCode, raw) {
|
|
2063
|
+
if (errorCode === 'auth')
|
|
2064
|
+
return true;
|
|
2065
|
+
if (errorCode === 'validation') {
|
|
2066
|
+
const msg = raw.toLowerCase();
|
|
2067
|
+
// Only treat validation errors as wax-related if the message explicitly
|
|
2068
|
+
// names the wax/tokenization session mechanism. A bare "session" match
|
|
2069
|
+
// was too broad — any message mentioning "session" (e.g. a merchant
|
|
2070
|
+
// session field error) would trigger a spurious re-mint.
|
|
2071
|
+
return (msg.includes('wax') ||
|
|
2072
|
+
msg.includes('expired') ||
|
|
2073
|
+
msg.includes('consumed') ||
|
|
2074
|
+
msg.includes('invalid key') ||
|
|
2075
|
+
msg.includes('key not found') ||
|
|
2076
|
+
msg.includes('tokenization session'));
|
|
2077
|
+
}
|
|
2078
|
+
return false;
|
|
2079
|
+
}
|
|
2080
|
+
/**
|
|
2081
|
+
* Re-mints the wax key using the stored fetchWaxKey callback and updates
|
|
2082
|
+
* the tokenizer with the new key. Used for transparent auto-refresh when
|
|
2083
|
+
* the vault returns an auth error on tokenization.
|
|
2084
|
+
*
|
|
2085
|
+
* Only one refresh runs at a time — concurrent retries share the same promise.
|
|
2086
|
+
*/
|
|
2087
|
+
refreshWaxKey() {
|
|
2088
|
+
var _a;
|
|
2089
|
+
if (this._waxRefreshing)
|
|
2090
|
+
return this._waxRefreshing;
|
|
2091
|
+
if (!this._storedFetchWaxKey) {
|
|
2092
|
+
return Promise.reject(new OzError('Wax key expired and no fetchWaxKey callback is available for auto-refresh. Call OzVault.create() again to obtain a new vault instance.', undefined, 'auth'));
|
|
2093
|
+
}
|
|
2094
|
+
const newSessionId = uuid();
|
|
2095
|
+
(_a = this._onWaxRefresh) === null || _a === void 0 ? void 0 : _a.call(this);
|
|
2096
|
+
const refreshStartMs = Date.now();
|
|
2097
|
+
this.log('wax key refresh started');
|
|
2098
|
+
this._waxRefreshing = this._storedFetchWaxKey(newSessionId)
|
|
2099
|
+
.then(newWaxKey => {
|
|
2100
|
+
if (typeof newWaxKey !== 'string' || !newWaxKey.trim()) {
|
|
2101
|
+
throw new OzError('fetchWaxKey returned an empty string during auto-refresh.', undefined, 'auth');
|
|
2102
|
+
}
|
|
2103
|
+
if (!this._destroyed) {
|
|
2104
|
+
this.waxKey = newWaxKey;
|
|
2105
|
+
this.tokenizationSessionId = newSessionId;
|
|
2106
|
+
this._tokenizeSuccessCount = 0;
|
|
2107
|
+
}
|
|
2108
|
+
if (!this._destroyed && this.tokenizerReady) {
|
|
2109
|
+
this.sendToTokenizer({ type: 'OZ_INIT', frameId: '__tokenizer__', waxKey: newWaxKey, debug: this._debug });
|
|
2110
|
+
}
|
|
2111
|
+
this.log('wax key refresh succeeded', { durationMs: Date.now() - refreshStartMs });
|
|
2112
|
+
})
|
|
2113
|
+
.catch((err) => {
|
|
2114
|
+
this.log('wax key refresh failed', { error: err instanceof Error ? err.message : String(err), durationMs: Date.now() - refreshStartMs });
|
|
2115
|
+
throw err;
|
|
2116
|
+
})
|
|
2117
|
+
.finally(() => {
|
|
2118
|
+
this._waxRefreshing = null;
|
|
2119
|
+
});
|
|
2120
|
+
return this._waxRefreshing;
|
|
2121
|
+
}
|
|
2122
|
+
sendToTokenizer(data, transfer) {
|
|
2123
|
+
var _a;
|
|
2124
|
+
const msg = Object.assign({ __oz: true, vaultId: this.vaultId }, data);
|
|
2125
|
+
(_a = this.tokenizerWindow) === null || _a === void 0 ? void 0 : _a.postMessage(msg, this.frameOrigin, transfer !== null && transfer !== void 0 ? transfer : []);
|
|
2126
|
+
}
|
|
2127
|
+
}
|
|
2128
|
+
|
|
2129
|
+
/**
|
|
2130
|
+
* @ozura/elements/vue — Vue 3 wrapper for OzElements.
|
|
2131
|
+
*
|
|
2132
|
+
* Provides a context-based provider that creates and manages an OzVault instance,
|
|
2133
|
+
* five individual field components, and a composable to access createToken +
|
|
2134
|
+
* readiness state from anywhere inside the provider tree.
|
|
2135
|
+
*
|
|
2136
|
+
* Quick start:
|
|
2137
|
+
* @example
|
|
2138
|
+
* ```vue
|
|
2139
|
+
* <script setup lang="ts">
|
|
2140
|
+
* import { OzElements, OzCardNumber, OzExpiry, OzCvv, useOzElements } from '@ozura/elements/vue';
|
|
2141
|
+
* const { createToken, ready } = useOzElements();
|
|
2142
|
+
* </script>
|
|
2143
|
+
*
|
|
2144
|
+
* <template>
|
|
2145
|
+
* <OzElements pub-key="pk_live_..." session-url="/api/oz-session">
|
|
2146
|
+
* <OzCardNumber />
|
|
2147
|
+
* <OzExpiry />
|
|
2148
|
+
* <OzCvv />
|
|
2149
|
+
* <button :disabled="!ready" @click="createToken()">Pay</button>
|
|
2150
|
+
* </OzElements>
|
|
2151
|
+
* </template>
|
|
2152
|
+
* ```
|
|
2153
|
+
*/
|
|
2154
|
+
const OZ_KEY = Symbol('oz-elements');
|
|
2155
|
+
// ─── Provider ─────────────────────────────────────────────────────────────────
|
|
2156
|
+
/**
|
|
2157
|
+
* Creates and owns an OzVault instance for the lifetime of this component.
|
|
2158
|
+
* All field components must be rendered inside this provider.
|
|
2159
|
+
*/
|
|
2160
|
+
const OzElements = defineComponent({
|
|
2161
|
+
name: 'OzElements',
|
|
2162
|
+
props: {
|
|
2163
|
+
pubKey: { type: String, required: true },
|
|
2164
|
+
sessionUrl: { type: String, default: undefined },
|
|
2165
|
+
getSessionKey: { type: Function, default: undefined },
|
|
2166
|
+
frameBaseUrl: { type: String, default: undefined },
|
|
2167
|
+
fonts: { type: Array, default: undefined },
|
|
2168
|
+
appearance: { type: Object, default: undefined },
|
|
2169
|
+
loadTimeoutMs: { type: Number, default: undefined },
|
|
2170
|
+
debug: { type: Boolean, default: undefined },
|
|
2171
|
+
},
|
|
2172
|
+
emits: ['ready'],
|
|
2173
|
+
setup(props, { slots, emit }) {
|
|
2174
|
+
const vault = shallowRef(null);
|
|
2175
|
+
const initError = ref(null);
|
|
2176
|
+
const mountedCount = ref(0);
|
|
2177
|
+
const readyCount = ref(0);
|
|
2178
|
+
const tokenizeCount = ref(0);
|
|
2179
|
+
const notifyMount = () => { mountedCount.value++; };
|
|
2180
|
+
let readyEmitted = false;
|
|
2181
|
+
const notifyReady = () => {
|
|
2182
|
+
readyCount.value++;
|
|
2183
|
+
if (!readyEmitted && mountedCount.value > 0 && readyCount.value >= mountedCount.value) {
|
|
2184
|
+
readyEmitted = true;
|
|
2185
|
+
emit('ready');
|
|
2186
|
+
}
|
|
2187
|
+
};
|
|
2188
|
+
const notifyUnmount = () => {
|
|
2189
|
+
mountedCount.value = Math.max(0, mountedCount.value - 1);
|
|
2190
|
+
readyCount.value = Math.max(0, readyCount.value - 1);
|
|
2191
|
+
};
|
|
2192
|
+
const notifyTokenize = () => { tokenizeCount.value++; };
|
|
2193
|
+
provide(OZ_KEY, {
|
|
2194
|
+
vault,
|
|
2195
|
+
initError,
|
|
2196
|
+
mountedCount,
|
|
2197
|
+
readyCount,
|
|
2198
|
+
tokenizeCount,
|
|
2199
|
+
notifyMount,
|
|
2200
|
+
notifyReady,
|
|
2201
|
+
notifyUnmount,
|
|
2202
|
+
notifyTokenize,
|
|
2203
|
+
});
|
|
2204
|
+
let createdVault = null;
|
|
2205
|
+
let abortController = null;
|
|
2206
|
+
onMounted(() => {
|
|
2207
|
+
const ac = new AbortController();
|
|
2208
|
+
abortController = ac;
|
|
2209
|
+
OzVault.create(Object.assign(Object.assign(Object.assign(Object.assign(Object.assign(Object.assign(Object.assign({ pubKey: props.pubKey }, (props.sessionUrl ? { sessionUrl: props.sessionUrl } : {})), (props.getSessionKey ? { getSessionKey: props.getSessionKey } : {})), (props.frameBaseUrl ? { frameBaseUrl: props.frameBaseUrl } : {})), (props.fonts ? { fonts: props.fonts } : {})), (props.appearance ? { appearance: props.appearance } : {})), (props.loadTimeoutMs !== undefined ? { loadTimeoutMs: props.loadTimeoutMs } : {})), (props.debug ? { debug: props.debug } : {})), ac.signal).then(v => {
|
|
2210
|
+
if (ac.signal.aborted) {
|
|
2211
|
+
v.destroy();
|
|
2212
|
+
return;
|
|
2213
|
+
}
|
|
2214
|
+
createdVault = v;
|
|
2215
|
+
vault.value = v;
|
|
2216
|
+
initError.value = null;
|
|
2217
|
+
}).catch((err) => {
|
|
2218
|
+
if (ac.signal.aborted)
|
|
2219
|
+
return;
|
|
2220
|
+
initError.value = err instanceof Error ? err : new Error('OzVault.create() failed.');
|
|
2221
|
+
});
|
|
2222
|
+
});
|
|
2223
|
+
onUnmounted(() => {
|
|
2224
|
+
abortController === null || abortController === void 0 ? void 0 : abortController.abort();
|
|
2225
|
+
createdVault === null || createdVault === void 0 ? void 0 : createdVault.destroy();
|
|
2226
|
+
vault.value = null;
|
|
2227
|
+
});
|
|
2228
|
+
return () => { var _a; return (_a = slots.default) === null || _a === void 0 ? void 0 : _a.call(slots); };
|
|
2229
|
+
},
|
|
2230
|
+
});
|
|
2231
|
+
/**
|
|
2232
|
+
* Returns createToken, createBankToken, ready, initError, tokenizeCount, and reset.
|
|
2233
|
+
* Must be called from inside an <OzElements> provider tree.
|
|
2234
|
+
*
|
|
2235
|
+
* @throws {Error} if called outside an <OzElements> provider
|
|
2236
|
+
*/
|
|
2237
|
+
function useOzElements() {
|
|
2238
|
+
const ctx = inject(OZ_KEY);
|
|
2239
|
+
if (!ctx) {
|
|
2240
|
+
throw new Error('[OzVault] useOzElements() must be called inside <OzElements>');
|
|
2241
|
+
}
|
|
2242
|
+
const { vault, initError, mountedCount, readyCount, tokenizeCount, notifyTokenize } = ctx;
|
|
2243
|
+
const ready = computed(() => vault.value !== null &&
|
|
2244
|
+
vault.value.isReady &&
|
|
2245
|
+
mountedCount.value > 0 &&
|
|
2246
|
+
readyCount.value >= mountedCount.value);
|
|
2247
|
+
const createToken = async (options) => {
|
|
2248
|
+
if (!vault.value) {
|
|
2249
|
+
throw new Error('[OzVault] vault is not ready — wait for ready before calling createToken()');
|
|
2250
|
+
}
|
|
2251
|
+
const result = await vault.value.createToken(options);
|
|
2252
|
+
notifyTokenize();
|
|
2253
|
+
return result;
|
|
2254
|
+
};
|
|
2255
|
+
const createBankToken = async (options) => {
|
|
2256
|
+
if (!vault.value) {
|
|
2257
|
+
throw new Error('[OzVault] vault is not ready — wait for ready before calling createBankToken()');
|
|
2258
|
+
}
|
|
2259
|
+
const result = await vault.value.createBankToken(options);
|
|
2260
|
+
notifyTokenize();
|
|
2261
|
+
return result;
|
|
2262
|
+
};
|
|
2263
|
+
const reset = () => { var _a; (_a = vault.value) === null || _a === void 0 ? void 0 : _a.reset(); };
|
|
2264
|
+
return { createToken, createBankToken, ready, initError, tokenizeCount, reset };
|
|
2265
|
+
}
|
|
2266
|
+
function createFieldComponent(displayName, mountElement) {
|
|
2267
|
+
return defineComponent({
|
|
2268
|
+
name: displayName,
|
|
2269
|
+
props: {
|
|
2270
|
+
placeholder: { type: String, default: undefined },
|
|
2271
|
+
disabled: { type: Boolean, default: undefined },
|
|
2272
|
+
style: { type: Object, default: undefined },
|
|
2273
|
+
},
|
|
2274
|
+
emits: ['change', 'focus', 'blur'],
|
|
2275
|
+
setup(props, { emit }) {
|
|
2276
|
+
const ctx = inject(OZ_KEY);
|
|
2277
|
+
if (!ctx) {
|
|
2278
|
+
throw new Error('[OzVault] useOzElements() must be called inside <OzElements>');
|
|
2279
|
+
}
|
|
2280
|
+
const { vault, notifyMount, notifyReady, notifyUnmount } = ctx;
|
|
2281
|
+
const containerRef = ref(null);
|
|
2282
|
+
let element = null;
|
|
2283
|
+
let notifyMountCalled = false;
|
|
2284
|
+
watch(vault, (v) => {
|
|
2285
|
+
if (!v || !containerRef.value || element)
|
|
2286
|
+
return;
|
|
2287
|
+
element = mountElement(v, {
|
|
2288
|
+
style: props.style,
|
|
2289
|
+
placeholder: props.placeholder,
|
|
2290
|
+
disabled: props.disabled,
|
|
2291
|
+
});
|
|
2292
|
+
notifyMountCalled = true;
|
|
2293
|
+
notifyMount();
|
|
2294
|
+
element.on('ready', () => notifyReady());
|
|
2295
|
+
element.on('change', (e) => emit('change', e));
|
|
2296
|
+
element.on('focus', () => emit('focus'));
|
|
2297
|
+
element.on('blur', () => emit('blur'));
|
|
2298
|
+
element.mount(containerRef.value);
|
|
2299
|
+
}, { immediate: true });
|
|
2300
|
+
onUnmounted(() => {
|
|
2301
|
+
element === null || element === void 0 ? void 0 : element.destroy();
|
|
2302
|
+
element = null;
|
|
2303
|
+
if (notifyMountCalled)
|
|
2304
|
+
notifyUnmount();
|
|
2305
|
+
});
|
|
2306
|
+
watch(() => ({
|
|
2307
|
+
style: props.style,
|
|
2308
|
+
placeholder: props.placeholder,
|
|
2309
|
+
disabled: props.disabled,
|
|
2310
|
+
}), (opts) => { element === null || element === void 0 ? void 0 : element.update(opts); }, { deep: true });
|
|
2311
|
+
return () => h('div', { ref: containerRef });
|
|
2312
|
+
},
|
|
2313
|
+
});
|
|
2314
|
+
}
|
|
2315
|
+
/** Card number field. Emits `change` (ElementChangeEvent), `focus`, `blur`. */
|
|
2316
|
+
const OzCardNumber = createFieldComponent('OzCardNumber', (v, opts) => v.createElement('cardNumber', opts));
|
|
2317
|
+
/** Expiry date field. Emits `change` (ElementChangeEvent), `focus`, `blur`. */
|
|
2318
|
+
const OzExpiry = createFieldComponent('OzExpiry', (v, opts) => v.createElement('expirationDate', opts));
|
|
2319
|
+
/** CVV / CVC field. Emits `change` (ElementChangeEvent), `focus`, `blur`. */
|
|
2320
|
+
const OzCvv = createFieldComponent('OzCvv', (v, opts) => v.createElement('cvv', opts));
|
|
2321
|
+
/** Bank account number field. Emits `change` (ElementChangeEvent), `focus`, `blur`. */
|
|
2322
|
+
const OzBankAccountNumber = createFieldComponent('OzBankAccountNumber', (v, opts) => v.createBankElement('accountNumber', opts));
|
|
2323
|
+
/** Bank routing number field. Emits `change` (ElementChangeEvent), `focus`, `blur`. */
|
|
2324
|
+
const OzBankRoutingNumber = createFieldComponent('OzBankRoutingNumber', (v, opts) => v.createBankElement('routingNumber', opts));
|
|
2325
|
+
|
|
2326
|
+
export { OzBankAccountNumber, OzBankRoutingNumber, OzCardNumber, OzCvv, OzElements, OzExpiry, useOzElements };
|
|
2327
|
+
//# sourceMappingURL=index.esm.js.map
|