@henrylabs-interview/payment-processor 0.1.6 → 0.1.8

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 CHANGED
@@ -1,487 +1,17 @@
1
- var __create = Object.create;
2
- var __getProtoOf = Object.getPrototypeOf;
3
- var __defProp = Object.defineProperty;
4
- var __getOwnPropNames = Object.getOwnPropertyNames;
5
- var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
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
- };
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
- 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
- };
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
- return;
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['webhook-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,22 +1,20 @@
1
1
  {
2
2
  "name": "@henrylabs-interview/payment-processor",
3
- "version": "0.1.6",
3
+ "version": "0.1.8",
4
4
  "type": "module",
5
- "main": "./dist/index.cjs",
6
- "module": "./dist/index.mjs",
5
+ "main": "./dist/index.js",
7
6
  "types": "./dist/index.d.ts",
8
7
  "exports": {
9
8
  ".": {
10
9
  "types": "./dist/index.d.ts",
11
- "import": "./dist/index.mjs",
12
- "require": "./dist/index.cjs"
10
+ "default": "./dist/index.js"
13
11
  }
14
12
  },
15
13
  "files": [
16
14
  "dist"
17
15
  ],
18
16
  "scripts": {
19
- "build": "bun run build.ts && bunx tsc -p tsconfig.build.json",
17
+ "build": "rm -rf dist && bunx tsc -p tsconfig.build.json",
20
18
  "dev": "bun run src/index.ts",
21
19
  "publish": "bun run build && npm publish --access public"
22
20
  },