@ozura/elements 0.1.0-beta.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (55) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +717 -0
  3. package/dist/frame/element-frame.html +22 -0
  4. package/dist/frame/element-frame.js +731 -0
  5. package/dist/frame/element-frame.js.map +1 -0
  6. package/dist/frame/tokenizer-frame.html +11 -0
  7. package/dist/frame/tokenizer-frame.js +328 -0
  8. package/dist/frame/tokenizer-frame.js.map +1 -0
  9. package/dist/oz-elements.esm.js +1190 -0
  10. package/dist/oz-elements.esm.js.map +1 -0
  11. package/dist/oz-elements.umd.js +1202 -0
  12. package/dist/oz-elements.umd.js.map +1 -0
  13. package/dist/react/frame/elementFrame.d.ts +8 -0
  14. package/dist/react/frame/tokenizerFrame.d.ts +13 -0
  15. package/dist/react/index.cjs.js +1407 -0
  16. package/dist/react/index.cjs.js.map +1 -0
  17. package/dist/react/index.esm.js +1400 -0
  18. package/dist/react/index.esm.js.map +1 -0
  19. package/dist/react/react/index.d.ts +214 -0
  20. package/dist/react/sdk/OzElement.d.ts +65 -0
  21. package/dist/react/sdk/OzVault.d.ts +106 -0
  22. package/dist/react/sdk/errors.d.ts +55 -0
  23. package/dist/react/sdk/index.d.ts +5 -0
  24. package/dist/react/server/index.d.ts +140 -0
  25. package/dist/react/types/index.d.ts +432 -0
  26. package/dist/react/utils/appearance.d.ts +13 -0
  27. package/dist/react/utils/billingUtils.d.ts +60 -0
  28. package/dist/react/utils/cardUtils.d.ts +37 -0
  29. package/dist/server/frame/elementFrame.d.ts +8 -0
  30. package/dist/server/frame/tokenizerFrame.d.ts +13 -0
  31. package/dist/server/index.cjs.js +294 -0
  32. package/dist/server/index.cjs.js.map +1 -0
  33. package/dist/server/index.esm.js +290 -0
  34. package/dist/server/index.esm.js.map +1 -0
  35. package/dist/server/sdk/OzElement.d.ts +65 -0
  36. package/dist/server/sdk/OzVault.d.ts +106 -0
  37. package/dist/server/sdk/errors.d.ts +55 -0
  38. package/dist/server/sdk/index.d.ts +5 -0
  39. package/dist/server/server/index.d.ts +140 -0
  40. package/dist/server/types/index.d.ts +432 -0
  41. package/dist/server/utils/appearance.d.ts +13 -0
  42. package/dist/server/utils/billingUtils.d.ts +60 -0
  43. package/dist/server/utils/cardUtils.d.ts +37 -0
  44. package/dist/types/frame/elementFrame.d.ts +8 -0
  45. package/dist/types/frame/tokenizerFrame.d.ts +13 -0
  46. package/dist/types/sdk/OzElement.d.ts +65 -0
  47. package/dist/types/sdk/OzVault.d.ts +106 -0
  48. package/dist/types/sdk/errors.d.ts +55 -0
  49. package/dist/types/sdk/index.d.ts +5 -0
  50. package/dist/types/server/index.d.ts +140 -0
  51. package/dist/types/types/index.d.ts +432 -0
  52. package/dist/types/utils/appearance.d.ts +13 -0
  53. package/dist/types/utils/billingUtils.d.ts +60 -0
  54. package/dist/types/utils/cardUtils.d.ts +37 -0
  55. package/package.json +97 -0
