@henrylabs-interview/payment-processor 0.1.7 → 0.1.9
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.js +13 -483
- package/dist/resources/checkout.js +416 -0
- package/dist/resources/webhooks.js +20 -0
- package/dist/utils/async.js +1 -0
- package/dist/utils/crypto.js +19 -0
- package/dist/utils/store.js +32 -0
- package/package.json +2 -2
package/dist/index.js
CHANGED
|
@@ -1,487 +1,17 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
for (let key of __getOwnPropNames(mod))
|
|
11
|
-
if (!__hasOwnProp.call(to, key))
|
|
12
|
-
__defProp(to, key, {
|
|
13
|
-
get: () => mod[key],
|
|
14
|
-
enumerable: true
|
|
15
|
-
});
|
|
16
|
-
return to;
|
|
17
|
-
};
|
|
18
|
-
var __moduleCache = /* @__PURE__ */ new WeakMap;
|
|
19
|
-
var __toCommonJS = (from) => {
|
|
20
|
-
var entry = __moduleCache.get(from), desc;
|
|
21
|
-
if (entry)
|
|
22
|
-
return entry;
|
|
23
|
-
entry = __defProp({}, "__esModule", { value: true });
|
|
24
|
-
if (from && typeof from === "object" || typeof from === "function")
|
|
25
|
-
__getOwnPropNames(from).map((key) => !__hasOwnProp.call(entry, key) && __defProp(entry, key, {
|
|
26
|
-
get: () => from[key],
|
|
27
|
-
enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable
|
|
28
|
-
}));
|
|
29
|
-
__moduleCache.set(from, entry);
|
|
30
|
-
return entry;
|
|
31
|
-
};
|
|
32
|
-
var __export = (target, all) => {
|
|
33
|
-
for (var name in all)
|
|
34
|
-
__defProp(target, name, {
|
|
35
|
-
get: all[name],
|
|
36
|
-
enumerable: true,
|
|
37
|
-
configurable: true,
|
|
38
|
-
set: (newValue) => all[name] = () => newValue
|
|
39
|
-
});
|
|
40
|
-
};
|
|
41
|
-
|
|
42
|
-
// src/index.ts
|
|
43
|
-
var exports_src = {};
|
|
44
|
-
__export(exports_src, {
|
|
45
|
-
default: () => HenryPaymentProcessorSDK
|
|
46
|
-
});
|
|
47
|
-
module.exports = __toCommonJS(exports_src);
|
|
48
|
-
|
|
49
|
-
// src/utils/store.ts
|
|
50
|
-
var path = new URL("../db-store/history.json", "file:///Users/aaroncassar/Projects/take-home/src/utils/store.ts");
|
|
51
|
-
async function readHistory() {
|
|
52
|
-
const file = Bun.file(path);
|
|
53
|
-
if (!await file.exists()) {
|
|
54
|
-
return [];
|
|
55
|
-
}
|
|
56
|
-
const text = await file.text();
|
|
57
|
-
if (!text)
|
|
58
|
-
return [];
|
|
59
|
-
try {
|
|
60
|
-
return JSON.parse(text);
|
|
61
|
-
} catch {
|
|
62
|
-
return [];
|
|
63
|
-
}
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
// src/utils/crypto.ts
|
|
67
|
-
var import_crypto = __toESM(require("crypto"));
|
|
68
|
-
function generateID(length = 12) {
|
|
69
|
-
const digits = [];
|
|
70
|
-
const bytes = import_crypto.default.randomBytes(length);
|
|
71
|
-
for (let i = 0;i < length; i++) {
|
|
72
|
-
digits.push((bytes[i] ?? 0) % 10);
|
|
73
|
-
}
|
|
74
|
-
return parseInt(digits.join(""));
|
|
75
|
-
}
|
|
76
|
-
function hashToNumber(input, length = 12) {
|
|
77
|
-
if (length > 16) {
|
|
78
|
-
throw new Error("Length is greater than max length of 16!");
|
|
79
|
-
}
|
|
80
|
-
const hash = import_crypto.default.createHash("sha256").update(input).digest("hex");
|
|
81
|
-
return parseInt(hash.slice(0, length), 16);
|
|
82
|
-
}
|
|
83
|
-
function signPayload(payload, secret) {
|
|
84
|
-
return import_crypto.default.createHmac("sha256", secret).update(payload).digest("hex");
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
// src/utils/async.ts
|
|
88
|
-
var sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
89
|
-
|
|
90
|
-
// src/resources/webhooks.ts
|
|
91
|
-
var INTERNAL_WEBHOOKS = [];
|
|
92
|
-
|
|
93
|
-
class Webhooks {
|
|
94
|
-
async createEndpoint(params) {
|
|
95
|
-
for (const event of params.events) {
|
|
96
|
-
await sleep(Math.random() * 100);
|
|
97
|
-
INTERNAL_WEBHOOKS.push({
|
|
98
|
-
url: params.url,
|
|
99
|
-
event,
|
|
100
|
-
secret: params.secret
|
|
101
|
-
});
|
|
102
|
-
}
|
|
103
|
-
return true;
|
|
104
|
-
}
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
// src/resources/checkout.ts
|
|
108
|
-
var INTERNAL_CHECKOUTS = {};
|
|
109
|
-
var INTERNAL_CARDS = {};
|
|
110
|
-
|
|
111
|
-
class Checkout {
|
|
112
|
-
async create(params) {
|
|
113
|
-
await sleep(Math.random() * 100);
|
|
114
|
-
const hashId = this.buildHistoryHash(params);
|
|
115
|
-
const history = await readHistory();
|
|
116
|
-
const sameRecords = history.filter((v) => v.id === hashId);
|
|
117
|
-
const validationFailure = this.validateCreate(params);
|
|
118
|
-
const response = validationFailure ?? this.processCreateDecision(params, hashId, sameRecords.length);
|
|
119
|
-
this.scheduleCreateWebhook(hashId, response);
|
|
120
|
-
await sleep(Math.random() * 2000);
|
|
121
|
-
return response;
|
|
122
|
-
}
|
|
123
|
-
async confirm(params) {
|
|
124
|
-
await sleep(Math.random() * 100);
|
|
125
|
-
const validationFailure = await this.validateConfirm(params);
|
|
126
|
-
const response = validationFailure ?? await this.processConfirmDecision(params);
|
|
127
|
-
this.scheduleConfirmWebhook(params, response);
|
|
128
|
-
await sleep(Math.random() * 2000);
|
|
129
|
-
return response;
|
|
130
|
-
}
|
|
131
|
-
validateCreate(params) {
|
|
132
|
-
if (params.amount <= 0) {
|
|
133
|
-
return {
|
|
134
|
-
status: "failure",
|
|
135
|
-
substatus: "500-error",
|
|
136
|
-
code: 500,
|
|
137
|
-
message: "Invalid amount"
|
|
138
|
-
};
|
|
139
|
-
}
|
|
140
|
-
if (params.currency === "JPY") {
|
|
141
|
-
return {
|
|
142
|
-
status: "failure",
|
|
143
|
-
substatus: "501-not-supported",
|
|
144
|
-
code: 501,
|
|
145
|
-
message: "This currency is currently not supported, please convert first."
|
|
146
|
-
};
|
|
147
|
-
}
|
|
148
|
-
if (Math.random() > 0.85) {
|
|
149
|
-
return {
|
|
150
|
-
status: "failure",
|
|
151
|
-
substatus: "500-error",
|
|
152
|
-
code: 500,
|
|
153
|
-
message: "An internal error occurred when creating checkout"
|
|
154
|
-
};
|
|
155
|
-
}
|
|
156
|
-
return null;
|
|
157
|
-
}
|
|
158
|
-
processCreateDecision(params, hashId, duplicateCount) {
|
|
159
|
-
const resCase = this.determineResponseCase(params.amount, duplicateCount);
|
|
160
|
-
if (resCase === "failure-retry") {
|
|
161
|
-
return {
|
|
162
|
-
status: "failure",
|
|
163
|
-
substatus: "503-retry",
|
|
164
|
-
code: 503,
|
|
165
|
-
message: "Server is busy, please retry the request"
|
|
166
|
-
};
|
|
167
|
-
}
|
|
168
|
-
if (resCase === "failure-fraud") {
|
|
169
|
-
return {
|
|
170
|
-
status: "failure",
|
|
171
|
-
substatus: "502-fraud",
|
|
172
|
-
code: 502,
|
|
173
|
-
message: "Potential fraud detected with this purchase"
|
|
174
|
-
};
|
|
175
|
-
}
|
|
176
|
-
const checkoutId = this.createCheckoutRecord(hashId);
|
|
177
|
-
if (resCase === "success-deferred") {
|
|
178
|
-
return {
|
|
179
|
-
status: "success",
|
|
180
|
-
substatus: "202-deferred",
|
|
181
|
-
code: 202,
|
|
182
|
-
message: "Authorizing, checkout information will likely be returned via webhook"
|
|
183
|
-
};
|
|
184
|
-
}
|
|
185
|
-
return {
|
|
186
|
-
status: "success",
|
|
187
|
-
substatus: "201-immediate",
|
|
188
|
-
code: 201,
|
|
189
|
-
message: "Approved",
|
|
190
|
-
data: {
|
|
191
|
-
checkoutId,
|
|
192
|
-
paymentMethodOptions: ["embedded", "raw-card"]
|
|
193
|
-
}
|
|
194
|
-
};
|
|
195
|
-
}
|
|
196
|
-
createCheckoutRecord(hashId) {
|
|
197
|
-
const checkoutId = generateID();
|
|
198
|
-
INTERNAL_CHECKOUTS[checkoutId] = {
|
|
199
|
-
historyRecordId: hashId
|
|
200
|
-
};
|
|
201
|
-
return checkoutId;
|
|
202
|
-
}
|
|
203
|
-
scheduleCreateWebhook(hashId, response) {
|
|
204
|
-
const webhookDelay = Math.random() * 3000;
|
|
205
|
-
setTimeout(() => {
|
|
206
|
-
if (response.status === "success" && response.substatus === "202-deferred") {
|
|
207
|
-
const isSuccess = Math.random() > 0.35;
|
|
208
|
-
if (isSuccess) {
|
|
209
|
-
const checkoutId = this.createCheckoutRecord(hashId);
|
|
210
|
-
this.sendWebhookResponse("checkout.create", {
|
|
211
|
-
status: "success",
|
|
212
|
-
substatus: "201-immediate",
|
|
213
|
-
code: 201,
|
|
214
|
-
message: "Approved",
|
|
215
|
-
data: {
|
|
216
|
-
checkoutId,
|
|
217
|
-
paymentMethodOptions: ["embedded", "raw-card"]
|
|
218
|
-
}
|
|
219
|
-
});
|
|
220
|
-
} else {
|
|
221
|
-
this.sendWebhookResponse("checkout.create", {
|
|
222
|
-
status: "failure",
|
|
223
|
-
substatus: "502-fraud",
|
|
224
|
-
code: 502,
|
|
225
|
-
message: "Potential fraud detected with this purchase"
|
|
226
|
-
});
|
|
1
|
+
import { Checkout } from './resources/checkout';
|
|
2
|
+
import { Webhooks } from './resources/webhooks';
|
|
3
|
+
export default class HenryPaymentProcessorSDK {
|
|
4
|
+
checkout;
|
|
5
|
+
webhooks;
|
|
6
|
+
VALID_API_KEYS = ['824c951e-dfac-4342-8e03', '3f67e17a-880a-463b-a667', '78254d40-623a-48c5-b83f'];
|
|
7
|
+
constructor(config) {
|
|
8
|
+
if (!config.apiKey) {
|
|
9
|
+
throw new Error('Henry: apiKey is required');
|
|
227
10
|
}
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
this.sendWebhookResponse("checkout.create", response);
|
|
231
|
-
}, webhookDelay);
|
|
232
|
-
}
|
|
233
|
-
buildHistoryHash(params) {
|
|
234
|
-
return hashToNumber(JSON.stringify({
|
|
235
|
-
type: "HISTORY_RECORD",
|
|
236
|
-
amount: params.amount,
|
|
237
|
-
currency: params.currency,
|
|
238
|
-
customerId: params.customerId
|
|
239
|
-
}));
|
|
240
|
-
}
|
|
241
|
-
async validateConfirm(params) {
|
|
242
|
-
if (!INTERNAL_CHECKOUTS[params.checkoutId]) {
|
|
243
|
-
return {
|
|
244
|
-
status: "failure",
|
|
245
|
-
substatus: "500-error",
|
|
246
|
-
code: 500,
|
|
247
|
-
message: "Invalid checkout ID"
|
|
248
|
-
};
|
|
249
|
-
}
|
|
250
|
-
if (params.type === "embedded") {
|
|
251
|
-
const stored = INTERNAL_CARDS[params.data.paymentToken];
|
|
252
|
-
if (!stored) {
|
|
253
|
-
return {
|
|
254
|
-
status: "failure",
|
|
255
|
-
substatus: "500-error",
|
|
256
|
-
code: 500,
|
|
257
|
-
message: "Invalid payment token"
|
|
258
|
-
};
|
|
259
|
-
}
|
|
260
|
-
}
|
|
261
|
-
if (params.type === "raw-card") {
|
|
262
|
-
const { number, expMonth, expYear, cvc } = params.data;
|
|
263
|
-
if (!this.isValidCardNumber(number)) {
|
|
264
|
-
return {
|
|
265
|
-
status: "failure",
|
|
266
|
-
substatus: "502-fraud",
|
|
267
|
-
code: 502,
|
|
268
|
-
message: "Invalid card number"
|
|
269
|
-
};
|
|
270
|
-
}
|
|
271
|
-
if (!this.isValidExpiry(expMonth, expYear)) {
|
|
272
|
-
return {
|
|
273
|
-
status: "failure",
|
|
274
|
-
substatus: "503-retry",
|
|
275
|
-
code: 503,
|
|
276
|
-
message: "Card expired"
|
|
277
|
-
};
|
|
278
|
-
}
|
|
279
|
-
if (!/^\d{3,4}$/.test(cvc)) {
|
|
280
|
-
return {
|
|
281
|
-
status: "failure",
|
|
282
|
-
substatus: "502-fraud",
|
|
283
|
-
code: 502,
|
|
284
|
-
message: "Invalid CVC"
|
|
285
|
-
};
|
|
286
|
-
}
|
|
287
|
-
}
|
|
288
|
-
return null;
|
|
289
|
-
}
|
|
290
|
-
async processConfirmDecision(params) {
|
|
291
|
-
const decision = Math.random();
|
|
292
|
-
if (decision > 0.85) {
|
|
293
|
-
return {
|
|
294
|
-
status: "failure",
|
|
295
|
-
substatus: "502-fraud",
|
|
296
|
-
code: 502,
|
|
297
|
-
message: "Potential fraud detected with this purchase"
|
|
298
|
-
};
|
|
299
|
-
}
|
|
300
|
-
if (decision > 0.65) {
|
|
301
|
-
return {
|
|
302
|
-
status: "failure",
|
|
303
|
-
substatus: "503-retry",
|
|
304
|
-
code: 503,
|
|
305
|
-
message: "Server is busy, please retry the request"
|
|
306
|
-
};
|
|
307
|
-
}
|
|
308
|
-
if (decision > 0.35) {
|
|
309
|
-
return {
|
|
310
|
-
status: "success",
|
|
311
|
-
substatus: "202-deferred",
|
|
312
|
-
code: 202,
|
|
313
|
-
message: "Authorizing, purchase information will likely be returned via webhook"
|
|
314
|
-
};
|
|
315
|
-
}
|
|
316
|
-
return this.buildInstantConfirmSuccess(params.checkoutId);
|
|
317
|
-
}
|
|
318
|
-
async buildInstantConfirmSuccess(checkoutId) {
|
|
319
|
-
const { historyRecordId } = INTERNAL_CHECKOUTS[checkoutId] ?? {};
|
|
320
|
-
if (!historyRecordId) {
|
|
321
|
-
return {
|
|
322
|
-
status: "failure",
|
|
323
|
-
substatus: "500-error",
|
|
324
|
-
code: 500,
|
|
325
|
-
message: "Missing history record"
|
|
326
|
-
};
|
|
327
|
-
}
|
|
328
|
-
const history = await readHistory();
|
|
329
|
-
const record = history.find((v) => v.id === historyRecordId);
|
|
330
|
-
if (!record) {
|
|
331
|
-
return {
|
|
332
|
-
status: "failure",
|
|
333
|
-
substatus: "500-error",
|
|
334
|
-
code: 500,
|
|
335
|
-
message: "Missing history record"
|
|
336
|
-
};
|
|
337
|
-
}
|
|
338
|
-
const confirmationId = hashToNumber(JSON.stringify({
|
|
339
|
-
type: "CONFIRMATION_ID",
|
|
340
|
-
amount: record.amount,
|
|
341
|
-
currency: record.currency,
|
|
342
|
-
customerId: record.customerId
|
|
343
|
-
}));
|
|
344
|
-
return {
|
|
345
|
-
status: "success",
|
|
346
|
-
substatus: "201-immediate",
|
|
347
|
-
code: 201,
|
|
348
|
-
message: "Approved",
|
|
349
|
-
data: {
|
|
350
|
-
confirmationId,
|
|
351
|
-
amount: record.amount,
|
|
352
|
-
currency: record.currency,
|
|
353
|
-
customerId: record.customerId ?? undefined
|
|
354
|
-
}
|
|
355
|
-
};
|
|
356
|
-
}
|
|
357
|
-
scheduleConfirmWebhook(params, response) {
|
|
358
|
-
const webhookDelay = Math.random() * 3000;
|
|
359
|
-
setTimeout(() => {
|
|
360
|
-
if (response.status === "success" && response.substatus === "202-deferred") {
|
|
361
|
-
const isSuccess = Math.random() > 0.35;
|
|
362
|
-
if (isSuccess) {
|
|
363
|
-
this.buildInstantConfirmSuccess(params.checkoutId).then((finalResponse) => {
|
|
364
|
-
this.sendWebhookResponse("checkout.confirm", finalResponse);
|
|
365
|
-
});
|
|
366
|
-
} else {
|
|
367
|
-
this.sendWebhookResponse("checkout.confirm", {
|
|
368
|
-
status: "failure",
|
|
369
|
-
substatus: "502-fraud",
|
|
370
|
-
code: 502,
|
|
371
|
-
message: "Potential fraud detected with this purchase"
|
|
372
|
-
});
|
|
11
|
+
if (!this.VALID_API_KEYS.includes(config.apiKey)) {
|
|
12
|
+
throw new Error('Henry: Invalid apiKey provided');
|
|
373
13
|
}
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
this.sendWebhookResponse("checkout.confirm", response);
|
|
377
|
-
}, webhookDelay);
|
|
378
|
-
}
|
|
379
|
-
isValidCardNumber(number) {
|
|
380
|
-
if (number === "4242424242424242")
|
|
381
|
-
return true;
|
|
382
|
-
if (!/^\d{13,19}$/.test(number))
|
|
383
|
-
return false;
|
|
384
|
-
let sum = 0;
|
|
385
|
-
let shouldDouble = false;
|
|
386
|
-
for (let i = number.length - 1;i >= 0; i--) {
|
|
387
|
-
let digit = parseInt(number[i] ?? "");
|
|
388
|
-
if (shouldDouble) {
|
|
389
|
-
digit *= 2;
|
|
390
|
-
if (digit > 9)
|
|
391
|
-
digit -= 9;
|
|
392
|
-
}
|
|
393
|
-
sum += digit;
|
|
394
|
-
shouldDouble = !shouldDouble;
|
|
395
|
-
}
|
|
396
|
-
return sum % 10 === 0;
|
|
397
|
-
}
|
|
398
|
-
isValidExpiry(month, year) {
|
|
399
|
-
if (month < 1 || month > 12)
|
|
400
|
-
return false;
|
|
401
|
-
const now = new Date;
|
|
402
|
-
const expiry = new Date(year, month - 1);
|
|
403
|
-
return expiry > now;
|
|
404
|
-
}
|
|
405
|
-
determineResponseCase(amount, sameRecords) {
|
|
406
|
-
let immediateWeight = 65;
|
|
407
|
-
let deferredWeight = 20;
|
|
408
|
-
let retryWeight = 10;
|
|
409
|
-
let fraudWeight = 5;
|
|
410
|
-
immediateWeight -= sameRecords * 10;
|
|
411
|
-
deferredWeight += sameRecords * 5;
|
|
412
|
-
retryWeight += sameRecords * 5;
|
|
413
|
-
fraudWeight += sameRecords * 5;
|
|
414
|
-
if (amount > 1000) {
|
|
415
|
-
deferredWeight += 10;
|
|
416
|
-
retryWeight += 5;
|
|
417
|
-
}
|
|
418
|
-
if (amount > 5000) {
|
|
419
|
-
immediateWeight -= 10;
|
|
420
|
-
retryWeight += 10;
|
|
421
|
-
fraudWeight += 10;
|
|
422
|
-
}
|
|
423
|
-
if (amount > 1e4) {
|
|
424
|
-
fraudWeight += 20;
|
|
425
|
-
}
|
|
426
|
-
immediateWeight = Math.max(0, immediateWeight);
|
|
427
|
-
deferredWeight = Math.max(0, deferredWeight);
|
|
428
|
-
retryWeight = Math.max(0, retryWeight);
|
|
429
|
-
fraudWeight = Math.max(0, fraudWeight);
|
|
430
|
-
const total = immediateWeight + deferredWeight + retryWeight + fraudWeight;
|
|
431
|
-
const rand = Math.random() * total;
|
|
432
|
-
if (rand < immediateWeight) {
|
|
433
|
-
return "success-immediate";
|
|
434
|
-
}
|
|
435
|
-
if (rand < immediateWeight + deferredWeight) {
|
|
436
|
-
return "success-deferred";
|
|
437
|
-
}
|
|
438
|
-
if (rand < immediateWeight + deferredWeight + retryWeight) {
|
|
439
|
-
return "failure-retry";
|
|
440
|
-
}
|
|
441
|
-
return "failure-fraud";
|
|
442
|
-
}
|
|
443
|
-
async sendWebhookResponse(baseType, response) {
|
|
444
|
-
const statusSuffix = response.status === "success" ? "success" : "failure";
|
|
445
|
-
const eventType = `${baseType}.${statusSuffix}`;
|
|
446
|
-
const matchingTypes = ["checkout", baseType, eventType];
|
|
447
|
-
const hooks = INTERNAL_WEBHOOKS.filter((w) => matchingTypes.includes(w.event));
|
|
448
|
-
const event = {
|
|
449
|
-
id: crypto.randomUUID(),
|
|
450
|
-
type: eventType,
|
|
451
|
-
createdAt: Date.now(),
|
|
452
|
-
data: response
|
|
453
|
-
};
|
|
454
|
-
const payload = JSON.stringify(event);
|
|
455
|
-
await Promise.allSettled(hooks.map(async (hook) => {
|
|
456
|
-
const headers = {
|
|
457
|
-
"Content-Type": "application/json"
|
|
458
|
-
};
|
|
459
|
-
if (hook.secret) {
|
|
460
|
-
headers["webhook-signature"] = signPayload(payload, hook.secret);
|
|
461
|
-
}
|
|
462
|
-
await fetch(hook.url, {
|
|
463
|
-
method: "POST",
|
|
464
|
-
headers,
|
|
465
|
-
body: payload
|
|
466
|
-
});
|
|
467
|
-
}));
|
|
468
|
-
return true;
|
|
469
|
-
}
|
|
470
|
-
}
|
|
471
|
-
|
|
472
|
-
// src/index.ts
|
|
473
|
-
class HenryPaymentProcessorSDK {
|
|
474
|
-
checkout;
|
|
475
|
-
webhooks;
|
|
476
|
-
VALID_API_KEYS = ["824c951e-dfac-4342-8e03", "3f67e17a-880a-463b-a667", "78254d40-623a-48c5-b83f"];
|
|
477
|
-
constructor(config) {
|
|
478
|
-
if (!config.apiKey) {
|
|
479
|
-
throw new Error("Henry: apiKey is required");
|
|
480
|
-
}
|
|
481
|
-
if (!this.VALID_API_KEYS.includes(config.apiKey)) {
|
|
482
|
-
throw new Error("Henry: Invalid apiKey provided");
|
|
14
|
+
this.checkout = new Checkout();
|
|
15
|
+
this.webhooks = new Webhooks();
|
|
483
16
|
}
|
|
484
|
-
this.checkout = new Checkout;
|
|
485
|
-
this.webhooks = new Webhooks;
|
|
486
|
-
}
|
|
487
17
|
}
|
|
@@ -0,0 +1,416 @@
|
|
|
1
|
+
import { readHistory } from '../utils/store';
|
|
2
|
+
import { generateID, hashToNumber, signPayload } from '../utils/crypto';
|
|
3
|
+
import { INTERNAL_WEBHOOKS } from './webhooks';
|
|
4
|
+
import { sleep } from '../utils/async';
|
|
5
|
+
// checkoutId -> { historyRecordId }
|
|
6
|
+
const INTERNAL_CHECKOUTS = {};
|
|
7
|
+
// paymentToken -> card details
|
|
8
|
+
const INTERNAL_CARDS = {};
|
|
9
|
+
export class Checkout {
|
|
10
|
+
/**
|
|
11
|
+
* Create a new checkout session
|
|
12
|
+
* @param amount - The amount for the checkout
|
|
13
|
+
* @param currency - The curreny type (note: not all are supported atm)
|
|
14
|
+
* @param customerId - Optional customer ID, used for unique customer identification
|
|
15
|
+
* @returns The response from the checkout creation
|
|
16
|
+
*/
|
|
17
|
+
async create(params) {
|
|
18
|
+
await sleep(Math.random() * 100);
|
|
19
|
+
const hashId = this.buildHistoryHash(params);
|
|
20
|
+
const history = await readHistory();
|
|
21
|
+
const sameRecords = history.filter((v) => v.id === hashId);
|
|
22
|
+
// ---------------------------------------
|
|
23
|
+
// Phase 1: Validation
|
|
24
|
+
// ---------------------------------------
|
|
25
|
+
const validationFailure = this.validateCreate(params);
|
|
26
|
+
// ---------------------------------------
|
|
27
|
+
// Phase 2: Business Logic
|
|
28
|
+
// ---------------------------------------
|
|
29
|
+
const response = validationFailure ?? this.processCreateDecision(params, hashId, sameRecords.length);
|
|
30
|
+
// ---------------------------------------
|
|
31
|
+
// Phase 3: Webhook Scheduling
|
|
32
|
+
// ---------------------------------------
|
|
33
|
+
this.scheduleCreateWebhook(hashId, response);
|
|
34
|
+
// Simulate API latency
|
|
35
|
+
await sleep(Math.random() * 2000);
|
|
36
|
+
return response;
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Confirm a checkout session
|
|
40
|
+
* @param params - Either embedded or raw card checkout confirmation parameters
|
|
41
|
+
* @returns - The response from the checkout confirmation
|
|
42
|
+
*/
|
|
43
|
+
async confirm(params) {
|
|
44
|
+
await sleep(Math.random() * 100);
|
|
45
|
+
// ---------------------------------------
|
|
46
|
+
// Phase 1: Validation (no early returns)
|
|
47
|
+
// ---------------------------------------
|
|
48
|
+
const validationFailure = await this.validateConfirm(params);
|
|
49
|
+
// ---------------------------------------
|
|
50
|
+
// Phase 2: Business Logic
|
|
51
|
+
// ---------------------------------------
|
|
52
|
+
const response = validationFailure ?? (await this.processConfirmDecision(params));
|
|
53
|
+
// ---------------------------------------
|
|
54
|
+
// Phase 3: Async Webhook Emission
|
|
55
|
+
// ---------------------------------------
|
|
56
|
+
this.scheduleConfirmWebhook(params, response);
|
|
57
|
+
// Simulate response delay
|
|
58
|
+
await sleep(Math.random() * 2000);
|
|
59
|
+
return response;
|
|
60
|
+
}
|
|
61
|
+
validateCreate(params) {
|
|
62
|
+
if (params.amount <= 0) {
|
|
63
|
+
return {
|
|
64
|
+
status: 'failure',
|
|
65
|
+
substatus: '500-error',
|
|
66
|
+
code: 500,
|
|
67
|
+
message: 'Invalid amount',
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
if (params.currency === 'JPY') {
|
|
71
|
+
return {
|
|
72
|
+
status: 'failure',
|
|
73
|
+
substatus: '501-not-supported',
|
|
74
|
+
code: 501,
|
|
75
|
+
message: 'This currency is currently not supported, please convert first.',
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
// Simulated random internal failure
|
|
79
|
+
if (Math.random() > 0.85) {
|
|
80
|
+
return {
|
|
81
|
+
status: 'failure',
|
|
82
|
+
substatus: '500-error',
|
|
83
|
+
code: 500,
|
|
84
|
+
message: 'An internal error occurred when creating checkout',
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
return null;
|
|
88
|
+
}
|
|
89
|
+
processCreateDecision(params, hashId, duplicateCount) {
|
|
90
|
+
const resCase = this.determineResponseCase(params.amount, duplicateCount);
|
|
91
|
+
if (resCase === 'failure-retry') {
|
|
92
|
+
return {
|
|
93
|
+
status: 'failure',
|
|
94
|
+
substatus: '503-retry',
|
|
95
|
+
code: 503,
|
|
96
|
+
message: 'Server is busy, please retry the request',
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
if (resCase === 'failure-fraud') {
|
|
100
|
+
return {
|
|
101
|
+
status: 'failure',
|
|
102
|
+
substatus: '502-fraud',
|
|
103
|
+
code: 502,
|
|
104
|
+
message: 'Potential fraud detected with this purchase',
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
const checkoutId = this.createCheckoutRecord(hashId);
|
|
108
|
+
if (resCase === 'success-deferred') {
|
|
109
|
+
return {
|
|
110
|
+
status: 'success',
|
|
111
|
+
substatus: '202-deferred',
|
|
112
|
+
code: 202,
|
|
113
|
+
message: 'Authorizing, checkout information will likely be returned via webhook',
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
return {
|
|
117
|
+
status: 'success',
|
|
118
|
+
substatus: '201-immediate',
|
|
119
|
+
code: 201,
|
|
120
|
+
message: 'Approved',
|
|
121
|
+
data: {
|
|
122
|
+
checkoutId,
|
|
123
|
+
paymentMethodOptions: ['embedded', 'raw-card'],
|
|
124
|
+
},
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
createCheckoutRecord(hashId) {
|
|
128
|
+
const checkoutId = generateID();
|
|
129
|
+
INTERNAL_CHECKOUTS[checkoutId] = {
|
|
130
|
+
historyRecordId: hashId,
|
|
131
|
+
};
|
|
132
|
+
return checkoutId;
|
|
133
|
+
}
|
|
134
|
+
scheduleCreateWebhook(hashId, response) {
|
|
135
|
+
const webhookDelay = Math.random() * 3000;
|
|
136
|
+
setTimeout(() => {
|
|
137
|
+
// Deferred flow resolves later
|
|
138
|
+
if (response.status === 'success' && response.substatus === '202-deferred') {
|
|
139
|
+
const isSuccess = Math.random() > 0.35;
|
|
140
|
+
if (isSuccess) {
|
|
141
|
+
const checkoutId = this.createCheckoutRecord(hashId);
|
|
142
|
+
this.sendWebhookResponse('checkout.create', {
|
|
143
|
+
status: 'success',
|
|
144
|
+
substatus: '201-immediate',
|
|
145
|
+
code: 201,
|
|
146
|
+
message: 'Approved',
|
|
147
|
+
data: {
|
|
148
|
+
checkoutId,
|
|
149
|
+
paymentMethodOptions: ['embedded', 'raw-card'],
|
|
150
|
+
},
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
else {
|
|
154
|
+
this.sendWebhookResponse('checkout.create', {
|
|
155
|
+
status: 'failure',
|
|
156
|
+
substatus: '502-fraud',
|
|
157
|
+
code: 502,
|
|
158
|
+
message: 'Potential fraud detected with this purchase',
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
163
|
+
// Immediate success or failure
|
|
164
|
+
this.sendWebhookResponse('checkout.create', response);
|
|
165
|
+
}, webhookDelay);
|
|
166
|
+
}
|
|
167
|
+
buildHistoryHash(params) {
|
|
168
|
+
return hashToNumber(JSON.stringify({
|
|
169
|
+
type: 'HISTORY_RECORD',
|
|
170
|
+
amount: params.amount,
|
|
171
|
+
currency: params.currency,
|
|
172
|
+
customerId: params.customerId,
|
|
173
|
+
}));
|
|
174
|
+
}
|
|
175
|
+
///
|
|
176
|
+
async validateConfirm(params) {
|
|
177
|
+
if (!INTERNAL_CHECKOUTS[params.checkoutId]) {
|
|
178
|
+
return {
|
|
179
|
+
status: 'failure',
|
|
180
|
+
substatus: '500-error',
|
|
181
|
+
code: 500,
|
|
182
|
+
message: 'Invalid checkout ID',
|
|
183
|
+
};
|
|
184
|
+
}
|
|
185
|
+
if (params.type === 'embedded') {
|
|
186
|
+
const stored = INTERNAL_CARDS[params.data.paymentToken];
|
|
187
|
+
if (!stored) {
|
|
188
|
+
return {
|
|
189
|
+
status: 'failure',
|
|
190
|
+
substatus: '500-error',
|
|
191
|
+
code: 500,
|
|
192
|
+
message: 'Invalid payment token',
|
|
193
|
+
};
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
if (params.type === 'raw-card') {
|
|
197
|
+
const { number, expMonth, expYear, cvc } = params.data;
|
|
198
|
+
if (!this.isValidCardNumber(number)) {
|
|
199
|
+
return {
|
|
200
|
+
status: 'failure',
|
|
201
|
+
substatus: '502-fraud',
|
|
202
|
+
code: 502,
|
|
203
|
+
message: 'Invalid card number',
|
|
204
|
+
};
|
|
205
|
+
}
|
|
206
|
+
if (!this.isValidExpiry(expMonth, expYear)) {
|
|
207
|
+
return {
|
|
208
|
+
status: 'failure',
|
|
209
|
+
substatus: '503-retry',
|
|
210
|
+
code: 503,
|
|
211
|
+
message: 'Card expired',
|
|
212
|
+
};
|
|
213
|
+
}
|
|
214
|
+
if (!/^\d{3,4}$/.test(cvc)) {
|
|
215
|
+
return {
|
|
216
|
+
status: 'failure',
|
|
217
|
+
substatus: '502-fraud',
|
|
218
|
+
code: 502,
|
|
219
|
+
message: 'Invalid CVC',
|
|
220
|
+
};
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
return null;
|
|
224
|
+
}
|
|
225
|
+
async processConfirmDecision(params) {
|
|
226
|
+
const decision = Math.random();
|
|
227
|
+
if (decision > 0.85) {
|
|
228
|
+
return {
|
|
229
|
+
status: 'failure',
|
|
230
|
+
substatus: '502-fraud',
|
|
231
|
+
code: 502,
|
|
232
|
+
message: 'Potential fraud detected with this purchase',
|
|
233
|
+
};
|
|
234
|
+
}
|
|
235
|
+
if (decision > 0.65) {
|
|
236
|
+
return {
|
|
237
|
+
status: 'failure',
|
|
238
|
+
substatus: '503-retry',
|
|
239
|
+
code: 503,
|
|
240
|
+
message: 'Server is busy, please retry the request',
|
|
241
|
+
};
|
|
242
|
+
}
|
|
243
|
+
if (decision > 0.35) {
|
|
244
|
+
return {
|
|
245
|
+
status: 'success',
|
|
246
|
+
substatus: '202-deferred',
|
|
247
|
+
code: 202,
|
|
248
|
+
message: 'Authorizing, purchase information will likely be returned via webhook',
|
|
249
|
+
};
|
|
250
|
+
}
|
|
251
|
+
return this.buildInstantConfirmSuccess(params.checkoutId);
|
|
252
|
+
}
|
|
253
|
+
async buildInstantConfirmSuccess(checkoutId) {
|
|
254
|
+
const { historyRecordId } = INTERNAL_CHECKOUTS[checkoutId] ?? {};
|
|
255
|
+
if (!historyRecordId) {
|
|
256
|
+
return {
|
|
257
|
+
status: 'failure',
|
|
258
|
+
substatus: '500-error',
|
|
259
|
+
code: 500,
|
|
260
|
+
message: 'Missing history record',
|
|
261
|
+
};
|
|
262
|
+
}
|
|
263
|
+
const history = await readHistory();
|
|
264
|
+
const record = history.find((v) => v.id === historyRecordId);
|
|
265
|
+
if (!record) {
|
|
266
|
+
return {
|
|
267
|
+
status: 'failure',
|
|
268
|
+
substatus: '500-error',
|
|
269
|
+
code: 500,
|
|
270
|
+
message: 'Missing history record',
|
|
271
|
+
};
|
|
272
|
+
}
|
|
273
|
+
const confirmationId = hashToNumber(JSON.stringify({
|
|
274
|
+
type: 'CONFIRMATION_ID',
|
|
275
|
+
amount: record.amount,
|
|
276
|
+
currency: record.currency,
|
|
277
|
+
customerId: record.customerId,
|
|
278
|
+
}));
|
|
279
|
+
return {
|
|
280
|
+
status: 'success',
|
|
281
|
+
substatus: '201-immediate',
|
|
282
|
+
code: 201,
|
|
283
|
+
message: 'Approved',
|
|
284
|
+
data: {
|
|
285
|
+
confirmationId,
|
|
286
|
+
amount: record.amount,
|
|
287
|
+
currency: record.currency,
|
|
288
|
+
customerId: record.customerId ?? undefined,
|
|
289
|
+
},
|
|
290
|
+
};
|
|
291
|
+
}
|
|
292
|
+
scheduleConfirmWebhook(params, response) {
|
|
293
|
+
const webhookDelay = Math.random() * 3000;
|
|
294
|
+
setTimeout(() => {
|
|
295
|
+
if (response.status === 'success' && response.substatus === '202-deferred') {
|
|
296
|
+
const isSuccess = Math.random() > 0.35;
|
|
297
|
+
if (isSuccess) {
|
|
298
|
+
this.buildInstantConfirmSuccess(params.checkoutId).then((finalResponse) => {
|
|
299
|
+
this.sendWebhookResponse('checkout.confirm', finalResponse);
|
|
300
|
+
});
|
|
301
|
+
}
|
|
302
|
+
else {
|
|
303
|
+
this.sendWebhookResponse('checkout.confirm', {
|
|
304
|
+
status: 'failure',
|
|
305
|
+
substatus: '502-fraud',
|
|
306
|
+
code: 502,
|
|
307
|
+
message: 'Potential fraud detected with this purchase',
|
|
308
|
+
});
|
|
309
|
+
}
|
|
310
|
+
return;
|
|
311
|
+
}
|
|
312
|
+
this.sendWebhookResponse('checkout.confirm', response);
|
|
313
|
+
}, webhookDelay);
|
|
314
|
+
}
|
|
315
|
+
//
|
|
316
|
+
isValidCardNumber(number) {
|
|
317
|
+
// Test card is valid
|
|
318
|
+
if (number === '4242424242424242')
|
|
319
|
+
return true;
|
|
320
|
+
// Cards must be 13-19 digits
|
|
321
|
+
if (!/^\d{13,19}$/.test(number))
|
|
322
|
+
return false;
|
|
323
|
+
// Luhn check
|
|
324
|
+
let sum = 0;
|
|
325
|
+
let shouldDouble = false;
|
|
326
|
+
for (let i = number.length - 1; i >= 0; i--) {
|
|
327
|
+
let digit = parseInt(number[i] ?? '');
|
|
328
|
+
if (shouldDouble) {
|
|
329
|
+
digit *= 2;
|
|
330
|
+
if (digit > 9)
|
|
331
|
+
digit -= 9;
|
|
332
|
+
}
|
|
333
|
+
sum += digit;
|
|
334
|
+
shouldDouble = !shouldDouble;
|
|
335
|
+
}
|
|
336
|
+
return sum % 10 === 0;
|
|
337
|
+
}
|
|
338
|
+
isValidExpiry(month, year) {
|
|
339
|
+
if (month < 1 || month > 12)
|
|
340
|
+
return false;
|
|
341
|
+
const now = new Date();
|
|
342
|
+
const expiry = new Date(year, month - 1);
|
|
343
|
+
return expiry > now;
|
|
344
|
+
}
|
|
345
|
+
determineResponseCase(amount, sameRecords) {
|
|
346
|
+
// --- Base probabilities ---
|
|
347
|
+
let immediateWeight = 65;
|
|
348
|
+
let deferredWeight = 20;
|
|
349
|
+
let retryWeight = 10;
|
|
350
|
+
let fraudWeight = 5;
|
|
351
|
+
// --- Adjust based on repeated attempts ---
|
|
352
|
+
immediateWeight -= sameRecords * 10;
|
|
353
|
+
deferredWeight += sameRecords * 5;
|
|
354
|
+
retryWeight += sameRecords * 5;
|
|
355
|
+
fraudWeight += sameRecords * 5;
|
|
356
|
+
// --- Adjust based on amount ---
|
|
357
|
+
if (amount > 1000) {
|
|
358
|
+
deferredWeight += 10;
|
|
359
|
+
retryWeight += 5;
|
|
360
|
+
}
|
|
361
|
+
if (amount > 5000) {
|
|
362
|
+
immediateWeight -= 10;
|
|
363
|
+
retryWeight += 10;
|
|
364
|
+
fraudWeight += 10;
|
|
365
|
+
}
|
|
366
|
+
if (amount > 10000) {
|
|
367
|
+
fraudWeight += 20;
|
|
368
|
+
}
|
|
369
|
+
// Ensure no negative weights
|
|
370
|
+
immediateWeight = Math.max(0, immediateWeight);
|
|
371
|
+
deferredWeight = Math.max(0, deferredWeight);
|
|
372
|
+
retryWeight = Math.max(0, retryWeight);
|
|
373
|
+
fraudWeight = Math.max(0, fraudWeight);
|
|
374
|
+
// --- Weighted random selection ---
|
|
375
|
+
const total = immediateWeight + deferredWeight + retryWeight + fraudWeight;
|
|
376
|
+
const rand = Math.random() * total;
|
|
377
|
+
if (rand < immediateWeight) {
|
|
378
|
+
return 'success-immediate';
|
|
379
|
+
}
|
|
380
|
+
if (rand < immediateWeight + deferredWeight) {
|
|
381
|
+
return 'success-deferred';
|
|
382
|
+
}
|
|
383
|
+
if (rand < immediateWeight + deferredWeight + retryWeight) {
|
|
384
|
+
return 'failure-retry';
|
|
385
|
+
}
|
|
386
|
+
return 'failure-fraud';
|
|
387
|
+
}
|
|
388
|
+
async sendWebhookResponse(baseType, response) {
|
|
389
|
+
const statusSuffix = response.status === 'success' ? 'success' : 'failure';
|
|
390
|
+
const eventType = `${baseType}.${statusSuffix}`;
|
|
391
|
+
// Build all matching event types hierarchically
|
|
392
|
+
const matchingTypes = ['checkout', baseType, eventType];
|
|
393
|
+
const hooks = INTERNAL_WEBHOOKS.filter((w) => matchingTypes.includes(w.event));
|
|
394
|
+
const event = {
|
|
395
|
+
id: crypto.randomUUID(),
|
|
396
|
+
type: eventType,
|
|
397
|
+
createdAt: Date.now(),
|
|
398
|
+
data: response,
|
|
399
|
+
};
|
|
400
|
+
const payload = JSON.stringify(event);
|
|
401
|
+
await Promise.allSettled(hooks.map(async (hook) => {
|
|
402
|
+
const headers = {
|
|
403
|
+
'Content-Type': 'application/json',
|
|
404
|
+
};
|
|
405
|
+
if (hook.secret) {
|
|
406
|
+
headers['x-henry-signature'] = signPayload(payload, hook.secret);
|
|
407
|
+
}
|
|
408
|
+
await fetch(hook.url, {
|
|
409
|
+
method: 'POST',
|
|
410
|
+
headers,
|
|
411
|
+
body: payload,
|
|
412
|
+
});
|
|
413
|
+
}));
|
|
414
|
+
return true;
|
|
415
|
+
}
|
|
416
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { sleep } from '../utils/async';
|
|
2
|
+
export const INTERNAL_WEBHOOKS = [];
|
|
3
|
+
export class Webhooks {
|
|
4
|
+
/**
|
|
5
|
+
* Registers a new webhook endpoint for the specified events
|
|
6
|
+
* @param params - The webhook parameters including URL, events, and optional secret
|
|
7
|
+
* @returns A promise that resolves to a boolean indicating successful registration
|
|
8
|
+
*/
|
|
9
|
+
async createEndpoint(params) {
|
|
10
|
+
for (const event of params.events) {
|
|
11
|
+
await sleep(Math.random() * 100);
|
|
12
|
+
INTERNAL_WEBHOOKS.push({
|
|
13
|
+
url: params.url,
|
|
14
|
+
event: event,
|
|
15
|
+
secret: params.secret,
|
|
16
|
+
});
|
|
17
|
+
}
|
|
18
|
+
return true;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import crypto from 'crypto';
|
|
2
|
+
export function generateID(length = 12) {
|
|
3
|
+
const digits = [];
|
|
4
|
+
const bytes = crypto.randomBytes(length);
|
|
5
|
+
for (let i = 0; i < length; i++) {
|
|
6
|
+
digits.push((bytes[i] ?? 0) % 10);
|
|
7
|
+
}
|
|
8
|
+
return parseInt(digits.join(''));
|
|
9
|
+
}
|
|
10
|
+
export function hashToNumber(input, length = 12) {
|
|
11
|
+
if (length > 16) {
|
|
12
|
+
throw new Error('Length is greater than max length of 16!');
|
|
13
|
+
}
|
|
14
|
+
const hash = crypto.createHash('sha256').update(input).digest('hex');
|
|
15
|
+
return parseInt(hash.slice(0, length), 16);
|
|
16
|
+
}
|
|
17
|
+
export function signPayload(payload, secret) {
|
|
18
|
+
return crypto.createHmac('sha256', secret).update(payload).digest('hex');
|
|
19
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
const path = new URL('../db-store/history.json', import.meta.url);
|
|
2
|
+
/**
|
|
3
|
+
* Reads all history records from the JSON store.
|
|
4
|
+
*/
|
|
5
|
+
export async function readHistory() {
|
|
6
|
+
const file = Bun.file(path);
|
|
7
|
+
if (!(await file.exists())) {
|
|
8
|
+
return [];
|
|
9
|
+
}
|
|
10
|
+
const text = await file.text();
|
|
11
|
+
if (!text)
|
|
12
|
+
return [];
|
|
13
|
+
try {
|
|
14
|
+
return JSON.parse(text);
|
|
15
|
+
}
|
|
16
|
+
catch {
|
|
17
|
+
return [];
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Writes a new record to the JSON store.
|
|
22
|
+
*/
|
|
23
|
+
export async function writeHistory(params) {
|
|
24
|
+
const history = await readHistory();
|
|
25
|
+
const newRecord = {
|
|
26
|
+
updatedAt: new Date().getTime(),
|
|
27
|
+
...params,
|
|
28
|
+
};
|
|
29
|
+
history.push(newRecord);
|
|
30
|
+
await Bun.write(path, JSON.stringify(history, null, 2));
|
|
31
|
+
return newRecord;
|
|
32
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@henrylabs-interview/payment-processor",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.9",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"main": "./dist/index.js",
|
|
6
6
|
"types": "./dist/index.d.ts",
|
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
"dist"
|
|
15
15
|
],
|
|
16
16
|
"scripts": {
|
|
17
|
-
"build": "
|
|
17
|
+
"build": "rm -rf dist && bunx tsc -p tsconfig.build.json",
|
|
18
18
|
"dev": "bun run src/index.ts",
|
|
19
19
|
"publish": "bun run build && npm publish --access public"
|
|
20
20
|
},
|