@henrylabs-interview/payment-processor 0.1.3 → 0.1.5
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.d.ts +8 -7
- package/dist/index.js +445 -10
- package/dist/resources/checkout.d.ts +100 -0
- package/dist/resources/webhooks.d.ts +24 -0
- package/dist/utils/async.d.ts +1 -0
- package/dist/utils/crypto.d.ts +3 -0
- package/dist/utils/store.d.ts +17 -0
- package/package.json +3 -2
package/dist/index.d.ts
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
constructor(
|
|
7
|
-
|
|
1
|
+
import { Checkout } from './resources/checkout';
|
|
2
|
+
import { Webhooks } from './resources/webhooks';
|
|
3
|
+
export declare class HenryTakeHomeSDK {
|
|
4
|
+
checkout: Checkout;
|
|
5
|
+
webhooks: Webhooks;
|
|
6
|
+
constructor(config: {
|
|
7
|
+
apiKey: string;
|
|
8
|
+
});
|
|
8
9
|
}
|
package/dist/index.js
CHANGED
|
@@ -1,7 +1,20 @@
|
|
|
1
|
+
var __create = Object.create;
|
|
2
|
+
var __getProtoOf = Object.getPrototypeOf;
|
|
1
3
|
var __defProp = Object.defineProperty;
|
|
2
4
|
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
3
5
|
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
4
6
|
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
7
|
+
var __toESM = (mod, isNodeMode, target) => {
|
|
8
|
+
target = mod != null ? __create(__getProtoOf(mod)) : {};
|
|
9
|
+
const to = isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target;
|
|
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
|
+
};
|
|
5
18
|
var __moduleCache = /* @__PURE__ */ new WeakMap;
|
|
6
19
|
var __toCommonJS = (from) => {
|
|
7
20
|
var entry = __moduleCache.get(from), desc;
|
|
@@ -29,20 +42,442 @@ var __export = (target, all) => {
|
|
|
29
42
|
// src/index.ts
|
|
30
43
|
var exports_src = {};
|
|
31
44
|
__export(exports_src, {
|
|
32
|
-
|
|
45
|
+
HenryTakeHomeSDK: () => HenryTakeHomeSDK
|
|
33
46
|
});
|
|
34
47
|
module.exports = __toCommonJS(exports_src);
|
|
35
48
|
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
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
|
+
});
|
|
227
|
+
}
|
|
228
|
+
return;
|
|
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
|
+
};
|
|
40
356
|
}
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
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
|
+
});
|
|
373
|
+
}
|
|
374
|
+
return;
|
|
45
375
|
}
|
|
46
|
-
|
|
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 HenryTakeHomeSDK {
|
|
474
|
+
checkout;
|
|
475
|
+
webhooks;
|
|
476
|
+
constructor(config) {
|
|
477
|
+
if (!config.apiKey) {
|
|
478
|
+
throw new Error("Henry: apiKey is required");
|
|
479
|
+
}
|
|
480
|
+
this.checkout = new Checkout;
|
|
481
|
+
this.webhooks = new Webhooks;
|
|
47
482
|
}
|
|
48
483
|
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
type CheckoutCreateResponse = CheckoutCreateSuccessDeferred | CheckoutCreateSuccessImmediate | CheckoutCreateFailure;
|
|
2
|
+
interface CheckoutCreateGeneric {
|
|
3
|
+
status: 'success' | 'failure';
|
|
4
|
+
code: number;
|
|
5
|
+
message: string;
|
|
6
|
+
}
|
|
7
|
+
interface CheckoutCreateSuccessDeferred extends CheckoutCreateGeneric {
|
|
8
|
+
status: 'success';
|
|
9
|
+
substatus: '202-deferred';
|
|
10
|
+
}
|
|
11
|
+
interface CheckoutCreateSuccessImmediate extends CheckoutCreateGeneric {
|
|
12
|
+
status: 'success';
|
|
13
|
+
substatus: '201-immediate';
|
|
14
|
+
data: {
|
|
15
|
+
checkoutId: number;
|
|
16
|
+
paymentMethodOptions: string[];
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
interface CheckoutCreateFailure extends CheckoutCreateGeneric {
|
|
20
|
+
status: 'failure';
|
|
21
|
+
substatus: '500-error' | '501-not-supported' | '502-fraud' | '503-retry';
|
|
22
|
+
}
|
|
23
|
+
type CheckoutConfirmParams = CheckoutConfirmParamsEmbedded | CheckoutConfirmParamsRawCard;
|
|
24
|
+
interface CheckoutConfirmParamsGeneric {
|
|
25
|
+
checkoutId: string;
|
|
26
|
+
type: 'embedded' | 'raw-card';
|
|
27
|
+
}
|
|
28
|
+
interface CheckoutConfirmParamsEmbedded extends CheckoutConfirmParamsGeneric {
|
|
29
|
+
type: 'embedded';
|
|
30
|
+
data: {
|
|
31
|
+
paymentToken: string;
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
interface CheckoutConfirmParamsRawCard extends CheckoutConfirmParamsGeneric {
|
|
35
|
+
type: 'raw-card';
|
|
36
|
+
data: {
|
|
37
|
+
number: string;
|
|
38
|
+
expMonth: number;
|
|
39
|
+
expYear: number;
|
|
40
|
+
cvc: string;
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
type CheckoutConfirmResponse = CheckoutConfirmSuccessDeferred | CheckoutConfirmSuccessImmediate | CheckoutConfirmFailure;
|
|
44
|
+
interface CheckoutConfirmGeneric {
|
|
45
|
+
status: 'success' | 'failure';
|
|
46
|
+
code: number;
|
|
47
|
+
message: string;
|
|
48
|
+
}
|
|
49
|
+
interface CheckoutConfirmSuccessDeferred extends CheckoutConfirmGeneric {
|
|
50
|
+
status: 'success';
|
|
51
|
+
substatus: '202-deferred';
|
|
52
|
+
}
|
|
53
|
+
interface CheckoutConfirmSuccessImmediate extends CheckoutConfirmGeneric {
|
|
54
|
+
status: 'success';
|
|
55
|
+
substatus: '201-immediate';
|
|
56
|
+
data: {
|
|
57
|
+
confirmationId: number;
|
|
58
|
+
amount: number;
|
|
59
|
+
currency: string;
|
|
60
|
+
customerId?: string;
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
interface CheckoutConfirmFailure extends CheckoutConfirmGeneric {
|
|
64
|
+
status: 'failure';
|
|
65
|
+
substatus: '500-error' | '502-fraud' | '503-retry';
|
|
66
|
+
}
|
|
67
|
+
export declare class Checkout {
|
|
68
|
+
/**
|
|
69
|
+
* Create a new checkout session
|
|
70
|
+
* @param amount - The amount for the checkout
|
|
71
|
+
* @param currency - The curreny type (note: not all are supported atm)
|
|
72
|
+
* @param customerId - Optional customer ID, used for unique customer identification
|
|
73
|
+
* @returns The response from the checkout creation
|
|
74
|
+
*/
|
|
75
|
+
create(params: {
|
|
76
|
+
amount: number;
|
|
77
|
+
currency: 'USD' | 'EUR' | 'JPY';
|
|
78
|
+
customerId?: string;
|
|
79
|
+
}): Promise<CheckoutCreateResponse>;
|
|
80
|
+
/**
|
|
81
|
+
* Confirm a checkout session
|
|
82
|
+
* @param params - Either embedded or raw card checkout confirmation parameters
|
|
83
|
+
* @returns - The response from the checkout confirmation
|
|
84
|
+
*/
|
|
85
|
+
confirm(params: CheckoutConfirmParams): Promise<CheckoutConfirmResponse>;
|
|
86
|
+
private validateCreate;
|
|
87
|
+
private processCreateDecision;
|
|
88
|
+
private createCheckoutRecord;
|
|
89
|
+
private scheduleCreateWebhook;
|
|
90
|
+
private buildHistoryHash;
|
|
91
|
+
private validateConfirm;
|
|
92
|
+
private processConfirmDecision;
|
|
93
|
+
private buildInstantConfirmSuccess;
|
|
94
|
+
private scheduleConfirmWebhook;
|
|
95
|
+
private isValidCardNumber;
|
|
96
|
+
private isValidExpiry;
|
|
97
|
+
private determineResponseCase;
|
|
98
|
+
private sendWebhookResponse;
|
|
99
|
+
}
|
|
100
|
+
export {};
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
export type EventType = 'checkout' | 'checkout.create' | 'checkout.create.success' | 'checkout.create.failure' | 'checkout.confirm' | 'checkout.confirm.success' | 'checkout.confirm.failure';
|
|
2
|
+
export interface WebhookEvent {
|
|
3
|
+
id: string;
|
|
4
|
+
type: EventType;
|
|
5
|
+
createdAt: number;
|
|
6
|
+
data: Record<string, any>;
|
|
7
|
+
}
|
|
8
|
+
export declare const INTERNAL_WEBHOOKS: {
|
|
9
|
+
url: string;
|
|
10
|
+
event: EventType;
|
|
11
|
+
secret?: string;
|
|
12
|
+
}[];
|
|
13
|
+
export declare class Webhooks {
|
|
14
|
+
/**
|
|
15
|
+
* Registers a new webhook endpoint for the specified events
|
|
16
|
+
* @param params - The webhook parameters including URL, events, and optional secret
|
|
17
|
+
* @returns A promise that resolves to a boolean indicating successful registration
|
|
18
|
+
*/
|
|
19
|
+
createEndpoint(params: {
|
|
20
|
+
url: string;
|
|
21
|
+
events: EventType[];
|
|
22
|
+
secret?: string;
|
|
23
|
+
}): Promise<boolean>;
|
|
24
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare const sleep: (ms: number) => Promise<unknown>;
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
export type HistoryRecord = {
|
|
2
|
+
id: number;
|
|
3
|
+
amount: number;
|
|
4
|
+
currency: 'USD' | 'EUR' | 'JPY';
|
|
5
|
+
customerId: string | null;
|
|
6
|
+
updatedAt: number;
|
|
7
|
+
createdAt: number;
|
|
8
|
+
confirmed: boolean;
|
|
9
|
+
};
|
|
10
|
+
/**
|
|
11
|
+
* Reads all history records from the JSON store.
|
|
12
|
+
*/
|
|
13
|
+
export declare function readHistory(): Promise<HistoryRecord[]>;
|
|
14
|
+
/**
|
|
15
|
+
* Writes a new record to the JSON store.
|
|
16
|
+
*/
|
|
17
|
+
export declare function writeHistory(params: Omit<HistoryRecord, 'updatedAt'>): Promise<HistoryRecord>;
|
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.5",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"main": "./dist/index.cjs",
|
|
6
6
|
"module": "./dist/index.mjs",
|
|
@@ -17,7 +17,8 @@
|
|
|
17
17
|
],
|
|
18
18
|
"scripts": {
|
|
19
19
|
"build": "bun run build.ts && bunx tsc -p tsconfig.build.json",
|
|
20
|
-
"dev": "bun run src/index.ts"
|
|
20
|
+
"dev": "bun run src/index.ts",
|
|
21
|
+
"publish": "bun run build && npm publish --access public"
|
|
21
22
|
},
|
|
22
23
|
"devDependencies": {
|
|
23
24
|
"@types/bun": "latest",
|