package/README.md ADDED
@@ -0,0 +1,717 @@
1
+ # OzElements
2
+
3
+ PCI-compliant card tokenization SDK for the Ozura Vault.
4
+
5
+ OzElements renders secure, iframe-isolated card input fields on your page and tokenizes card data directly to the Ozura Vault. Your JavaScript never touches raw card numbers, CVVs, or expiry dates — the iframes handle everything. You get back a token. Use it with your own payment processor, your own backend systems, or with the Ozura Pay API.
6
+
7
+ All you need is a vault API key.
8
+
9
+ ---
10
+
11
+ ## Why this exists
12
+
13
+ If you need to collect card data, you have a PCI compliance problem. OzElements solves it.
14
+
15
+ The Ozura Vault stores and tokenizes sensitive card data. OzElements is the frontend SDK for the vault — it gives you drop-in card fields that are fully isolated inside Ozura-controlled iframes. Your page never sees raw card data, which keeps your PCI scope minimal (SAQ-A).
16
+
17
+ **You don't need to be an OzuraPay merchant to use this.** You just need a vault API key. Once you have a token, you can use it however you want — with your own processor, your own systems, or optionally with the Ozura Pay API.
18
+
19
+ ---
20
+
21
+ ## How it works
22
+
23
+ ```
24
+ Your page (yoursite.com)
25
+
26
+ │ import { OzVault } from 'oz-elements'
27
+
28
+ │ const vault = new OzVault('vault_api_key');
29
+
30
+ │ const cardNumber = vault.createElement('cardNumber', { style: { ... } });
31
+ │ cardNumber.mount('#card-number');
32
+ │ cardNumber.on('change', ({ valid, cardBrand }) => { ... });
33
+
34
+ │ const { token, cvcSession } = await vault.createToken();
35
+ │ // token is yours — use it with any system
36
+
37
+ ├── <iframe src="https://elements.ozura.com/frame/element-frame.html">
38
+ │ <input type="tel" /> ← your JS cannot access this
39
+ │ </iframe>
40
+
41
+ └── <iframe style="display:none" src="https://elements.ozura.com/frame/tokenizer-frame.html">
42
+ Receives raw values from element iframes (same-origin).
43
+ POSTs directly to vault /tokenize.
44
+ Returns token + cvcSession to the SDK.
45
+ </iframe>
46
+ ```
47
+
48
+ Raw card data travels one path: element iframe → tokenizer iframe (direct same-origin `postMessage`). Your SDK only ever sees metadata (valid/complete/cardBrand) and the final token.
49
+
50
+ ---
51
+
52
+ ## Installation
53
+
54
+ ```bash
55
+ npm install oz-elements
56
+ ```
57
+
58
+ Or via script tag (UMD):
59
+
60
+ ```html
61
+ <script src="https://cdn.ozura.com/oz-elements/oz-elements.umd.js"></script>
62
+ ```
63
+
64
+ ---
65
+
66
+ ## Quick start
67
+
68
+ ```js
69
+ import { OzVault } from 'oz-elements';
70
+
71
+ const vault = new OzVault('your_vault_api_key', {
72
+ pubKey: 'your_pub_key',
73
+ });
74
+
75
+ const cardNumber = vault.createElement('cardNumber', {
76
+ style: {
77
+ base: { color: '#1a1a2e', fontSize: '16px', fontFamily: 'Inter, sans-serif' },
78
+ focus: { color: '#1a1a2e' },
79
+ invalid: { color: '#dc2626' },
80
+ },
81
+ });
82
+
83
+ const expiry = vault.createElement('expirationDate');
84
+ const cvv = vault.createElement('cvv');
85
+
86
+ cardNumber.mount('#card-number');
87
+ expiry.mount('#expiry');
88
+ cvv.mount('#cvv');
89
+
90
+ cardNumber.on('change', ({ valid, complete, cardBrand }) => {
91
+ console.log(cardBrand); // 'visa' | 'mastercard' | 'amex' | ...
92
+ });
93
+
94
+ document.getElementById('tokenize').addEventListener('click', async () => {
95
+ const { token, cvcSession } = await vault.createToken();
96
+
97
+ console.log('Token:', token);
98
+ console.log('CVC Session:', cvcSession);
99
+
100
+ // The token is yours — send it to your backend and use it however you want:
101
+ // • With your own payment processor
102
+ // • With the Ozura Pay API (see "OzuraPay integration" below)
103
+ // • With any system that accepts tokenized card data
104
+ await fetch('/api/tokenized', {
105
+ method: 'POST',
106
+ headers: { 'Content-Type': 'application/json' },
107
+ body: JSON.stringify({ token, cvcSession }),
108
+ });
109
+ });
110
+ ```
111
+
112
+ ---
113
+
114
+ ## API
115
+
116
+ ### `new OzVault(apiKey, options)`
117
+
118
+ | Option | Type | Default | Description |
119
+ |---|---|---|---|
120
+ | `apiKey` | `string` | required | Your Ozura vault API key |
121
+ | `options.pubKey` | `string` | required | System pub key for the tokenize endpoint. Obtain from Ozura admin. |
122
+ | `options.apiUrl` | `string` | Ozura prod vault | Vault base URL |
123
+ | `options.frameBaseUrl` | `string` | `https://elements.ozura.com` | Where iframe assets are served from |
124
+
125
+ ### `vault.createElement(type, options?)`
126
+
127
+ `type`: `'cardNumber'` | `'cvv'` | `'expirationDate'`
128
+
129
+ | Option | Type | Description |
130
+ |---|---|---|
131
+ | `style.base` | `object` | CSS properties applied by default |
132
+ | `style.focus` | `object` | CSS properties applied on focus |
133
+ | `style.invalid` | `object` | CSS properties applied when input is invalid |
134
+ | `placeholder` | `string` | Override default placeholder text |
135
+ | `disabled` | `boolean` | Disable the input |
136
+
137
+ ### `vault.createToken(options?)`
138
+
139
+ Triggers tokenization across all mounted elements. Returns a Promise with the vault token.
140
+
141
+ ```ts
142
+ // Minimal — just tokenize the card data
143
+ const { token, cvcSession } = await vault.createToken();
144
+
145
+ // With billing — validates and normalizes billing details alongside tokenization.
146
+ // Useful if you plan to pass billing to a payment API (e.g. Ozura Pay API).
147
+ const { token, cvcSession, billing } = await vault.createToken({
148
+ billing: {
149
+ firstName: string; // required, 1–50 chars
150
+ lastName: string; // required, 1–50 chars
151
+ email?: string; // valid address, max 50 chars
152
+ phone?: string; // E.164 format e.g. "+15551234567"
153
+ address?: {
154
+ line1: string; // required if address provided
155
+ line2?: string; // omitted from output if blank
156
+ city: string;
157
+ state: string; // "California" auto-normalised → "CA" for US/CA
158
+ zip: string;
159
+ country: string; // ISO 3166-1 alpha-2, e.g. "US"
160
+ };
161
+ };
162
+
163
+ // Deprecated — use billing.firstName/lastName instead:
164
+ firstName?: string;
165
+ lastName?: string;
166
+ });
167
+ ```
168
+
169
+ `TokenResponse` fields:
170
+
171
+ | Field | Description |
172
+ |---|---|
173
+ | `token` | Vault token referencing the stored card data |
174
+ | `cvcSession` | CVC session ID for use with payment APIs |
175
+ | `billing` | Validated, normalized billing details (only present when `billing` was passed) |
176
+
177
+ **Validation** thrown as `OzError` before any network call: empty firstName/lastName, fields > 50 chars, invalid email format, non-E.164 phone, address fields 0 or > 50 chars.
178
+
179
+ ### `vault.isReady`
180
+
181
+ `boolean` — `true` once the hidden tokenizer iframe has loaded. For React, use the `ready` value from `useOzElements()` instead (which also tracks field iframe readiness).
182
+
183
+ ### `vault.destroy()`
184
+
185
+ Tears down the vault completely: removes the global message listener, rejects any in-flight `createToken()` promises, unmounts all element iframes, and removes the tokenizer iframe. Call this when your checkout component unmounts.
186
+
187
+ ```js
188
+ // React useEffect cleanup / SPA route change
189
+ return () => vault.destroy();
190
+ ```
191
+
192
+ ### `element.mount(target)`
193
+
194
+ Injects the element iframe into a container. Accepts either a CSS selector string or a direct `HTMLElement` reference (required when using React refs).
195
+
196
+ ```js
197
+ element.mount('#card-number'); // CSS selector
198
+ element.mount(containerRef.current); // HTMLElement (React)
199
+ ```
200
+
201
+ ### `element.unmount()`
202
+
203
+ Removes the iframe from the DOM and resets internal state. Called automatically by `vault.destroy()`. Safe to call manually when swapping payment methods in a SPA.
204
+
205
+ ### `element.on(event, callback)`
206
+
207
+ | Event | Payload | Description |
208
+ |---|---|---|
209
+ | `ready` | — | Iframe loaded and initialised |
210
+ | `change` | `ElementChangeEvent` | Input value changed |
211
+ | `focus` | — | Input focused |
212
+ | `blur` | — | Input blurred |
213
+
214
+ **`ElementChangeEvent` shape:**
215
+
216
+ | Field | Type | Present on |
217
+ |---|---|---|
218
+ | `empty` | `boolean` | all elements |
219
+ | `complete` | `boolean` | all elements |
220
+ | `valid` | `boolean` | all elements |
221
+ | `error` | `string \| undefined` | all — set when complete but invalid |
222
+ | `cardBrand` | `string \| undefined` | `cardNumber` only |
223
+ | `month` | `string \| undefined` | `expirationDate` only (`"01"`–`"12"`) |
224
+ | `year` | `string \| undefined` | `expirationDate` only (2-digit, e.g. `"27"`) |
225
+
226
+ ### `element.clear()`
227
+
228
+ Clears the current value in the iframe input and resets validation state.
229
+
230
+ ### `element.update(options)`
231
+
232
+ Updates `style`, `placeholder`, or `disabled` on a mounted element without remounting the iframe.
233
+
234
+ ---
235
+
236
+ ## React
237
+
238
+ ```bash
239
+ npm install oz-elements
240
+ ```
241
+
242
+ Import from the `/react` subpath:
243
+
244
+ ```tsx
245
+ import { OzElements, OzCardNumber, OzExpiry, OzCvv, useOzElements } from 'oz-elements/react';
246
+ ```
247
+
248
+ ### Example
249
+
250
+ ```tsx
251
+ import { useState } from 'react';
252
+ import { OzElements, OzCardNumber, OzExpiry, OzCvv, useOzElements } from 'oz-elements/react';
253
+ import { normalizeCardSaleError } from 'oz-elements';
254
+
255
+ const fieldStyle = {
256
+ base: { color: '#111827', fontSize: '16px', fontFamily: 'Inter, sans-serif' },
257
+ focus: { color: '#111827' },
258
+ invalid: { color: '#dc2626' },
259
+ };
260
+
261
+ function CheckoutForm() {
262
+ const { createToken, ready } = useOzElements();
263
+
264
+ const [form, setForm] = useState({
265
+ firstName: '', lastName: '', email: '', phone: '',
266
+ address1: '', city: '', state: '', zip: '', country: 'US',
267
+ });
268
+ const [error, setError] = useState('');
269
+
270
+ const set = (field: string) => (e: React.ChangeEvent<HTMLInputElement>) =>
271
+ setForm(f => ({ ...f, [field]: e.target.value }));
272
+
273
+ const handlePay = async () => {
274
+ setError('');
275
+ try {
276
+ const { token, cvcSession, billing } = await createToken({
277
+ billing: {
278
+ firstName: form.firstName,
279
+ lastName: form.lastName,
280
+ email: form.email || undefined,
281
+ phone: form.phone || undefined,
282
+ ...(form.address1 ? {
283
+ address: {
284
+ line1: form.address1,
285
+ city: form.city,
286
+ state: form.state,
287
+ zip: form.zip,
288
+ country: form.country.toUpperCase(),
289
+ },
290
+ } : {}),
291
+ },
292
+ });
293
+
294
+ // Send token + cvcSession + billing to your server → Ozura Pay API cardSale
295
+ const res = await fetch('/api/charge', {
296
+ method: 'POST',
297
+ headers: { 'Content-Type': 'application/json' },
298
+ body: JSON.stringify({ token, cvcSession, billing, amount: '49.00', currency: 'USD' }),
299
+ });
300
+
301
+ if (!res.ok) {
302
+ const data = await res.json();
303
+ setError(normalizeCardSaleError(data.error ?? 'Payment failed'));
304
+ }
305
+ } catch (err: unknown) {
306
+ setError(err instanceof Error ? err.message : 'An error occurred');
307
+ }
308
+ };
309
+
310
+ return (
311
+ <div>
312
+ {/* Billing fields — plain inputs, not PCI-sensitive */}
313
+ <input placeholder="First name" value={form.firstName} onChange={set('firstName')} />
314
+ <input placeholder="Last name" value={form.lastName} onChange={set('lastName')} />
315
+ <input placeholder="Email" value={form.email} onChange={set('email')} />
316
+ <input placeholder="Phone (+15551234567)" value={form.phone} onChange={set('phone')} />
317
+ <input placeholder="Street address" value={form.address1} onChange={set('address1')} />
318
+ <input placeholder="City" value={form.city} onChange={set('city')} />
319
+ <input placeholder="State" value={form.state} onChange={set('state')} />
320
+ <input placeholder="ZIP" value={form.zip} onChange={set('zip')} />
321
+ <input placeholder="Country (US)" value={form.country} onChange={set('country')} maxLength={2} />
322
+
323
+ {/* Card fields — iframe-isolated, raw card data never touches this component */}
324
+ <OzCardNumber style={fieldStyle} />
325
+ <OzExpiry style={fieldStyle} />
326
+ <OzCvv style={fieldStyle} />
327
+
328
+ {error && <p style={{ color: 'red' }}>{error}</p>}
329
+ <button onClick={handlePay} disabled={!ready}>Pay $49.00</button>
330
+ </div>
331
+ );
332
+ }
333
+
334
+ export default function App() {
335
+ return (
336
+ <OzElements
337
+ apiKey="your_vault_api_key"
338
+ pubKey="your_pub_key"
339
+ frameBaseUrl="https://elements.ozura.com"
340
+ >
341
+ <CheckoutForm />
342
+ </OzElements>
343
+ );
344
+ }
345
+ ```
346
+
347
+ ### `<OzElements>` props
348
+
349
+ | Prop | Type | Description |
350
+ |---|---|---|
351
+ | `apiKey` | `string` | Your Ozura vault API key |
352
+ | `apiUrl` | `string?` | Vault API base URL |
353
+ | `frameBaseUrl` | `string?` | Where iframe assets are hosted |
354
+
355
+ ### `useOzElements()` return
356
+
357
+ | Value | Type | Description |
358
+ |---|---|---|
359
+ | `createToken` | `(opts?) => Promise<{ token, cvcSession }>` | Tokenizes all mounted fields |
360
+ | `ready` | `boolean` | `true` when all field iframes have loaded |
361
+
362
+ ### Field component props (`OzCardNumber`, `OzExpiry`, `OzCvv`)
363
+
364
+ | Prop | Type | Description |
365
+ |---|---|---|
366
+ | `style` | `ElementStyleConfig?` | `{ base, focus, invalid }` CSS property objects |
367
+ | `placeholder` | `string?` | Override default placeholder |
368
+ | `disabled` | `boolean?` | Disable the input |
369
+ | `onChange` | `(e: ElementChangeEvent) => void` | Keystroke callback |
370
+ | `onFocus` | `() => void` | Focus callback |
371
+ | `onBlur` | `() => void` | Blur callback |
372
+ | `onReady` | `() => void` | Iframe ready callback |
373
+ | `className` | `string?` | CSS class on the wrapper `<div>` |
374
+
375
+ ---
376
+
377
+ ## OzuraPay Integration (Optional)
378
+
379
+ If you're an OzuraPay merchant and want to charge cards through Ozura's processors, you can use your vault tokens with the Ozura Pay API. **You must be onboarded as an OzuraPay merchant first** — this gives you a merchant ID and a Pay API key, which are required alongside your vault API key.
380
+
381
+ | Credential | Where it goes |
382
+ |-----------|---------------|
383
+ | Vault API key | Frontend: `new OzVault(vaultApiKey)`. Backend: `vault-api-key` header |
384
+ | Pay API key | Backend only: `x-api-key` header |
385
+ | Merchant ID | Backend only: `merchantId` in request body |
386
+
387
+ For the full credential setup, billing form field requirements, validation rules, and constraints, see [DOCS-REFRAME.md](./DOCS-REFRAME.md#using-tokens-with-ozurapay--prerequisites-and-credentials).
388
+
389
+ ### 1 — Collect card data + billing in one call
390
+
391
+ ```js
392
+ import { OzVault, normalizeCardSaleError } from 'oz-elements';
393
+
394
+ const vault = new OzVault('vault_api_key_here', {
395
+ pubKey: 'your_pub_key',
396
+ });
397
+
398
+ // … mount card elements …
399
+
400
+ async function handlePay(formValues) {
401
+ let tokenResult;
402
+ try {
403
+ tokenResult = await vault.createToken({
404
+ billing: {
405
+ firstName: formValues.firstName, // validated 1–50 chars
406
+ lastName: formValues.lastName,
407
+ email: formValues.email, // validated email format
408
+ phone: formValues.phone, // must be E.164: "+15551234567"
409
+ address: {
410
+ line1: formValues.address1,
411
+ line2: formValues.address2, // omitted from output if blank
412
+ city: formValues.city,
413
+ state: formValues.state, // "California" → "CA" auto-normalized
414
+ zip: formValues.zip,
415
+ country: formValues.country, // "US", "CA", "GB" …
416
+ },
417
+ },
418
+ });
419
+ } catch (err) {
420
+ // Validation errors thrown here — show directly to user
421
+ showError(err.message);
422
+ return;
423
+ }
424
+
425
+ const { token, cvcSession, billing } = tokenResult;
426
+
427
+ // 2 — Fetch client IP from your own server (never api.ipify.org — ad-blockers block it)
428
+ const ipRes = await fetch('/api/client-ip');
429
+ const { ip } = await ipRes.json();
430
+
431
+ // 3 — POST to your server, which calls cardSale
432
+ const res = await fetch('/api/charge', {
433
+ method: 'POST',
434
+ headers: { 'Content-Type': 'application/json' },
435
+ body: JSON.stringify({
436
+ ozuraCardToken: token,
437
+ ozuraCvcSession: cvcSession,
438
+ billingFirstName: billing.firstName,
439
+ billingLastName: billing.lastName,
440
+ billingEmail: billing.email,
441
+ billingPhone: billing.phone, // already E.164
442
+ billingAddress1: billing.address.line1,
443
+ billingAddress2: billing.address.line2, // omit key if undefined
444
+ billingCity: billing.address.city,
445
+ billingState: billing.address.state, // already 2-letter
446
+ billingZipcode: billing.address.zip,
447
+ billingCountry: billing.address.country,
448
+ clientIpAddress: ip,
449
+ amount: '49.00',
450
+ currency: 'USD',
451
+ salesTaxExempt: false,
452
+ surchargePercent: '0.00',
453
+ tipAmount: '0.00',
454
+ }),
455
+ });
456
+
457
+ const data = await res.json();
458
+
459
+ if (!res.ok) {
460
+ showError(normalizeCardSaleError(data.error ?? res.statusText));
461
+ } else {
462
+ showSuccess(data.transactionId);
463
+ }
464
+ }
465
+ ```
466
+
467
+ ### 2 — Server-side cardSale call
468
+
469
+ Your server proxies to the Ozura Pay API with **two API keys** in the headers:
470
+
471
+ ```js
472
+ // Node.js / Express example
473
+ import type { CardSaleRequest, CardSaleApiResponse } from 'oz-elements';
474
+
475
+ app.post('/api/charge', async (req, res) => {
476
+ const body = req.body;
477
+
478
+ const payload: CardSaleRequest = {
479
+ processor: 'elavon',
480
+ merchantId: process.env.MERCHANT_ID,
481
+ amount: body.amount,
482
+ currency: body.currency,
483
+ ozuraCardToken: body.ozuraCardToken,
484
+ ozuraCvcSession: body.ozuraCvcSession,
485
+ billingFirstName: body.billingFirstName,
486
+ billingLastName: body.billingLastName,
487
+ billingEmail: body.billingEmail,
488
+ billingPhone: body.billingPhone,
489
+ billingAddress1: body.billingAddress1,
490
+ ...(body.billingAddress2 ? { billingAddress2: body.billingAddress2 } : {}),
491
+ billingCity: body.billingCity,
492
+ billingState: body.billingState,
493
+ billingZipcode: body.billingZipcode,
494
+ billingCountry: body.billingCountry,
495
+ clientIpAddress: body.clientIpAddress,
496
+ salesTaxExempt: body.salesTaxExempt,
497
+ surchargePercent: '0.00',
498
+ tipAmount: '0.00',
499
+ };
500
+
501
+ const response = await fetch(`${process.env.OZURA_API_URL}/api/v1/cardSale`, {
502
+ method: 'POST',
503
+ headers: {
504
+ 'Content-Type': 'application/json',
505
+ 'x-api-key': process.env.MERCHANT_API_KEY, // merchant Pay API key
506
+ 'vault-api-key': process.env.VAULT_API_KEY, // same key used in OzVault
507
+ },
508
+ body: JSON.stringify(payload),
509
+ signal: AbortSignal.timeout(30_000),
510
+ });
511
+
512
+ if (!response.ok) {
513
+ const err = await response.json().catch(() => ({}));
514
+ // Pass err.error through normalizeCardSaleError on the client side
515
+ return res.status(response.status).json({ error: err.error ?? 'cardSale failed' });
516
+ }
517
+
518
+ const result: CardSaleApiResponse = await response.json();
519
+
520
+ if (!result.success) {
521
+ return res.status(402).json({ error: 'Payment was not successful' });
522
+ }
523
+
524
+ const txn = result.data;
525
+
526
+ // ⚠️ Store txn.transactionId in your database NOW — before responding to the client.
527
+ // You need it for refunds and to prevent duplicate charges on network retry.
528
+ await db.transactions.insertOne({
529
+ transactionId: txn.transactionId,
530
+ amount: txn.amount,
531
+ currency: txn.currency,
532
+ cardLastFour: txn.cardLastFour,
533
+ cardBrand: txn.cardBrand,
534
+ cardExpMonth: txn.cardExpMonth,
535
+ cardExpYear: txn.cardExpYear,
536
+ billingName: `${txn.billingFirstName} ${txn.billingLastName}`,
537
+ transDate: txn.transDate,
538
+ createdAt: new Date(),
539
+ });
540
+
541
+ res.json({ transactionId: txn.transactionId, cardLastFour: txn.cardLastFour });
542
+ });
543
+ ```
544
+
545
+ ### 3 — cardSale response shape
546
+
547
+ ```ts
548
+ import type { CardSaleApiResponse, CardSaleResponseData } from 'oz-elements';
549
+
550
+ // Successful response shape from POST /api/v1/cardSale:
551
+ // {
552
+ // success: true,
553
+ // data: {
554
+ // transactionId: "txn_...", ← store this immediately
555
+ // amount: "49.00",
556
+ // currency: "USD",
557
+ // surchargeAmount:"0.00",
558
+ // tipAmount: "0.00",
559
+ // cardLastFour: "4242",
560
+ // cardExpMonth: "09",
561
+ // cardExpYear: "2027", ← 4-digit year
562
+ // cardBrand: "visa",
563
+ // transDate: "2026-02-20T...",
564
+ // ozuraMerchantId:"...",
565
+ // billingFirstName, billingLastName, billingEmail, billingPhone,
566
+ // billingAddress1, billingAddress2?, billingCity, billingState,
567
+ // billingZipcode, billingCountry
568
+ // }
569
+ // }
570
+ //
571
+ // Error response (4xx/5xx): { error: string }
572
+ // → pass error to normalizeCardSaleError() for a user-facing message
573
+ ```
574
+
575
+ ### 3 — Client IP endpoint
576
+
577
+ The Pay API requires a real IP address. Fetch it server-side — do **not** use `api.ipify.org` from the client (blocked by most ad-blockers):
578
+
579
+ ```js
580
+ app.get('/api/client-ip', (req, res) => {
581
+ const ip =
582
+ req.headers['x-forwarded-for']?.split(',')[0].trim() ??
583
+ req.socket.remoteAddress ??
584
+ '0.0.0.0';
585
+ res.json({ ip });
586
+ });
587
+ ```
588
+
589
+ ### `normalizeCardSaleError(raw)`
590
+
591
+ Maps raw Pay API error strings to user-friendly copy. Uses the same key table as the Ozura checkout frontend — identical errors produce identical messages on both surfaces:
592
+
593
+ ```js
594
+ import { normalizeCardSaleError } from 'oz-elements';
595
+
596
+ normalizeCardSaleError('Insufficient Funds')
597
+ // → "Your card has insufficient funds. Please use a different payment method."
598
+
599
+ normalizeCardSaleError('CVV Verification Failed')
600
+ // → "The CVV code you entered is incorrect. Please check and try again."
601
+
602
+ normalizeCardSaleError('Card expired')
603
+ // → "Your card has expired. Please use a different card."
604
+ ```
605
+
606
+ ### TypeScript: `CardSaleRequest`
607
+
608
+ Import the interface to type your cardSale payload and catch missing fields at compile time:
609
+
610
+ ```ts
611
+ import type { CardSaleRequest } from 'oz-elements';
612
+
613
+ const payload: CardSaleRequest = {
614
+ processor: 'elavon',
615
+ merchantId: '…',
616
+ amount: '49.00',
617
+ currency: 'USD',
618
+ ozuraCardToken: token,
619
+ ozuraCvcSession: cvcSession,
620
+ billingFirstName: billing.firstName,
621
+ billingLastName: billing.lastName,
622
+ billingEmail: billing.email!,
623
+ billingPhone: billing.phone!,
624
+ billingAddress1: billing.address!.line1,
625
+ billingCity: billing.address!.city,
626
+ billingState: billing.address!.state,
627
+ billingZipcode: billing.address!.zip,
628
+ billingCountry: billing.address!.country,
629
+ clientIpAddress: ip,
630
+ salesTaxExempt: false,
631
+ surchargePercent: '0.00',
632
+ tipAmount: '0.00',
633
+ };
634
+ ```
635
+
636
+ ---
637
+
638
+ ## Testing
639
+
640
+ ```bash
641
+ npm test # run once
642
+ npm run test:watch # watch mode
643
+ npm run test:coverage # with V8 coverage report
644
+ ```
645
+
646
+ The test suite covers: Luhn algorithm, card brand detection, card number formatting, expiry validation, and error normalisation.
647
+
648
+ ---
649
+
650
+ ## Development
651
+
652
+ ```bash
653
+ npm install
654
+ npm run dev # build + serve demo at http://localhost:4242/demo/index.html
655
+ npm run build # production build
656
+ npm run watch # rebuild on save
657
+ ```
658
+
659
+ Build outputs in `dist/`:
660
+
661
+ | File | Format | Use |
662
+ |---|---|---|
663
+ | `oz-elements.esm.js` | ESM | `import { OzVault } from 'oz-elements'` |
664
+ | `oz-elements.umd.js` | UMD | `<script>` tag / CDN |
665
+ | `frame/element-frame.js` | IIFE | loaded by element iframes |
666
+ | `frame/tokenizer-frame.js` | IIFE | loaded by tokenizer iframe |
667
+ | `react/index.esm.js` | ESM | `import ... from 'oz-elements/react'` |
668
+ | `react/index.cjs.js` | CJS | `require('oz-elements/react')` |
669
+
670
+ See [DEVELOPMENT.md](./DEVELOPMENT.md) for architecture detail, security model, and internal progress log.
671
+
672
+ ---
673
+
674
+ ## Roadmap
675
+
676
+ ### v0.1 — Core SDK ✅
677
+ - [x] Iframe-isolated card number, CVV, and expiration date elements
678
+ - [x] Luhn validation, real-time formatting, card brand detection
679
+ - [x] postMessage protocol between host SDK and iframes
680
+ - [x] Tokenizer iframe — assembles card data and POSTs directly to vault
681
+ - [x] Style customization (base / focus / invalid states)
682
+ - [x] Rollup build — ESM, UMD, and IIFE frame bundles
683
+ - [x] Working demo page
684
+ - [x] CORS resolved for dev environment (local proxy server)
685
+
686
+ ### v0.2 — Developer experience ✅
687
+ - [x] Card brand SVG icon inside the card number field (updates in real time)
688
+ - [x] Auto-advance focus: card number → expiry → CVV on completion
689
+ - [x] `element.clear()`, `element.update()`, `element.unmount()` fully wired
690
+ - [x] Field-level error strings in change events
691
+ - [x] `OzVault.destroy()` for full lifecycle cleanup
692
+ - [x] `OzVault.isReady` getter
693
+ - [x] `element.mount()` accepts `HTMLElement` as well as a CSS selector
694
+ - [x] Unit test suite (Vitest + jsdom) — 168 tests, 0 failures
695
+ - [ ] Published to npm as `oz-elements`
696
+ - [ ] CDN delivery for UMD build
697
+
698
+ ### v0.3 — React ✅
699
+ - [x] `<OzElements>` provider — creates and owns an `OzVault` instance
700
+ - [x] `<OzCardNumber />`, `<OzExpiry />`, `<OzCvv />` field components
701
+ - [x] `useOzElements()` hook — `createToken` + `ready` flag
702
+ - [x] ESM + CJS React bundle, exported at `oz-elements/react`
703
+ - [ ] Published as a standalone `@oz/react-elements` package
704
+
705
+ ### v1.0 — Production ready
706
+ - [x] Production iframe hosting at `elements.ozura.com`
707
+ - [ ] postMessage integration tests
708
+ - [ ] Security audit
709
+ - [ ] Public documentation site
710
+ - [ ] ACH / bank account element (pending Pay API support)
711
+ - [ ] Saved payment method support (returning customers)
712
+
713
+ ---
714
+
715
+ ## License
716
+
717
+ MIT — Copyright 2025 Ozura