@ozura/elements 0.1.0-beta.6 → 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1121 -720
- package/dist/frame/element-frame.js +77 -57
- package/dist/frame/element-frame.js.map +1 -1
- package/dist/frame/tokenizer-frame.html +1 -1
- package/dist/frame/tokenizer-frame.js +221 -74
- package/dist/frame/tokenizer-frame.js.map +1 -1
- package/dist/oz-elements.esm.js +870 -231
- package/dist/oz-elements.esm.js.map +1 -1
- package/dist/oz-elements.umd.js +870 -230
- package/dist/oz-elements.umd.js.map +1 -1
- package/dist/react/frame/tokenizerFrame.d.ts +32 -0
- package/dist/react/index.cjs.js +1045 -220
- package/dist/react/index.cjs.js.map +1 -1
- package/dist/react/index.esm.js +1042 -221
- package/dist/react/index.esm.js.map +1 -1
- package/dist/react/react/index.d.ts +165 -8
- package/dist/react/sdk/OzElement.d.ts +34 -3
- package/dist/react/sdk/OzVault.d.ts +104 -4
- package/dist/react/sdk/errors.d.ts +9 -0
- package/dist/react/sdk/index.d.ts +29 -0
- package/dist/react/server/index.d.ts +266 -2
- package/dist/react/types/index.d.ts +94 -16
- package/dist/react/utils/appearance.d.ts +9 -0
- package/dist/react/utils/cardUtils.d.ts +14 -0
- package/dist/react/utils/uuid.d.ts +12 -0
- package/dist/server/frame/tokenizerFrame.d.ts +32 -0
- package/dist/server/index.cjs.js +761 -30
- package/dist/server/index.cjs.js.map +1 -1
- package/dist/server/index.esm.js +757 -31
- package/dist/server/index.esm.js.map +1 -1
- package/dist/server/sdk/OzElement.d.ts +34 -3
- package/dist/server/sdk/OzVault.d.ts +104 -4
- package/dist/server/sdk/errors.d.ts +9 -0
- package/dist/server/sdk/index.d.ts +29 -0
- package/dist/server/server/index.d.ts +266 -2
- package/dist/server/types/index.d.ts +94 -16
- package/dist/server/utils/appearance.d.ts +9 -0
- package/dist/server/utils/cardUtils.d.ts +14 -0
- package/dist/server/utils/uuid.d.ts +12 -0
- package/dist/types/frame/tokenizerFrame.d.ts +32 -0
- package/dist/types/sdk/OzElement.d.ts +34 -3
- package/dist/types/sdk/OzVault.d.ts +104 -4
- package/dist/types/sdk/errors.d.ts +9 -0
- package/dist/types/sdk/index.d.ts +29 -0
- package/dist/types/server/index.d.ts +266 -2
- package/dist/types/types/index.d.ts +94 -16
- package/dist/types/utils/appearance.d.ts +9 -0
- package/dist/types/utils/cardUtils.d.ts +14 -0
- package/dist/types/utils/uuid.d.ts +12 -0
- package/package.json +7 -4
package/dist/server/index.cjs.js
CHANGED
|
@@ -1,5 +1,269 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
|
+
/**
|
|
4
|
+
* errors.ts — error types and normalisation for OzElements.
|
|
5
|
+
*
|
|
6
|
+
* Three normalisation functions:
|
|
7
|
+
* - normalizeVaultError — maps raw vault /tokenize errors to user-facing messages (card flows)
|
|
8
|
+
* - normalizeBankVaultError — maps raw vault /tokenize errors to user-facing messages (bank/ACH flows)
|
|
9
|
+
* - normalizeCardSaleError — maps raw cardSale API errors to user-facing messages
|
|
10
|
+
*
|
|
11
|
+
* Error keys in normalizeCardSaleError are taken directly from checkout's
|
|
12
|
+
* errorMapping.ts so the same error strings produce the same user-facing copy.
|
|
13
|
+
*/
|
|
14
|
+
// ─── cardSale error map (mirrors checkout/src/utils/errorMapping.ts exactly) ─
|
|
15
|
+
const CARD_SALE_ERROR_MAP = [
|
|
16
|
+
['Insufficient Funds', 'Your card has insufficient funds. Please use a different payment method.'],
|
|
17
|
+
['Invalid card number', 'The card number you entered is invalid. Please check and try again.'],
|
|
18
|
+
['Card expired', 'Your card has expired. Please use a different card.'],
|
|
19
|
+
['CVV Verification Failed', 'The CVV code you entered is incorrect. Please check and try again.'],
|
|
20
|
+
['Address Verification Failed', 'The billing address does not match your card. Please verify your address.'],
|
|
21
|
+
['Do Not Honor', 'Your card was declined. Please contact your bank or use a different payment method.'],
|
|
22
|
+
['Declined', 'Your card was declined. Please contact your bank or use a different payment method.'],
|
|
23
|
+
['Surcharge is currently not supported', 'Surcharge fees are not supported at this time.'],
|
|
24
|
+
['Surcharge percent must be between', 'Surcharge fees are not supported at this time.'],
|
|
25
|
+
['Forbidden - API key', 'Authentication failed. Please refresh the page.'],
|
|
26
|
+
['Api Key is invalid', 'Authentication failed. Please refresh the page.'],
|
|
27
|
+
['API key not found', 'Authentication failed. Please refresh the page.'],
|
|
28
|
+
['Access token expired', 'Your session has expired. Please refresh the page.'],
|
|
29
|
+
['Access token is invalid', 'Authentication failed. Please refresh the page.'],
|
|
30
|
+
['Unauthorized', 'Authentication failed. Please refresh the page.'],
|
|
31
|
+
['Too Many Requests', 'Too many requests. Please wait a moment and try again.'],
|
|
32
|
+
['Rate limit exceeded', 'System is busy. Please wait a moment and try again.'],
|
|
33
|
+
['No processor integrations found', 'Payment processing is not configured for this merchant. Please contact the merchant for assistance.'],
|
|
34
|
+
['processor integration', 'Payment processing is temporarily unavailable. Please try again later or contact the merchant.'],
|
|
35
|
+
['Invalid zipcode', 'The ZIP code you entered is invalid. Please check and try again.'],
|
|
36
|
+
['failed to save to database', 'Your payment was processed but we encountered an issue. Please contact support.'],
|
|
37
|
+
['CASHBACK UNAVAIL', 'This transaction type is not supported. Please try again or use a different payment method.'],
|
|
38
|
+
];
|
|
39
|
+
/**
|
|
40
|
+
* Maps a raw Ozura Pay API cardSale error string to a user-facing message.
|
|
41
|
+
*
|
|
42
|
+
* Uses the exact same error key table as checkout's `getUserFriendlyError()` in
|
|
43
|
+
* errorMapping.ts so both surfaces produce identical copy for the same errors.
|
|
44
|
+
*
|
|
45
|
+
* Falls back to the original string when it's under 100 characters, or to a
|
|
46
|
+
* generic message for long/opaque server errors — matching checkout's fallback
|
|
47
|
+
* behaviour exactly.
|
|
48
|
+
*
|
|
49
|
+
* **Trade-off:** Short unrecognised strings (e.g. processor codes like
|
|
50
|
+
* `"PROC_TIMEOUT"`) are passed through verbatim. This intentionally mirrors
|
|
51
|
+
* checkout so the same raw Pay API errors produce the same user-facing text on
|
|
52
|
+
* both surfaces. If the Pay API ever returns internal codes that should never
|
|
53
|
+
* reach the UI, the fix belongs in the Pay API error normalisation layer rather
|
|
54
|
+
* than here.
|
|
55
|
+
*/
|
|
56
|
+
function normalizeCardSaleError(raw) {
|
|
57
|
+
if (!raw)
|
|
58
|
+
return 'Payment processing failed. Please try again.';
|
|
59
|
+
for (const [key, message] of CARD_SALE_ERROR_MAP) {
|
|
60
|
+
if (raw.toLowerCase().includes(key.toLowerCase())) {
|
|
61
|
+
return message;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
// Checkout fallback: pass through short errors, genericise long ones
|
|
65
|
+
if (raw.length < 100)
|
|
66
|
+
return raw;
|
|
67
|
+
return 'Payment processing failed. Please try again or contact support.';
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* billingUtils.ts — billing detail validation and normalization.
|
|
72
|
+
*
|
|
73
|
+
* Mirrors the validation in checkout/page.tsx (pre-flight checks before cardSale)
|
|
74
|
+
* so that billing data passed to createToken() is guaranteed schema-compliant and
|
|
75
|
+
* ready to forward directly to the Ozura Pay API cardSale endpoint.
|
|
76
|
+
*
|
|
77
|
+
* All string fields enforced to 1–50 characters (cardSale schema constraint).
|
|
78
|
+
* State is normalized to 2-letter abbreviation for US and CA.
|
|
79
|
+
* Phone must be E.164 format (matches checkout's formatPhoneForAPI output).
|
|
80
|
+
*/
|
|
81
|
+
// ─── Email ────────────────────────────────────────────────────────────────────
|
|
82
|
+
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
|
83
|
+
/** Returns true when the email is syntactically valid and ≤50 characters. */
|
|
84
|
+
function validateEmail(email) {
|
|
85
|
+
return EMAIL_RE.test(email) && email.length <= 50;
|
|
86
|
+
}
|
|
87
|
+
// ─── Phone ───────────────────────────────────────────────────────────────────
|
|
88
|
+
/**
|
|
89
|
+
* Validates E.164 phone format: starts with +, 1–3 digit country code,
|
|
90
|
+
* followed by 7–12 digits, total ≤50 characters.
|
|
91
|
+
*
|
|
92
|
+
* Matches the output of checkout's formatPhoneForAPI() function.
|
|
93
|
+
* Examples: "+15551234567", "+447911123456", "+61412345678"
|
|
94
|
+
*/
|
|
95
|
+
function validateE164Phone(phone) {
|
|
96
|
+
return /^\+[1-9]\d{6,49}$/.test(phone) && phone.length <= 50;
|
|
97
|
+
}
|
|
98
|
+
// ─── Field length ─────────────────────────────────────────────────────────────
|
|
99
|
+
/** Returns true when the string is non-empty and ≤50 characters (cardSale schema). */
|
|
100
|
+
function isValidBillingField(value) {
|
|
101
|
+
return value.length > 0 && value.length <= 50;
|
|
102
|
+
}
|
|
103
|
+
// ─── US state normalization ───────────────────────────────────────────────────
|
|
104
|
+
// Mirrors checkout's convertStateToAbbreviation() so the same input variants work.
|
|
105
|
+
const US_STATES = {
|
|
106
|
+
alabama: 'AL', alaska: 'AK', arizona: 'AZ', arkansas: 'AR',
|
|
107
|
+
california: 'CA', colorado: 'CO', connecticut: 'CT', delaware: 'DE',
|
|
108
|
+
'district of columbia': 'DC', florida: 'FL', georgia: 'GA', hawaii: 'HI',
|
|
109
|
+
idaho: 'ID', illinois: 'IL', indiana: 'IN', iowa: 'IA', kansas: 'KS',
|
|
110
|
+
kentucky: 'KY', louisiana: 'LA', maine: 'ME', maryland: 'MD',
|
|
111
|
+
massachusetts: 'MA', michigan: 'MI', minnesota: 'MN', mississippi: 'MS',
|
|
112
|
+
missouri: 'MO', montana: 'MT', nebraska: 'NE', nevada: 'NV',
|
|
113
|
+
'new hampshire': 'NH', 'new jersey': 'NJ', 'new mexico': 'NM', 'new york': 'NY',
|
|
114
|
+
'north carolina': 'NC', 'north dakota': 'ND', ohio: 'OH', oklahoma: 'OK',
|
|
115
|
+
oregon: 'OR', pennsylvania: 'PA', 'rhode island': 'RI', 'south carolina': 'SC',
|
|
116
|
+
'south dakota': 'SD', tennessee: 'TN', texas: 'TX', utah: 'UT',
|
|
117
|
+
vermont: 'VT', virginia: 'VA', washington: 'WA', 'west virginia': 'WV',
|
|
118
|
+
wisconsin: 'WI', wyoming: 'WY',
|
|
119
|
+
// US territories
|
|
120
|
+
'puerto rico': 'PR', guam: 'GU', 'virgin islands': 'VI',
|
|
121
|
+
'us virgin islands': 'VI', 'u.s. virgin islands': 'VI',
|
|
122
|
+
'american samoa': 'AS', 'northern mariana islands': 'MP',
|
|
123
|
+
'commonwealth of the northern mariana islands': 'MP',
|
|
124
|
+
// Military / diplomatic addresses
|
|
125
|
+
'armed forces europe': 'AE', 'armed forces pacific': 'AP',
|
|
126
|
+
'armed forces americas': 'AA',
|
|
127
|
+
};
|
|
128
|
+
const US_ABBREVS = new Set(Object.values(US_STATES));
|
|
129
|
+
const CA_PROVINCES = {
|
|
130
|
+
alberta: 'AB', 'british columbia': 'BC', manitoba: 'MB', 'new brunswick': 'NB',
|
|
131
|
+
'newfoundland and labrador': 'NL', 'nova scotia': 'NS', ontario: 'ON',
|
|
132
|
+
'prince edward island': 'PE', quebec: 'QC', saskatchewan: 'SK',
|
|
133
|
+
'northwest territories': 'NT', nunavut: 'NU', yukon: 'YT',
|
|
134
|
+
};
|
|
135
|
+
const CA_ABBREVS = new Set(Object.values(CA_PROVINCES));
|
|
136
|
+
/**
|
|
137
|
+
* Converts a full US state or Canadian province name to its 2-letter abbreviation.
|
|
138
|
+
* If already a valid abbreviation (case-insensitive), returns it uppercased.
|
|
139
|
+
* For non-US/CA countries, returns the input uppercased unchanged.
|
|
140
|
+
*
|
|
141
|
+
* Matches checkout's convertStateToAbbreviation() behaviour exactly.
|
|
142
|
+
*/
|
|
143
|
+
function normalizeState(state, country) {
|
|
144
|
+
var _a, _b;
|
|
145
|
+
const upper = state.trim().toUpperCase();
|
|
146
|
+
const lower = state.trim().toLowerCase();
|
|
147
|
+
if (country === 'US') {
|
|
148
|
+
if (US_ABBREVS.has(upper))
|
|
149
|
+
return upper;
|
|
150
|
+
return (_a = US_STATES[lower]) !== null && _a !== void 0 ? _a : upper;
|
|
151
|
+
}
|
|
152
|
+
if (country === 'CA') {
|
|
153
|
+
if (CA_ABBREVS.has(upper))
|
|
154
|
+
return upper;
|
|
155
|
+
return (_b = CA_PROVINCES[lower]) !== null && _b !== void 0 ? _b : upper;
|
|
156
|
+
}
|
|
157
|
+
return upper;
|
|
158
|
+
}
|
|
159
|
+
// ─── Postal code validation ───────────────────────────────────────────────────
|
|
160
|
+
const POSTAL_PATTERNS = {
|
|
161
|
+
US: /^\d{5}(-?\d{4})?$/, // 5-digit or ZIP+4 (with or without hyphen)
|
|
162
|
+
CA: /^[A-Za-z]\d[A-Za-z]\s?\d[A-Za-z]\d$/, // A1A 1A1
|
|
163
|
+
GB: /^[A-Za-z]{1,2}\d[A-Za-z\d]?\s?\d[A-Za-z]{2}$/,
|
|
164
|
+
DE: /^\d{5}$/,
|
|
165
|
+
FR: /^\d{5}$/,
|
|
166
|
+
ES: /^\d{5}$/,
|
|
167
|
+
IT: /^\d{5}$/,
|
|
168
|
+
AU: /^\d{4}$/,
|
|
169
|
+
NL: /^\d{4}\s?[A-Za-z]{2}$/,
|
|
170
|
+
BR: /^\d{5}-?\d{3}$/,
|
|
171
|
+
JP: /^\d{3}-?\d{4}$/,
|
|
172
|
+
IN: /^\d{6}$/,
|
|
173
|
+
};
|
|
174
|
+
/**
|
|
175
|
+
* Validates a postal/ZIP code against country-specific format rules.
|
|
176
|
+
* For countries without a specific pattern, falls back to generic 1–50 char check.
|
|
177
|
+
*/
|
|
178
|
+
function validatePostalCode(zip, country) {
|
|
179
|
+
if (!zip || zip.length === 0)
|
|
180
|
+
return { valid: false, error: 'Postal code is required' };
|
|
181
|
+
if (zip.length > 50)
|
|
182
|
+
return { valid: false, error: 'Postal code must be 50 characters or fewer' };
|
|
183
|
+
const pattern = POSTAL_PATTERNS[country.toUpperCase()];
|
|
184
|
+
if (pattern && !pattern.test(zip)) {
|
|
185
|
+
return { valid: false, error: `Invalid postal code format for ${country.toUpperCase()}` };
|
|
186
|
+
}
|
|
187
|
+
return { valid: true };
|
|
188
|
+
}
|
|
189
|
+
/**
|
|
190
|
+
* Validates and normalizes billing details against the Ozura cardSale API schema.
|
|
191
|
+
*
|
|
192
|
+
* Rules applied (same as checkout's pre-flight validation in page.tsx):
|
|
193
|
+
* - firstName, lastName: required, 1–50 chars
|
|
194
|
+
* - email: optional; if provided, must be valid format and ≤50 chars
|
|
195
|
+
* - phone: optional; if provided, must be E.164 and ≤50 chars
|
|
196
|
+
* - address fields: if address is provided, line1/city/state/zip/country are
|
|
197
|
+
* required (1–50 chars each); line2 is optional and omitted from normalized
|
|
198
|
+
* output if blank (cardSale schema: minLength 1 if present)
|
|
199
|
+
* - state: normalized to 2-letter abbreviation for US and CA
|
|
200
|
+
*/
|
|
201
|
+
function validateBilling(billing) {
|
|
202
|
+
var _a, _b, _c, _d, _e, _f, _g, _h, _j, _k, _l, _m, _o, _p, _q, _r, _s, _t, _u, _v;
|
|
203
|
+
const errors = [];
|
|
204
|
+
const firstName = (_b = (_a = billing.firstName) === null || _a === void 0 ? void 0 : _a.trim()) !== null && _b !== void 0 ? _b : '';
|
|
205
|
+
const lastName = (_d = (_c = billing.lastName) === null || _c === void 0 ? void 0 : _c.trim()) !== null && _d !== void 0 ? _d : '';
|
|
206
|
+
const email = (_f = (_e = billing.email) === null || _e === void 0 ? void 0 : _e.trim()) !== null && _f !== void 0 ? _f : '';
|
|
207
|
+
const phone = (_h = (_g = billing.phone) === null || _g === void 0 ? void 0 : _g.trim()) !== null && _h !== void 0 ? _h : '';
|
|
208
|
+
if (!isValidBillingField(firstName)) {
|
|
209
|
+
errors.push('billing.firstName must be 1–50 characters');
|
|
210
|
+
}
|
|
211
|
+
if (!isValidBillingField(lastName)) {
|
|
212
|
+
errors.push('billing.lastName must be 1–50 characters');
|
|
213
|
+
}
|
|
214
|
+
if (email && !validateEmail(email)) {
|
|
215
|
+
errors.push('billing.email must be a valid address (max 50 characters)');
|
|
216
|
+
}
|
|
217
|
+
if (phone && !validateE164Phone(phone)) {
|
|
218
|
+
errors.push('billing.phone must be E.164 format, e.g. "+15551234567" (max 50 characters)');
|
|
219
|
+
}
|
|
220
|
+
let normalizedAddress;
|
|
221
|
+
if (billing.address) {
|
|
222
|
+
const a = billing.address;
|
|
223
|
+
const country = (_k = (_j = a.country) === null || _j === void 0 ? void 0 : _j.trim().toUpperCase()) !== null && _k !== void 0 ? _k : '';
|
|
224
|
+
const line1 = (_m = (_l = a.line1) === null || _l === void 0 ? void 0 : _l.trim()) !== null && _m !== void 0 ? _m : '';
|
|
225
|
+
const line2 = (_p = (_o = a.line2) === null || _o === void 0 ? void 0 : _o.trim()) !== null && _p !== void 0 ? _p : '';
|
|
226
|
+
const city = (_r = (_q = a.city) === null || _q === void 0 ? void 0 : _q.trim()) !== null && _r !== void 0 ? _r : '';
|
|
227
|
+
const zip = (_t = (_s = a.zip) === null || _s === void 0 ? void 0 : _s.trim()) !== null && _t !== void 0 ? _t : '';
|
|
228
|
+
const state = normalizeState((_v = (_u = a.state) === null || _u === void 0 ? void 0 : _u.trim()) !== null && _v !== void 0 ? _v : '', country);
|
|
229
|
+
if (!isValidBillingField(line1))
|
|
230
|
+
errors.push('billing.address.line1 must be 1–50 characters');
|
|
231
|
+
if (line2 && !isValidBillingField(line2))
|
|
232
|
+
errors.push('billing.address.line2 must be 1–50 characters if provided');
|
|
233
|
+
if (!isValidBillingField(city))
|
|
234
|
+
errors.push('billing.address.city must be 1–50 characters');
|
|
235
|
+
if (!isValidBillingField(state)) {
|
|
236
|
+
errors.push('billing.address.state must be 1–50 characters');
|
|
237
|
+
}
|
|
238
|
+
else if (country === 'US' && !US_ABBREVS.has(state)) {
|
|
239
|
+
errors.push(`billing.address.state "${state}" is not a recognized US state or territory abbreviation (e.g. "CA", "NY", "PR")`);
|
|
240
|
+
}
|
|
241
|
+
else if (country === 'CA' && !CA_ABBREVS.has(state)) {
|
|
242
|
+
errors.push(`billing.address.state "${state}" is not a recognized Canadian province or territory abbreviation (e.g. "ON", "BC", "QC")`);
|
|
243
|
+
}
|
|
244
|
+
// cardSale backend uses strict enum validation on country — must be exactly 2 uppercase letters
|
|
245
|
+
if (!/^[A-Z]{2}$/.test(country)) {
|
|
246
|
+
errors.push('billing.address.country must be a 2-letter ISO 3166-1 alpha-2 code (e.g. "US", "CA", "GB")');
|
|
247
|
+
}
|
|
248
|
+
if (!isValidBillingField(zip)) {
|
|
249
|
+
errors.push('billing.address.zip must be 1–50 characters');
|
|
250
|
+
}
|
|
251
|
+
else if (/^[A-Z]{2}$/.test(country)) {
|
|
252
|
+
const postalResult = validatePostalCode(zip, country);
|
|
253
|
+
if (!postalResult.valid) {
|
|
254
|
+
errors.push(`billing.address.zip: ${postalResult.error}`);
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
normalizedAddress = Object.assign(Object.assign({ line1 }, (line2 ? { line2 } : {})), { city,
|
|
258
|
+
state,
|
|
259
|
+
zip,
|
|
260
|
+
country });
|
|
261
|
+
}
|
|
262
|
+
const normalized = Object.assign(Object.assign(Object.assign({ firstName,
|
|
263
|
+
lastName }, (email ? { email } : {})), (phone ? { phone } : {})), (normalizedAddress ? { address: normalizedAddress } : {}));
|
|
264
|
+
return { valid: errors.length === 0, errors, normalized };
|
|
265
|
+
}
|
|
266
|
+
|
|
3
267
|
/**
|
|
4
268
|
* @ozura/server — Server-side SDK for the Ozura Pay API.
|
|
5
269
|
*
|
|
@@ -8,7 +272,7 @@
|
|
|
8
272
|
*
|
|
9
273
|
* @example
|
|
10
274
|
* ```ts
|
|
11
|
-
* import { Ozura } from '
|
|
275
|
+
* import { Ozura } from '@ozura/elements/server';
|
|
12
276
|
*
|
|
13
277
|
* const ozura = new Ozura({
|
|
14
278
|
* merchantId: process.env.MERCHANT_ID!,
|
|
@@ -29,7 +293,8 @@
|
|
|
29
293
|
* ```
|
|
30
294
|
*/
|
|
31
295
|
// ─── Configuration ───────────────────────────────────────────────────────────
|
|
32
|
-
const DEFAULT_API_URL = "https://
|
|
296
|
+
const DEFAULT_API_URL = "https://payapi.v2.ozurapay.com";
|
|
297
|
+
const DEFAULT_VAULT_URL = "https://api.ozuravault.com";
|
|
33
298
|
const DEFAULT_TIMEOUT = 30000;
|
|
34
299
|
// ─── Error ───────────────────────────────────────────────────────────────────
|
|
35
300
|
class OzuraError extends Error {
|
|
@@ -49,6 +314,23 @@ function isRetryable(status) {
|
|
|
49
314
|
async function sleep(ms) {
|
|
50
315
|
return new Promise(resolve => setTimeout(resolve, ms));
|
|
51
316
|
}
|
|
317
|
+
/**
|
|
318
|
+
* Returns an AbortSignal that aborts after `ms` milliseconds.
|
|
319
|
+
*
|
|
320
|
+
* Uses `AbortSignal.timeout()` when available (Node 17.3+ / 18.0+) and
|
|
321
|
+
* falls back to a manual `AbortController` + `setTimeout` for Node 16 and
|
|
322
|
+
* other older runtimes. Both paths produce an `AbortError`-family signal;
|
|
323
|
+
* the catch sites in this file only use `err.message` so the difference in
|
|
324
|
+
* `err.name` (`'AbortError'` vs `'TimeoutError'`) is inconsequential.
|
|
325
|
+
*/
|
|
326
|
+
function timeoutSignal(ms) {
|
|
327
|
+
if (typeof AbortSignal.timeout === 'function') {
|
|
328
|
+
return AbortSignal.timeout(ms);
|
|
329
|
+
}
|
|
330
|
+
const controller = new AbortController();
|
|
331
|
+
setTimeout(() => controller.abort(), ms);
|
|
332
|
+
return controller.signal;
|
|
333
|
+
}
|
|
52
334
|
// ─── Main class ──────────────────────────────────────────────────────────────
|
|
53
335
|
class Ozura {
|
|
54
336
|
constructor(config) {
|
|
@@ -63,6 +345,7 @@ class Ozura {
|
|
|
63
345
|
this.apiKey = config.apiKey;
|
|
64
346
|
this.vaultKey = config.vaultKey;
|
|
65
347
|
this.apiUrl = config.apiUrl || DEFAULT_API_URL;
|
|
348
|
+
this.vaultUrl = config.vaultUrl || DEFAULT_VAULT_URL;
|
|
66
349
|
this.timeoutMs = (_a = config.timeoutMs) !== null && _a !== void 0 ? _a : DEFAULT_TIMEOUT;
|
|
67
350
|
this.retries = (_b = config.retries) !== null && _b !== void 0 ? _b : DEFAULT_RETRIES;
|
|
68
351
|
}
|
|
@@ -132,17 +415,119 @@ class Ozura {
|
|
|
132
415
|
const raw = await this.getRaw(`/api/v1/transQuery?${qs}`);
|
|
133
416
|
return { transactions: raw.data, pagination: raw.pagination };
|
|
134
417
|
}
|
|
418
|
+
// ─── Wax key helpers ─────────────────────────────────────────────────────
|
|
419
|
+
/**
|
|
420
|
+
* Mint a short-lived, use-limited wax key from the vault.
|
|
421
|
+
*
|
|
422
|
+
* Call this server-side to implement the `fetchWaxKey` callback required by
|
|
423
|
+
* `OzVault.create()` on the frontend. The wax key replaces the vault secret
|
|
424
|
+
* on every browser tokenize call — the secret never leaves your server.
|
|
425
|
+
*
|
|
426
|
+
* **Use limits:** by default each wax key accepts up to 3 tokenize calls
|
|
427
|
+
* (`maxTokenizeCalls: 3`). After that the vault marks the key as consumed and
|
|
428
|
+
* the client SDK transparently re-mints. Keep `maxTokenizeCalls` in sync with
|
|
429
|
+
* `VaultOptions.maxTokenizeCalls` so the SDK can proactively refresh before
|
|
430
|
+
* hitting the limit rather than waiting for a rejection.
|
|
431
|
+
*
|
|
432
|
+
* **Session correlation:** the `tokenizationSessionId` forwarded from the SDK's
|
|
433
|
+
* `fetchWaxKey` callback should be passed here so the vault can correlate the
|
|
434
|
+
* key with the checkout session in its audit log.
|
|
435
|
+
*
|
|
436
|
+
* @example
|
|
437
|
+
* // Next.js API route
|
|
438
|
+
* export async function POST(req: Request) {
|
|
439
|
+
* const { sessionId } = await req.json();
|
|
440
|
+
* const { waxKey } = await ozura.mintWaxKey({
|
|
441
|
+
* tokenizationSessionId: sessionId,
|
|
442
|
+
* maxTokenizeCalls: 3,
|
|
443
|
+
* });
|
|
444
|
+
* return Response.json({ waxKey });
|
|
445
|
+
* }
|
|
446
|
+
*/
|
|
447
|
+
async mintWaxKey(options) {
|
|
448
|
+
var _a, _b, _c;
|
|
449
|
+
const body = {};
|
|
450
|
+
if (options === null || options === void 0 ? void 0 : options.tokenizationSessionId) {
|
|
451
|
+
body.checkout_session_id = options.tokenizationSessionId;
|
|
452
|
+
}
|
|
453
|
+
// Always send max_tokenize_calls — default 3 if not overridden.
|
|
454
|
+
body.max_tokenize_calls = (_a = options === null || options === void 0 ? void 0 : options.maxTokenizeCalls) !== null && _a !== void 0 ? _a : 3;
|
|
455
|
+
if ((options === null || options === void 0 ? void 0 : options.maxProxyCalls) !== undefined) {
|
|
456
|
+
body.max_proxy_calls = options.maxProxyCalls;
|
|
457
|
+
}
|
|
458
|
+
const res = await this.fetchWithRetry(`${this.vaultUrl}/internal/wax-session`, {
|
|
459
|
+
method: 'POST',
|
|
460
|
+
headers: {
|
|
461
|
+
'Content-Type': 'application/json',
|
|
462
|
+
'X-API-Key': this.vaultKey,
|
|
463
|
+
},
|
|
464
|
+
body: JSON.stringify(body),
|
|
465
|
+
});
|
|
466
|
+
const json = await res.json().catch(() => ({}));
|
|
467
|
+
if (res.status === 201) {
|
|
468
|
+
const data = json.data;
|
|
469
|
+
const waxKey = String((_b = data === null || data === void 0 ? void 0 : data.wax_key) !== null && _b !== void 0 ? _b : '');
|
|
470
|
+
if (!waxKey) {
|
|
471
|
+
throw new OzuraError('Vault mint response missing data.wax_key', res.status, JSON.stringify(json));
|
|
472
|
+
}
|
|
473
|
+
return {
|
|
474
|
+
waxKey,
|
|
475
|
+
expiresInSeconds: Number((_c = data === null || data === void 0 ? void 0 : data.expires_in_seconds) !== null && _c !== void 0 ? _c : 1800),
|
|
476
|
+
};
|
|
477
|
+
}
|
|
478
|
+
const errorCode = json.error_code || '';
|
|
479
|
+
const message = json.message || json.error || `Wax mint failed (HTTP ${res.status})`;
|
|
480
|
+
throw new OzuraError(errorCode ? `${message} [${errorCode}]` : message, res.status, errorCode || JSON.stringify(json));
|
|
481
|
+
}
|
|
482
|
+
/**
|
|
483
|
+
* Revoke a previously minted wax key.
|
|
484
|
+
*
|
|
485
|
+
* Best-effort: never throws. Call this when the user's session ends (payment
|
|
486
|
+
* complete, cancelled, or expired) to close the exposure window before the
|
|
487
|
+
* vault TTL (30 min) elapses.
|
|
488
|
+
*
|
|
489
|
+
* - 200 = revoked successfully.
|
|
490
|
+
* - 404 = key already expired or not found — treated as success.
|
|
491
|
+
* - 503 = Redis error; the wax may still be valid. Retry if needed.
|
|
492
|
+
*
|
|
493
|
+
* @example
|
|
494
|
+
* // After a successful card sale:
|
|
495
|
+
* await ozura.revokeWaxKey(waxKey);
|
|
496
|
+
*/
|
|
497
|
+
async revokeWaxKey(waxKey) {
|
|
498
|
+
var _a, _b;
|
|
499
|
+
if (!waxKey)
|
|
500
|
+
return;
|
|
501
|
+
try {
|
|
502
|
+
const res = await fetch(`${this.vaultUrl}/internal/wax-session/revoke`, {
|
|
503
|
+
method: 'POST',
|
|
504
|
+
headers: {
|
|
505
|
+
'Content-Type': 'application/json',
|
|
506
|
+
'X-API-Key': this.vaultKey,
|
|
507
|
+
},
|
|
508
|
+
body: JSON.stringify({ wax_key: waxKey }),
|
|
509
|
+
signal: timeoutSignal(3000),
|
|
510
|
+
});
|
|
511
|
+
if (res.status === 200 || res.status === 404)
|
|
512
|
+
return;
|
|
513
|
+
const json = await res.json().catch(() => ({}));
|
|
514
|
+
console.warn('[Ozura] revokeWaxKey: unexpected status', res.status, (_b = (_a = json.error_code) !== null && _a !== void 0 ? _a : json.message) !== null && _b !== void 0 ? _b : '');
|
|
515
|
+
}
|
|
516
|
+
catch (err) {
|
|
517
|
+
console.warn('[Ozura] revokeWaxKey: best-effort call failed:', err instanceof Error ? err.message : err);
|
|
518
|
+
}
|
|
519
|
+
}
|
|
135
520
|
// ─── HTTP helpers ────────────────────────────────────────────────────────
|
|
136
521
|
/**
|
|
137
522
|
* Execute a fetch with retry on 5xx / network errors.
|
|
138
523
|
* 4xx errors (including 429) are never retried — they require caller action.
|
|
139
524
|
*/
|
|
140
|
-
async fetchWithRetry(url, init) {
|
|
525
|
+
async fetchWithRetry(url, init, maxRetries = this.retries) {
|
|
141
526
|
let lastError;
|
|
142
|
-
for (let attempt = 0; attempt <=
|
|
527
|
+
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
|
143
528
|
try {
|
|
144
|
-
const res = await fetch(url, Object.assign(Object.assign({}, init), { signal:
|
|
145
|
-
if (isRetryable(res.status) && attempt <
|
|
529
|
+
const res = await fetch(url, Object.assign(Object.assign({}, init), { signal: timeoutSignal(this.timeoutMs) }));
|
|
530
|
+
if (isRetryable(res.status) && attempt < maxRetries) {
|
|
146
531
|
// Consume/cancel body so the connection is released (undici holds socket until body is drained)
|
|
147
532
|
if (res.body) {
|
|
148
533
|
try {
|
|
@@ -160,7 +545,7 @@ class Ozura {
|
|
|
160
545
|
}
|
|
161
546
|
catch (err) {
|
|
162
547
|
lastError = err;
|
|
163
|
-
if (attempt <
|
|
548
|
+
if (attempt < maxRetries) {
|
|
164
549
|
const backoff = Math.min(1000 * 2 ** attempt, 8000);
|
|
165
550
|
await sleep(backoff);
|
|
166
551
|
continue;
|
|
@@ -176,7 +561,14 @@ class Ozura {
|
|
|
176
561
|
}
|
|
177
562
|
throw new OzuraError('Network error', 0);
|
|
178
563
|
}
|
|
179
|
-
async post(path, body, includeVaultKey) {
|
|
564
|
+
async post(path, body, includeVaultKey, maxRetries) {
|
|
565
|
+
// Enforce: the server SDK sends x-api-key on every request, so it must
|
|
566
|
+
// never be used to call the vault tokenize endpoint. Tokenization is
|
|
567
|
+
// client-side only (OzVault + tokenizerFrame). Surface misuse immediately.
|
|
568
|
+
if (/\/tokenize\b/i.test(path)) {
|
|
569
|
+
throw new OzuraError('The server SDK must not call the vault tokenize endpoint. ' +
|
|
570
|
+
'Tokenization must be performed client-side via the OzVault SDK.', 0);
|
|
571
|
+
}
|
|
180
572
|
const headers = {
|
|
181
573
|
'Content-Type': 'application/json',
|
|
182
574
|
'x-api-key': this.apiKey,
|
|
@@ -188,41 +580,151 @@ class Ozura {
|
|
|
188
580
|
method: 'POST',
|
|
189
581
|
headers,
|
|
190
582
|
body: JSON.stringify(body),
|
|
191
|
-
});
|
|
583
|
+
}, maxRetries);
|
|
192
584
|
return this.handleResponse(res);
|
|
193
585
|
}
|
|
194
586
|
async getRaw(path) {
|
|
587
|
+
// Enforce: same rule as post() — x-api-key must never reach /tokenize.
|
|
588
|
+
if (/\/tokenize\b/i.test(path)) {
|
|
589
|
+
throw new OzuraError('The server SDK must not call the vault tokenize endpoint. ' +
|
|
590
|
+
'Tokenization must be performed client-side via the OzVault SDK.', 0);
|
|
591
|
+
}
|
|
195
592
|
const res = await this.fetchWithRetry(`${this.apiUrl}${path}`, {
|
|
196
593
|
method: 'GET',
|
|
197
594
|
headers: { 'x-api-key': this.apiKey },
|
|
198
595
|
});
|
|
199
|
-
|
|
200
|
-
if (!res.ok) {
|
|
201
|
-
const retryAfter = res.status === 429
|
|
202
|
-
? Number(res.headers.get('retry-after') || json.retryAfter) || undefined
|
|
203
|
-
: undefined;
|
|
204
|
-
throw new OzuraError(json.error || `HTTP ${res.status}`, res.status, json.error, retryAfter);
|
|
205
|
-
}
|
|
206
|
-
if (json.success === false) {
|
|
207
|
-
throw new OzuraError(json.error || 'Request was not successful', res.status, json.error);
|
|
208
|
-
}
|
|
209
|
-
return json;
|
|
596
|
+
return (await this.parseApiJson(res));
|
|
210
597
|
}
|
|
211
598
|
async handleResponse(res) {
|
|
212
599
|
var _a;
|
|
600
|
+
const json = await this.parseApiJson(res);
|
|
601
|
+
return ((_a = json.data) !== null && _a !== void 0 ? _a : json);
|
|
602
|
+
}
|
|
603
|
+
/**
|
|
604
|
+
* Parses a Pay API response JSON and throws `OzuraError` on HTTP errors or
|
|
605
|
+
* `success: false` payloads. Used by both `getRaw` and `handleResponse` to
|
|
606
|
+
* avoid duplicating the error-mapping logic.
|
|
607
|
+
*/
|
|
608
|
+
async parseApiJson(res) {
|
|
213
609
|
const json = await res.json().catch(() => ({ error: res.statusText }));
|
|
214
610
|
if (!res.ok) {
|
|
215
611
|
const retryAfter = res.status === 429
|
|
216
612
|
? Number(res.headers.get('retry-after') || json.retryAfter) || undefined
|
|
217
613
|
: undefined;
|
|
218
|
-
throw new OzuraError(json.error || `HTTP ${res.status}`, res.status, json.error, retryAfter);
|
|
614
|
+
throw new OzuraError(json.error || `HTTP ${res.status}`, res.status, typeof json.error === 'string' ? json.error : undefined, retryAfter);
|
|
219
615
|
}
|
|
220
616
|
if (json.success === false) {
|
|
221
|
-
throw new OzuraError(json.error || 'Request was not successful', res.status, json.error);
|
|
617
|
+
throw new OzuraError(json.error || 'Request was not successful', res.status, typeof json.error === 'string' ? json.error : undefined);
|
|
222
618
|
}
|
|
223
|
-
return
|
|
619
|
+
return json;
|
|
224
620
|
}
|
|
225
621
|
}
|
|
622
|
+
/**
|
|
623
|
+
* Creates a ready-to-use Fetch API route handler for minting wax keys.
|
|
624
|
+
*
|
|
625
|
+
* Drop-in for Next.js App Router, Cloudflare Workers, Vercel Edge, and any
|
|
626
|
+
* runtime built on the standard Web API `Request` / `Response`.
|
|
627
|
+
*
|
|
628
|
+
* The handler reads `sessionId` (or `tokenizationSessionId`) from the JSON
|
|
629
|
+
* request body, calls `ozura.mintWaxKey()`, and returns `{ waxKey }`.
|
|
630
|
+
* On error it returns `{ error }` with an appropriate HTTP status.
|
|
631
|
+
*
|
|
632
|
+
* @example
|
|
633
|
+
* // app/api/mint-wax/route.ts (Next.js App Router)
|
|
634
|
+
* import { Ozura, createMintWaxHandler } from '@ozura/elements/server';
|
|
635
|
+
*
|
|
636
|
+
* const ozura = new Ozura({
|
|
637
|
+
* merchantId: process.env.MERCHANT_ID!,
|
|
638
|
+
* apiKey: process.env.MERCHANT_API_KEY!,
|
|
639
|
+
* vaultKey: process.env.VAULT_API_KEY!,
|
|
640
|
+
* });
|
|
641
|
+
*
|
|
642
|
+
* export const POST = createMintWaxHandler(ozura);
|
|
643
|
+
*/
|
|
644
|
+
function createMintWaxHandler(ozura) {
|
|
645
|
+
return async (req) => {
|
|
646
|
+
var _a, _b;
|
|
647
|
+
if (req.method !== 'POST') {
|
|
648
|
+
return Response.json({ error: 'Method Not Allowed' }, { status: 405 });
|
|
649
|
+
}
|
|
650
|
+
// Reject non-JSON bodies — blocks simple-form CSRF requests which send
|
|
651
|
+
// application/x-www-form-urlencoded and cannot set Content-Type to
|
|
652
|
+
// application/json without triggering a CORS preflight.
|
|
653
|
+
const contentType = (_a = req.headers.get('content-type')) !== null && _a !== void 0 ? _a : '';
|
|
654
|
+
if (!contentType.includes('application/json')) {
|
|
655
|
+
return Response.json({ error: 'Content-Type must be application/json' }, { status: 415 });
|
|
656
|
+
}
|
|
657
|
+
let sessionId;
|
|
658
|
+
const body = await req.json().catch(() => ({}));
|
|
659
|
+
const raw = (_b = body.sessionId) !== null && _b !== void 0 ? _b : body.tokenizationSessionId;
|
|
660
|
+
if (typeof raw === 'string' && raw)
|
|
661
|
+
sessionId = raw;
|
|
662
|
+
try {
|
|
663
|
+
const { waxKey } = await ozura.mintWaxKey({ tokenizationSessionId: sessionId });
|
|
664
|
+
return Response.json({ waxKey });
|
|
665
|
+
}
|
|
666
|
+
catch (err) {
|
|
667
|
+
const status = err instanceof OzuraError && err.statusCode >= 400 ? err.statusCode : 502;
|
|
668
|
+
const message = err instanceof Error ? err.message : 'Failed to mint wax key';
|
|
669
|
+
return Response.json({ error: message }, { status });
|
|
670
|
+
}
|
|
671
|
+
};
|
|
672
|
+
}
|
|
673
|
+
/**
|
|
674
|
+
* Creates a ready-to-use Express / Connect middleware for minting wax keys.
|
|
675
|
+
*
|
|
676
|
+
* Requires `express.json()` (or equivalent body-parser) to be registered
|
|
677
|
+
* before this middleware so `req.body` is available.
|
|
678
|
+
*
|
|
679
|
+
* The middleware reads `sessionId` (or `tokenizationSessionId`) from
|
|
680
|
+
* `req.body`, calls `ozura.mintWaxKey()`, and sends `{ waxKey }`.
|
|
681
|
+
* On error it sends `{ error }` with an appropriate HTTP status.
|
|
682
|
+
*
|
|
683
|
+
* @example
|
|
684
|
+
* // Express
|
|
685
|
+
* import express from 'express';
|
|
686
|
+
* import { Ozura, createMintWaxMiddleware } from '@ozura/elements/server';
|
|
687
|
+
*
|
|
688
|
+
* const ozura = new Ozura({
|
|
689
|
+
* merchantId: process.env.MERCHANT_ID!,
|
|
690
|
+
* apiKey: process.env.MERCHANT_API_KEY!,
|
|
691
|
+
* vaultKey: process.env.VAULT_API_KEY!,
|
|
692
|
+
* });
|
|
693
|
+
*
|
|
694
|
+
* const app = express();
|
|
695
|
+
* app.use(express.json());
|
|
696
|
+
* app.post('/api/mint-wax', createMintWaxMiddleware(ozura));
|
|
697
|
+
*/
|
|
698
|
+
function createMintWaxMiddleware(ozura) {
|
|
699
|
+
return async (req, res) => {
|
|
700
|
+
var _a, _b, _c;
|
|
701
|
+
const method = req.method;
|
|
702
|
+
if (method && method !== 'POST') {
|
|
703
|
+
res.status(405).json({ error: 'Method Not Allowed' });
|
|
704
|
+
return;
|
|
705
|
+
}
|
|
706
|
+
// Reject non-JSON content types — blocks simple-form CSRF requests.
|
|
707
|
+
// Express sets req.headers as a plain object; check it directly.
|
|
708
|
+
const headers = req.headers;
|
|
709
|
+
const ct = (_a = (typeof (headers === null || headers === void 0 ? void 0 : headers['content-type']) === 'string' ? headers['content-type'] : '')) !== null && _a !== void 0 ? _a : '';
|
|
710
|
+
if (!ct.includes('application/json')) {
|
|
711
|
+
res.status(415).json({ error: 'Content-Type must be application/json' });
|
|
712
|
+
return;
|
|
713
|
+
}
|
|
714
|
+
const body = ((_b = req.body) !== null && _b !== void 0 ? _b : {});
|
|
715
|
+
const raw = (_c = body.sessionId) !== null && _c !== void 0 ? _c : body.tokenizationSessionId;
|
|
716
|
+
const sessionId = typeof raw === 'string' && raw ? raw : undefined;
|
|
717
|
+
try {
|
|
718
|
+
const { waxKey } = await ozura.mintWaxKey({ tokenizationSessionId: sessionId });
|
|
719
|
+
res.json({ waxKey });
|
|
720
|
+
}
|
|
721
|
+
catch (err) {
|
|
722
|
+
const status = err instanceof OzuraError && err.statusCode >= 400 ? err.statusCode : 502;
|
|
723
|
+
const message = err instanceof Error ? err.message : 'Failed to mint wax key';
|
|
724
|
+
res.status(status).json({ error: message });
|
|
725
|
+
}
|
|
726
|
+
};
|
|
727
|
+
}
|
|
226
728
|
// ─── Utilities ────────────────────────────────────────────────────────────────
|
|
227
729
|
/**
|
|
228
730
|
* Extract the client IP address from a server request object.
|
|
@@ -233,8 +735,27 @@ class Ozura {
|
|
|
233
735
|
* (Cloudflare) → `x-forwarded-for` (reverse proxy) → `x-real-ip` →
|
|
234
736
|
* `socket.remoteAddress` → `"0.0.0.0"`.
|
|
235
737
|
*
|
|
738
|
+
* **Proxy trust requirements — read before deploying:**
|
|
739
|
+
*
|
|
740
|
+
* - **`x-forwarded-for` / `x-real-ip`** are HTTP headers that any client can
|
|
741
|
+
* set arbitrarily. They are only trustworthy when your server sits behind a
|
|
742
|
+
* reverse proxy (nginx, AWS ALB, Cloudflare, etc.) that strips and rewrites
|
|
743
|
+
* those headers. If your Node.js process is directly internet-accessible,
|
|
744
|
+
* an attacker can spoof any IP value and bypass payment-processor
|
|
745
|
+
* IP-based fraud checks.
|
|
746
|
+
* - **Express `req.ip`** resolves through `X-Forwarded-For` only when
|
|
747
|
+
* `app.set('trust proxy', true)` (or a specific proxy count/subnet) is
|
|
748
|
+
* configured. Without `trust proxy`, `req.ip` returns the direct socket
|
|
749
|
+
* address (your load-balancer's IP, not the client's).
|
|
750
|
+
* - **`cf-connecting-ip`** is only trustworthy when Cloudflare is genuinely
|
|
751
|
+
* in front of your server. Without Cloudflare, any client can send this
|
|
752
|
+
* header with a fabricated value.
|
|
753
|
+
*
|
|
754
|
+
* In all cases, ensure your infrastructure strips untrusted forwarding headers
|
|
755
|
+
* before they reach your application.
|
|
756
|
+
*
|
|
236
757
|
* @example
|
|
237
|
-
* // Express
|
|
758
|
+
* // Express — requires app.set('trust proxy', true) behind a proxy
|
|
238
759
|
* clientIpAddress: getClientIp(req)
|
|
239
760
|
*
|
|
240
761
|
* // Next.js App Router
|
|
@@ -252,8 +773,11 @@ function getClientIp(req) {
|
|
|
252
773
|
if (cfIp)
|
|
253
774
|
return cfIp;
|
|
254
775
|
const xff = get('x-forwarded-for');
|
|
255
|
-
if (xff)
|
|
256
|
-
|
|
776
|
+
if (xff) {
|
|
777
|
+
const candidate = xff.split(',')[0].trim();
|
|
778
|
+
if (candidate)
|
|
779
|
+
return candidate;
|
|
780
|
+
}
|
|
257
781
|
const realIp = get('x-real-ip');
|
|
258
782
|
if (realIp)
|
|
259
783
|
return realIp;
|
|
@@ -265,10 +789,16 @@ function getClientIp(req) {
|
|
|
265
789
|
if (typeof cfIp === 'string' && cfIp)
|
|
266
790
|
return cfIp;
|
|
267
791
|
const xff = h['x-forwarded-for'];
|
|
268
|
-
if (typeof xff === 'string' && xff)
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
792
|
+
if (typeof xff === 'string' && xff) {
|
|
793
|
+
const candidate = xff.split(',')[0].trim();
|
|
794
|
+
if (candidate)
|
|
795
|
+
return candidate;
|
|
796
|
+
}
|
|
797
|
+
if (Array.isArray(xff) && xff[0]) {
|
|
798
|
+
const candidate = xff[0].split(',')[0].trim();
|
|
799
|
+
if (candidate)
|
|
800
|
+
return candidate;
|
|
801
|
+
}
|
|
272
802
|
const realIp = h['x-real-ip'];
|
|
273
803
|
if (typeof realIp === 'string' && realIp)
|
|
274
804
|
return realIp;
|
|
@@ -279,8 +809,209 @@ function getClientIp(req) {
|
|
|
279
809
|
return socket.remoteAddress;
|
|
280
810
|
return '0.0.0.0';
|
|
281
811
|
}
|
|
812
|
+
/**
|
|
813
|
+
* Validates the token/cvcSession/billing fields from a parsed request body.
|
|
814
|
+
* Returns the normalized billing or a 400 error descriptor.
|
|
815
|
+
*/
|
|
816
|
+
function parseCardSaleBody(body) {
|
|
817
|
+
const { token, cvcSession, billing } = body;
|
|
818
|
+
if (typeof token !== 'string' || !token) {
|
|
819
|
+
return { ok: false, error: 'token is required' };
|
|
820
|
+
}
|
|
821
|
+
if (typeof cvcSession !== 'string' || !cvcSession) {
|
|
822
|
+
return { ok: false, error: 'cvcSession is required' };
|
|
823
|
+
}
|
|
824
|
+
if (!billing || typeof billing.firstName !== 'string' || typeof billing.lastName !== 'string') {
|
|
825
|
+
return { ok: false, error: 'billing with firstName and lastName is required' };
|
|
826
|
+
}
|
|
827
|
+
const billingValidation = validateBilling(billing);
|
|
828
|
+
if (!billingValidation.valid) {
|
|
829
|
+
return { ok: false, error: `Invalid billing details: ${billingValidation.errors.join('; ')}` };
|
|
830
|
+
}
|
|
831
|
+
return { ok: true, token, cvcSession, billing: billingValidation.normalized };
|
|
832
|
+
}
|
|
833
|
+
/**
|
|
834
|
+
* Runs the card sale after body validation: resolves amount + currency,
|
|
835
|
+
* calls ozura.cardSale(), and returns a typed outcome. Both handler and
|
|
836
|
+
* middleware factories delegate to this shared implementation.
|
|
837
|
+
*/
|
|
838
|
+
async function executeCardSale(ozura, options, token, cvcSession, billing, rawBody, clientIpAddress) {
|
|
839
|
+
let amount;
|
|
840
|
+
try {
|
|
841
|
+
amount = await options.getAmount(rawBody);
|
|
842
|
+
}
|
|
843
|
+
catch (err) {
|
|
844
|
+
return { ok: false, error: err instanceof Error ? err.message : 'Failed to resolve amount', status: 500 };
|
|
845
|
+
}
|
|
846
|
+
if (typeof amount !== 'string' || !amount.trim()) {
|
|
847
|
+
return { ok: false, error: 'getAmount must return a non-empty decimal string', status: 500 };
|
|
848
|
+
}
|
|
849
|
+
if (!/^\d+(\.\d{1,2})?$/.test(amount.trim())) {
|
|
850
|
+
return {
|
|
851
|
+
ok: false,
|
|
852
|
+
error: `getAmount returned an invalid amount: "${amount}". Expected a positive decimal string, e.g. "49.00".`,
|
|
853
|
+
status: 500,
|
|
854
|
+
};
|
|
855
|
+
}
|
|
856
|
+
amount = amount.trim();
|
|
857
|
+
let currency = 'USD';
|
|
858
|
+
if (options.getCurrency) {
|
|
859
|
+
try {
|
|
860
|
+
currency = await options.getCurrency(rawBody);
|
|
861
|
+
}
|
|
862
|
+
catch (err) {
|
|
863
|
+
return { ok: false, error: err instanceof Error ? err.message : 'Failed to resolve currency', status: 500 };
|
|
864
|
+
}
|
|
865
|
+
}
|
|
866
|
+
try {
|
|
867
|
+
const result = await ozura.cardSale({ token, cvcSession, amount, currency, billing, clientIpAddress });
|
|
868
|
+
return {
|
|
869
|
+
ok: true,
|
|
870
|
+
data: {
|
|
871
|
+
transactionId: result.transactionId,
|
|
872
|
+
amount: result.amount,
|
|
873
|
+
cardLastFour: result.cardLastFour,
|
|
874
|
+
cardBrand: result.cardBrand,
|
|
875
|
+
},
|
|
876
|
+
};
|
|
877
|
+
}
|
|
878
|
+
catch (err) {
|
|
879
|
+
if (err instanceof OzuraError) {
|
|
880
|
+
if (err.statusCode === 429) {
|
|
881
|
+
return { ok: false, error: normalizeCardSaleError(err.message), status: 429, retryAfter: err.retryAfter };
|
|
882
|
+
}
|
|
883
|
+
const status = err.statusCode >= 400 && err.statusCode < 600 ? err.statusCode : 502;
|
|
884
|
+
return { ok: false, error: normalizeCardSaleError(err.message), status };
|
|
885
|
+
}
|
|
886
|
+
return { ok: false, error: 'Payment failed', status: 500 };
|
|
887
|
+
}
|
|
888
|
+
}
|
|
889
|
+
// ─── Public factories ─────────────────────────────────────────────────────────
|
|
890
|
+
/**
|
|
891
|
+
* Creates a ready-to-use Fetch API route handler for charging a tokenized card.
|
|
892
|
+
*
|
|
893
|
+
* Drop-in for Next.js App Router, Cloudflare Workers, Vercel Edge, and any
|
|
894
|
+
* runtime built on the standard Web API `Request` / `Response`.
|
|
895
|
+
*
|
|
896
|
+
* The handler reads `{ token, cvcSession, billing }` from the JSON request body,
|
|
897
|
+
* resolves the amount via `options.getAmount()`, calls `ozura.cardSale()`, and
|
|
898
|
+
* returns `{ transactionId, amount, cardLastFour, cardBrand }` on success.
|
|
899
|
+
* On error it returns `{ error }` with a normalized, user-facing message and
|
|
900
|
+
* an appropriate HTTP status.
|
|
901
|
+
*
|
|
902
|
+
* `clientIpAddress` is extracted automatically from the request headers.
|
|
903
|
+
*
|
|
904
|
+
* @example
|
|
905
|
+
* // app/api/charge/route.ts (Next.js App Router)
|
|
906
|
+
* import { Ozura, createCardSaleHandler } from '@ozura/elements/server';
|
|
907
|
+
*
|
|
908
|
+
* const ozura = new Ozura({ merchantId: '...', apiKey: '...', vaultKey: '...' });
|
|
909
|
+
*
|
|
910
|
+
* export const POST = createCardSaleHandler(ozura, {
|
|
911
|
+
* getAmount: async (body) => {
|
|
912
|
+
* const order = await db.orders.findById(body.orderId as string);
|
|
913
|
+
* return order.total;
|
|
914
|
+
* },
|
|
915
|
+
* });
|
|
916
|
+
*/
|
|
917
|
+
function createCardSaleHandler(ozura, options) {
|
|
918
|
+
return async (req) => {
|
|
919
|
+
var _a;
|
|
920
|
+
if (req.method !== 'POST') {
|
|
921
|
+
return Response.json({ error: 'Method Not Allowed' }, { status: 405 });
|
|
922
|
+
}
|
|
923
|
+
const contentType = (_a = req.headers.get('content-type')) !== null && _a !== void 0 ? _a : '';
|
|
924
|
+
if (!contentType.includes('application/json')) {
|
|
925
|
+
return Response.json({ error: 'Content-Type must be application/json' }, { status: 415 });
|
|
926
|
+
}
|
|
927
|
+
let body;
|
|
928
|
+
try {
|
|
929
|
+
body = await req.json();
|
|
930
|
+
}
|
|
931
|
+
catch (_b) {
|
|
932
|
+
return Response.json({ error: 'Invalid JSON body' }, { status: 400 });
|
|
933
|
+
}
|
|
934
|
+
const parsed = parseCardSaleBody(body);
|
|
935
|
+
if (!parsed.ok) {
|
|
936
|
+
return Response.json({ error: parsed.error }, { status: 400 });
|
|
937
|
+
}
|
|
938
|
+
const outcome = await executeCardSale(ozura, options, parsed.token, parsed.cvcSession, parsed.billing, body, getClientIp(req));
|
|
939
|
+
if (!outcome.ok) {
|
|
940
|
+
const headers = outcome.retryAfter
|
|
941
|
+
? { 'Retry-After': String(outcome.retryAfter) }
|
|
942
|
+
: {};
|
|
943
|
+
return Response.json({ error: outcome.error }, Object.assign({ status: outcome.status }, (Object.keys(headers).length > 0 ? { headers } : {})));
|
|
944
|
+
}
|
|
945
|
+
return Response.json(outcome.data);
|
|
946
|
+
};
|
|
947
|
+
}
|
|
948
|
+
/**
|
|
949
|
+
* Creates a ready-to-use Express / Connect middleware for charging a tokenized card.
|
|
950
|
+
*
|
|
951
|
+
* Requires `express.json()` (or equivalent body-parser) to be registered before
|
|
952
|
+
* this middleware so `req.body` is available.
|
|
953
|
+
*
|
|
954
|
+
* The middleware reads `{ token, cvcSession, billing }` from `req.body`, resolves
|
|
955
|
+
* the amount via `options.getAmount()`, calls `ozura.cardSale()`, and sends
|
|
956
|
+
* `{ transactionId, amount, cardLastFour, cardBrand }` on success.
|
|
957
|
+
* On error it sends `{ error }` with a normalized, user-facing message and an
|
|
958
|
+
* appropriate HTTP status.
|
|
959
|
+
*
|
|
960
|
+
* `clientIpAddress` is extracted automatically from the request object.
|
|
961
|
+
*
|
|
962
|
+
* @example
|
|
963
|
+
* // Express
|
|
964
|
+
* import express from 'express';
|
|
965
|
+
* import { Ozura, createCardSaleMiddleware } from '@ozura/elements/server';
|
|
966
|
+
*
|
|
967
|
+
* const app = express();
|
|
968
|
+
* const ozura = new Ozura({ merchantId: '...', apiKey: '...', vaultKey: '...' });
|
|
969
|
+
*
|
|
970
|
+
* app.use(express.json());
|
|
971
|
+
* app.post('/api/charge', createCardSaleMiddleware(ozura, {
|
|
972
|
+
* getAmount: async (body) => {
|
|
973
|
+
* const order = await db.orders.findById(body.orderId as string);
|
|
974
|
+
* return order.total;
|
|
975
|
+
* },
|
|
976
|
+
* }));
|
|
977
|
+
*/
|
|
978
|
+
function createCardSaleMiddleware(ozura, options) {
|
|
979
|
+
return async (req, res) => {
|
|
980
|
+
var _a, _b, _c;
|
|
981
|
+
const method = req.method;
|
|
982
|
+
if (method && method !== 'POST') {
|
|
983
|
+
res.status(405).json({ error: 'Method Not Allowed' });
|
|
984
|
+
return;
|
|
985
|
+
}
|
|
986
|
+
const headers = req.headers;
|
|
987
|
+
const ct = (_a = (typeof (headers === null || headers === void 0 ? void 0 : headers['content-type']) === 'string' ? headers['content-type'] : '')) !== null && _a !== void 0 ? _a : '';
|
|
988
|
+
if (!ct.includes('application/json')) {
|
|
989
|
+
res.status(415).json({ error: 'Content-Type must be application/json' });
|
|
990
|
+
return;
|
|
991
|
+
}
|
|
992
|
+
const body = ((_b = req.body) !== null && _b !== void 0 ? _b : {});
|
|
993
|
+
const parsed = parseCardSaleBody(body);
|
|
994
|
+
if (!parsed.ok) {
|
|
995
|
+
res.status(400).json({ error: parsed.error });
|
|
996
|
+
return;
|
|
997
|
+
}
|
|
998
|
+
const outcome = await executeCardSale(ozura, options, parsed.token, parsed.cvcSession, parsed.billing, body, getClientIp(req));
|
|
999
|
+
if (!outcome.ok) {
|
|
1000
|
+
if (outcome.retryAfter)
|
|
1001
|
+
(_c = res.setHeader) === null || _c === void 0 ? void 0 : _c.call(res, 'Retry-After', String(outcome.retryAfter));
|
|
1002
|
+
res.status(outcome.status).json({ error: outcome.error });
|
|
1003
|
+
return;
|
|
1004
|
+
}
|
|
1005
|
+
res.json(outcome.data);
|
|
1006
|
+
};
|
|
1007
|
+
}
|
|
282
1008
|
|
|
283
1009
|
exports.Ozura = Ozura;
|
|
284
1010
|
exports.OzuraError = OzuraError;
|
|
1011
|
+
exports.createCardSaleHandler = createCardSaleHandler;
|
|
1012
|
+
exports.createCardSaleMiddleware = createCardSaleMiddleware;
|
|
1013
|
+
exports.createMintWaxHandler = createMintWaxHandler;
|
|
1014
|
+
exports.createMintWaxMiddleware = createMintWaxMiddleware;
|
|
285
1015
|
exports.getClientIp = getClientIp;
|
|
1016
|
+
exports.normalizeCardSaleError = normalizeCardSaleError;
|
|
286
1017
|
//# sourceMappingURL=index.cjs.js.map
|