@henrylabs-interview/payment-processor 0.1.2 → 0.1.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +141 -5
- 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/README.md
CHANGED
|
@@ -1,15 +1,151 @@
|
|
|
1
|
-
#
|
|
1
|
+
# Henry Labs - Interview: Payment Processor
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
A lightweight payments SDK for creating and confirming checkouts, with built-in webhook support.
|
|
4
|
+
|
|
5
|
+
This SDK simulates a real-world payment processing system, including fraud detection, transient errors, retries, and asynchronous authorization flows.
|
|
6
|
+
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
## Installation
|
|
4
10
|
|
|
5
11
|
```bash
|
|
6
12
|
bun install
|
|
7
13
|
```
|
|
8
14
|
|
|
9
|
-
|
|
15
|
+
Build:
|
|
10
16
|
|
|
11
17
|
```bash
|
|
12
|
-
bun run
|
|
18
|
+
bun run build
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
---
|
|
22
|
+
|
|
23
|
+
## Overview
|
|
24
|
+
|
|
25
|
+
The SDK provides three primary capabilities:
|
|
26
|
+
|
|
27
|
+
- Create a checkout
|
|
28
|
+
- Confirm a checkout
|
|
29
|
+
- Register webhook endpoints
|
|
30
|
+
|
|
31
|
+
The system performs internal validation, fraud screening, and risk checks.
|
|
32
|
+
Depending on system conditions, operations may:
|
|
33
|
+
|
|
34
|
+
- Succeed immediately
|
|
35
|
+
- Succeed asynchronously
|
|
36
|
+
- Require a retry
|
|
37
|
+
- Fail due to risk controls
|
|
38
|
+
- Fail due to temporary system overload
|
|
39
|
+
|
|
40
|
+
Consumers should always handle both synchronous responses and webhook events.
|
|
41
|
+
|
|
42
|
+
---
|
|
43
|
+
|
|
44
|
+
## Usage
|
|
45
|
+
|
|
46
|
+
### Initialize
|
|
47
|
+
|
|
48
|
+
```ts
|
|
49
|
+
import { PaymentSDK } from 'henry-labs/take-home';
|
|
50
|
+
|
|
51
|
+
const sdk = new PaymentSDK();
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
---
|
|
55
|
+
|
|
56
|
+
## Create a Checkout
|
|
57
|
+
|
|
58
|
+
```ts
|
|
59
|
+
const response = await sdk.create({
|
|
60
|
+
amount: 1000,
|
|
61
|
+
currency: 'USD',
|
|
62
|
+
customerId: 'cust_123',
|
|
63
|
+
});
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
### Behavior
|
|
67
|
+
|
|
68
|
+
Creating a checkout may:
|
|
69
|
+
|
|
70
|
+
- Return immediate approval
|
|
71
|
+
- Return a pending authorization state
|
|
72
|
+
- Return a retryable error
|
|
73
|
+
- Return a fraud-related failure
|
|
74
|
+
- Fail due to temporary internal errors
|
|
75
|
+
|
|
76
|
+
If the checkout is authorized asynchronously, the final outcome will be delivered via webhook.
|
|
77
|
+
|
|
78
|
+
---
|
|
79
|
+
|
|
80
|
+
## Confirm a Checkout
|
|
81
|
+
|
|
82
|
+
```ts
|
|
83
|
+
const response = await sdk.confirm({
|
|
84
|
+
checkoutId: '...',
|
|
85
|
+
type: 'raw-card',
|
|
86
|
+
data: {
|
|
87
|
+
number: '4242424242424242',
|
|
88
|
+
expMonth: 12,
|
|
89
|
+
expYear: 2030,
|
|
90
|
+
cvc: '123',
|
|
91
|
+
},
|
|
92
|
+
});
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
### Behavior
|
|
96
|
+
|
|
97
|
+
Confirmation requests are subject to:
|
|
98
|
+
|
|
99
|
+
- Card validation
|
|
100
|
+
- Fraud screening
|
|
101
|
+
- Retry conditions
|
|
102
|
+
- System load conditions
|
|
103
|
+
|
|
104
|
+
Confirmations may resolve immediately or asynchronously.
|
|
105
|
+
Webhook handling is required to receive final outcomes in deferred cases.
|
|
106
|
+
|
|
107
|
+
---
|
|
108
|
+
|
|
109
|
+
## Webhooks
|
|
110
|
+
|
|
111
|
+
You can register webhook endpoints to receive event notifications.
|
|
112
|
+
|
|
113
|
+
```ts
|
|
114
|
+
sdk.webhooks.createEndpoint({
|
|
115
|
+
url: 'https://example.com/webhooks',
|
|
116
|
+
event: 'checkout.confirm',
|
|
117
|
+
secret: 'whsec_...',
|
|
118
|
+
});
|
|
13
119
|
```
|
|
14
120
|
|
|
15
|
-
|
|
121
|
+
### Webhook Events
|
|
122
|
+
|
|
123
|
+
Events are emitted for:
|
|
124
|
+
|
|
125
|
+
- Checkout creation
|
|
126
|
+
- Checkout confirmation
|
|
127
|
+
- Success outcomes
|
|
128
|
+
- Failure outcomes
|
|
129
|
+
|
|
130
|
+
Webhooks may be triggered for both synchronous and asynchronous results.
|
|
131
|
+
|
|
132
|
+
All webhook deliveries are signed if a secret is provided.
|
|
133
|
+
|
|
134
|
+
Consumers are responsible for verifying signatures and handling retries idempotently.
|
|
135
|
+
|
|
136
|
+
---
|
|
137
|
+
|
|
138
|
+
## Important Notes
|
|
139
|
+
|
|
140
|
+
- Not all operations resolve immediately.
|
|
141
|
+
- Some failures are retryable.
|
|
142
|
+
- The system may simulate temporary overload conditions.
|
|
143
|
+
- Fraud checks may result in blocked transactions.
|
|
144
|
+
- Consumers should implement robust retry and webhook handling logic.
|
|
145
|
+
|
|
146
|
+
---
|
|
147
|
+
|
|
148
|
+
## Disclaimer
|
|
149
|
+
|
|
150
|
+
This SDK is intended for evaluation and sandbox purposes only.
|
|
151
|
+
It does not process real payments.
|
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.4",
|
|
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",
|