@henrylabs-interview/payments 0.2.16

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 ADDED
@@ -0,0 +1,205 @@
1
+ # Henry Labs – Interview: Payment Processor
2
+
3
+ A lightweight payments SDK for creating and confirming checkouts, with built-in webhook support and an embeddable checkout UI.
4
+
5
+ This SDK simulates a real-world payment processing system, including:
6
+
7
+ - Fraud detection
8
+ - Transient system errors
9
+ - Retry scenarios
10
+ - Asynchronous authorization flows
11
+
12
+ It is designed to test robustness, error handling, and webhook integration.
13
+
14
+ ---
15
+
16
+ ## Installation
17
+
18
+ ```bash
19
+ bun install
20
+ ```
21
+
22
+ Build:
23
+
24
+ ```bash
25
+ bun run build
26
+ ```
27
+
28
+ ---
29
+
30
+ # Overview
31
+
32
+ The SDK provides:
33
+
34
+ - Checkout creation
35
+ - Checkout confirmation
36
+ - Webhook endpoint registration
37
+ - Embeddable checkout UI (frontend)
38
+ - API key validation
39
+
40
+ The system performs internal validation, fraud screening, and simulated load conditions.
41
+
42
+ Operations may:
43
+
44
+ - Succeed immediately
45
+ - Resolve asynchronously
46
+ - Require retries
47
+ - Fail due to fraud controls
48
+ - Fail due to temporary system overload
49
+
50
+ ---
51
+
52
+ # Usage
53
+
54
+ ---
55
+
56
+ ## Initialize (Backend)
57
+
58
+ ```ts
59
+ import { PaymentProcessor } from '@henrylabs-interview/payments';
60
+
61
+ const processor = new PaymentProcessor({
62
+ apiKey: ...,
63
+ });
64
+ ```
65
+
66
+ # Checkout API
67
+
68
+ Available under:
69
+
70
+ ```ts
71
+ processor.checkout;
72
+ ```
73
+
74
+ ---
75
+
76
+ ## Create a Checkout
77
+
78
+ ```ts
79
+ const response = await processor.checkout.create({
80
+ amount: 1000,
81
+ currency: 'USD',
82
+ customerId: 'cust_123',
83
+ });
84
+ ```
85
+
86
+ ---
87
+
88
+ ## Confirm a Checkout (Backend)
89
+
90
+ ```ts
91
+ const response = await processor.checkout.confirm({
92
+ checkoutId: ...,
93
+ type: 'raw-card',
94
+ data: {
95
+ number: ...,
96
+ expMonth: XX,
97
+ expYear: XXXX,
98
+ cvc: XXX,
99
+ },
100
+ });
101
+ ```
102
+
103
+ # Embedded Checkout (Frontend)
104
+
105
+ The SDK provides an optional embeddable checkout UI for collecting card details in the browser.
106
+
107
+ ---
108
+
109
+ ## Initialize Embedded Checkout
110
+
111
+ ```ts
112
+ import { EmbeddedCheckout } from '@henrylabs-interview/payments';
113
+
114
+ const embedded = new EmbeddedCheckout({
115
+ checkoutId: 'chk_123',
116
+ });
117
+ ```
118
+
119
+ ---
120
+
121
+ ## Render Embedded Checkout
122
+
123
+ ```ts
124
+ embedded.render('#checkout-container', (paymentToken) => {
125
+ console.log('Received payment token:', paymentToken);
126
+
127
+ // Send token to backend for confirmation
128
+ });
129
+ ```
130
+
131
+ ### Parameters
132
+
133
+ | Parameter | Description |
134
+ | -------------------- | ------------------------------------- |
135
+ | `containerElementId` | ID of DOM element to render into |
136
+ | `callbackFn` | Called with a generated payment token |
137
+
138
+ ### Behavior
139
+
140
+ - Renders a secure card collection UI
141
+ - Collects card number, expiry, and CVC
142
+ - Generates a payment token
143
+ - Returns the token via callback
144
+ - Token should be sent to your backend for confirmation
145
+
146
+ The embedded checkout is intended for **browser environments only**.
147
+
148
+ ---
149
+
150
+ # Webhooks
151
+
152
+ Available under:
153
+
154
+ ```ts
155
+ processor.webhooks;
156
+ ```
157
+
158
+ ---
159
+
160
+ ## Register Webhook Endpoint
161
+
162
+ ```ts
163
+ processor.webhooks.createEndpoint({
164
+ url: 'https://example.com/webhooks',
165
+ event: 'checkout.confirm',
166
+ secret: 'whsec_...',
167
+ });
168
+ ```
169
+
170
+ ---
171
+
172
+ ## Webhook Events
173
+
174
+ Events may be emitted for:
175
+
176
+ - `checkout.create`
177
+ - `checkout.confirm`
178
+ - `checkout.success`
179
+ - `checkout.failure`
180
+
181
+ Events are triggered for:
182
+
183
+ - Immediate outcomes
184
+ - Asynchronous resolutions
185
+ - Retry scenarios
186
+
187
+ ---
188
+
189
+ ## Webhook Security
190
+
191
+ If a secret is provided:
192
+
193
+ - Deliveries are signed
194
+ - Consumers must verify signatures
195
+ - Handlers must be idempotent
196
+
197
+ Retries may occur if delivery fails.
198
+
199
+ ---
200
+
201
+ # Disclaimer
202
+
203
+ This SDK is intended for evaluation and sandbox purposes only.
204
+
205
+ It does **not** process real payments and should not be used in production.
@@ -0,0 +1,23 @@
1
+ import { Checkout } from './resources/checkout';
2
+ import { Webhooks } from './resources/webhooks';
3
+ export declare class PaymentProcessor {
4
+ checkout: Checkout;
5
+ webhooks: Webhooks;
6
+ private VALID_API_KEYS;
7
+ constructor(config: {
8
+ apiKey: string;
9
+ });
10
+ }
11
+ export declare class EmbeddedCheckout {
12
+ private checkoutId;
13
+ constructor(config: {
14
+ checkoutId: string;
15
+ });
16
+ /**
17
+ * Renders the embedded checkout UI
18
+ * @param containerElementId - The ID of the container element where the checkout UI will be rendered
19
+ * @param callbackFn - Callback function to handle the payment token
20
+ * @returns - Whether the UI rendered successfully
21
+ */
22
+ render(containerElementId: string, callbackFn: (paymentToken: string) => void): Promise<boolean>;
23
+ }
package/dist/index.js ADDED
@@ -0,0 +1,36 @@
1
+ import { Checkout } from './resources/checkout';
2
+ import { renderEmbeddedCheckout } from './resources/embedded';
3
+ import { Webhooks } from './resources/webhooks';
4
+ export class PaymentProcessor {
5
+ checkout;
6
+ webhooks;
7
+ VALID_API_KEYS = ['824c951e-dfac-4342-8e03', '3f67e17a-880a-463b-a667', '78254d40-623a-48c5-b83f'];
8
+ constructor(config) {
9
+ if (!config.apiKey) {
10
+ throw new Error('Henry: apiKey is required');
11
+ }
12
+ if (!this.VALID_API_KEYS.includes(config.apiKey)) {
13
+ throw new Error('Henry: Invalid apiKey provided');
14
+ }
15
+ this.checkout = new Checkout();
16
+ this.webhooks = new Webhooks();
17
+ }
18
+ }
19
+ export class EmbeddedCheckout {
20
+ checkoutId;
21
+ constructor(config) {
22
+ if (!config.checkoutId) {
23
+ throw new Error('Henry: checkoutId is required');
24
+ }
25
+ this.checkoutId = config.checkoutId;
26
+ }
27
+ /**
28
+ * Renders the embedded checkout UI
29
+ * @param containerElementId - The ID of the container element where the checkout UI will be rendered
30
+ * @param callbackFn - Callback function to handle the payment token
31
+ * @returns - Whether the UI rendered successfully
32
+ */
33
+ render(containerElementId, callbackFn) {
34
+ return renderEmbeddedCheckout(containerElementId, this.checkoutId, callbackFn);
35
+ }
36
+ }
@@ -0,0 +1,100 @@
1
+ type CheckoutCreateResponse = CheckoutCreateSuccessDeferred | CheckoutCreateSuccessImmediate | CheckoutCreateFailure;
2
+ interface CheckoutCreateGeneric {
3
+ _reqId: string;
4
+ status: 'success' | 'failure';
5
+ code: number;
6
+ message: string;
7
+ }
8
+ interface CheckoutCreateSuccessDeferred extends CheckoutCreateGeneric {
9
+ status: 'success';
10
+ substatus: '202-deferred';
11
+ }
12
+ interface CheckoutCreateSuccessImmediate extends CheckoutCreateGeneric {
13
+ status: 'success';
14
+ substatus: '201-immediate';
15
+ data: {
16
+ checkoutId: string;
17
+ paymentMethodOptions: string[];
18
+ };
19
+ }
20
+ interface CheckoutCreateFailure extends CheckoutCreateGeneric {
21
+ status: 'failure';
22
+ substatus: '500-error' | '501-not-supported' | '502-fraud' | '503-retry';
23
+ }
24
+ type CheckoutConfirmParams = CheckoutConfirmParamsEmbedded | CheckoutConfirmParamsRawCard;
25
+ interface CheckoutConfirmParamsGeneric {
26
+ checkoutId: string;
27
+ type: 'embedded' | 'raw-card';
28
+ }
29
+ interface CheckoutConfirmParamsEmbedded extends CheckoutConfirmParamsGeneric {
30
+ type: 'embedded';
31
+ data: {
32
+ paymentToken: string;
33
+ };
34
+ }
35
+ interface CheckoutConfirmParamsRawCard extends CheckoutConfirmParamsGeneric {
36
+ type: 'raw-card';
37
+ data: {
38
+ number: string;
39
+ expMonth: number;
40
+ expYear: number;
41
+ cvc: string;
42
+ };
43
+ }
44
+ type CheckoutConfirmResponse = CheckoutConfirmSuccessDeferred | CheckoutConfirmSuccessImmediate | CheckoutConfirmFailure;
45
+ interface CheckoutConfirmGeneric {
46
+ _reqId: string;
47
+ status: 'success' | 'failure';
48
+ code: number;
49
+ message: string;
50
+ }
51
+ interface CheckoutConfirmSuccessDeferred extends CheckoutConfirmGeneric {
52
+ status: 'success';
53
+ substatus: '202-deferred';
54
+ }
55
+ interface CheckoutConfirmSuccessImmediate extends CheckoutConfirmGeneric {
56
+ status: 'success';
57
+ substatus: '201-immediate';
58
+ data: {
59
+ confirmationId: string;
60
+ amount: number;
61
+ currency: string;
62
+ customerId?: string;
63
+ };
64
+ }
65
+ interface CheckoutConfirmFailure extends CheckoutConfirmGeneric {
66
+ status: 'failure';
67
+ substatus: '500-error' | '502-fraud' | '503-retry';
68
+ }
69
+ export declare class Checkout {
70
+ /**
71
+ * Create a new checkout session
72
+ * @param amount - The amount for the checkout
73
+ * @param currency - The curreny type (note: not all are supported atm)
74
+ * @param customerId - Optional customer ID, used for unique customer identification
75
+ * @returns The response from the checkout creation
76
+ */
77
+ create(params: {
78
+ amount: number;
79
+ currency: 'USD' | 'EUR' | 'JPY';
80
+ customerId?: string;
81
+ }): Promise<CheckoutCreateResponse>;
82
+ /**
83
+ * Confirm a checkout session
84
+ * @param params - Either embedded or raw card checkout confirmation parameters
85
+ * @returns - The response from the checkout confirmation
86
+ */
87
+ confirm(params: CheckoutConfirmParams): Promise<CheckoutConfirmResponse>;
88
+ private validateCreate;
89
+ private processCreateDecision;
90
+ private createCheckoutRecord;
91
+ private scheduleCreateWebhook;
92
+ private buildHistoryHash;
93
+ private validateConfirm;
94
+ private processConfirmDecision;
95
+ private buildInstantConfirmSuccess;
96
+ private scheduleConfirmWebhook;
97
+ private determineResponseCase;
98
+ private sendWebhookResponse;
99
+ }
100
+ export {};
@@ -0,0 +1,451 @@
1
+ import { readHistory, writeHistory } from '../utils/store';
2
+ import { generateTimeBasedID, genReqId, hashToString, signPayload } from '../utils/crypto';
3
+ import { INTERNAL_WEBHOOKS } from './webhooks';
4
+ import { sleep } from '../utils/async';
5
+ import { isValidCardNumber, isValidExpiry } from '../utils/card';
6
+ // checkoutId -> { historyRecordId }
7
+ const INTERNAL_CHECKOUTS = {};
8
+ export class Checkout {
9
+ /**
10
+ * Create a new checkout session
11
+ * @param amount - The amount for the checkout
12
+ * @param currency - The curreny type (note: not all are supported atm)
13
+ * @param customerId - Optional customer ID, used for unique customer identification
14
+ * @returns The response from the checkout creation
15
+ */
16
+ async create(params) {
17
+ await sleep(Math.random() * 100);
18
+ const hashId = await this.buildHistoryHash(params);
19
+ const history = await readHistory();
20
+ const sameRecords = history.filter((v) => v.id === hashId);
21
+ // ---------------------------------------
22
+ // Phase 1: Validation
23
+ // ---------------------------------------
24
+ const validationFailure = this.validateCreate(params);
25
+ // ---------------------------------------
26
+ // Phase 2: Business Logic
27
+ // ---------------------------------------
28
+ const response = validationFailure ?? (await this.processCreateDecision(params, hashId, sameRecords.length));
29
+ // ---------------------------------------
30
+ // Phase 3: Webhook Scheduling
31
+ // ---------------------------------------
32
+ this.scheduleCreateWebhook(hashId, response);
33
+ // Add to history records
34
+ await writeHistory({
35
+ id: hashId,
36
+ amount: params.amount,
37
+ currency: params.currency,
38
+ customerId: params.customerId ?? null,
39
+ });
40
+ // Simulate API latency
41
+ await sleep(Math.random() * 2000);
42
+ return response;
43
+ }
44
+ /**
45
+ * Confirm a checkout session
46
+ * @param params - Either embedded or raw card checkout confirmation parameters
47
+ * @returns - The response from the checkout confirmation
48
+ */
49
+ async confirm(params) {
50
+ await sleep(Math.random() * 100);
51
+ // ---------------------------------------
52
+ // Phase 1: Validation (no early returns)
53
+ // ---------------------------------------
54
+ const validationFailure = await this.validateConfirm(params);
55
+ // ---------------------------------------
56
+ // Phase 2: Business Logic
57
+ // ---------------------------------------
58
+ const response = validationFailure ?? (await this.processConfirmDecision(params));
59
+ // ---------------------------------------
60
+ // Phase 3: Async Webhook Emission
61
+ // ---------------------------------------
62
+ this.scheduleConfirmWebhook(params, response);
63
+ // Simulate response delay
64
+ await sleep(Math.random() * 2000);
65
+ return response;
66
+ }
67
+ validateCreate(params) {
68
+ if (params.amount <= 0) {
69
+ return {
70
+ _reqId: genReqId(),
71
+ status: 'failure',
72
+ substatus: '500-error',
73
+ code: 500,
74
+ message: 'Invalid amount',
75
+ };
76
+ }
77
+ if (params.currency === 'JPY') {
78
+ return {
79
+ _reqId: genReqId(),
80
+ status: 'failure',
81
+ substatus: '501-not-supported',
82
+ code: 501,
83
+ message: 'This currency is currently not supported, please convert to another supported currency first.',
84
+ };
85
+ }
86
+ // Simulated stupid internal failures
87
+ if ((params.customerId ?? '').length === crypto.randomUUID().length || (params.customerId ?? '').includes('-') || params.customerId === '') {
88
+ return {
89
+ _reqId: genReqId(),
90
+ status: 'failure',
91
+ substatus: '500-error',
92
+ code: 500,
93
+ message: 'An internal error occurred when creating checkout',
94
+ };
95
+ }
96
+ return null;
97
+ }
98
+ async processCreateDecision(params, hashId, duplicateCount) {
99
+ const resCase = this.determineResponseCase(params.amount, duplicateCount);
100
+ if (resCase === 'failure-retry') {
101
+ return {
102
+ _reqId: genReqId(),
103
+ status: 'failure',
104
+ substatus: '503-retry',
105
+ code: 503,
106
+ message: 'Server is busy, please retry the request',
107
+ };
108
+ }
109
+ if (resCase === 'failure-fraud') {
110
+ return {
111
+ _reqId: genReqId(),
112
+ status: 'failure',
113
+ substatus: '502-fraud',
114
+ code: 502,
115
+ message: 'Potential fraud detected with this purchase',
116
+ };
117
+ }
118
+ const checkoutId = await this.createCheckoutRecord(hashId);
119
+ if (resCase === 'success-deferred') {
120
+ return {
121
+ _reqId: genReqId(),
122
+ status: 'success',
123
+ substatus: '202-deferred',
124
+ code: 202,
125
+ message: 'Authorizing, checkout information will likely be returned via webhook',
126
+ };
127
+ }
128
+ return {
129
+ _reqId: genReqId(),
130
+ status: 'success',
131
+ substatus: '201-immediate',
132
+ code: 201,
133
+ message: 'Approved',
134
+ data: {
135
+ checkoutId,
136
+ paymentMethodOptions: ['embedded', 'raw-card'],
137
+ },
138
+ };
139
+ }
140
+ async createCheckoutRecord(hashId) {
141
+ const checkoutId = `cki_${await generateTimeBasedID('checkout')}`;
142
+ INTERNAL_CHECKOUTS[`${checkoutId}`] = {
143
+ historyRecordId: hashId,
144
+ };
145
+ return checkoutId;
146
+ }
147
+ scheduleCreateWebhook(hashId, response) {
148
+ const webhookDelay = Math.random() * 3000;
149
+ setTimeout(async () => {
150
+ // Deferred flow resolves later
151
+ if (response.status === 'success' && response.substatus === '202-deferred') {
152
+ if (Math.random() > 0.2) {
153
+ const checkoutId = await this.createCheckoutRecord(hashId);
154
+ this.sendWebhookResponse('checkout.create', {
155
+ _reqId: response._reqId,
156
+ status: 'success',
157
+ substatus: '201-immediate',
158
+ code: 201,
159
+ message: 'Approved',
160
+ data: {
161
+ checkoutId,
162
+ paymentMethodOptions: ['embedded', 'raw-card'],
163
+ },
164
+ });
165
+ }
166
+ else if (Math.random() > 0.05) {
167
+ this.sendWebhookResponse('checkout.create', {
168
+ _reqId: response._reqId,
169
+ status: 'failure',
170
+ substatus: '503-retry',
171
+ code: 503,
172
+ message: 'Server error, please retry the request',
173
+ });
174
+ }
175
+ else {
176
+ this.sendWebhookResponse('checkout.create', {
177
+ _reqId: response._reqId,
178
+ status: 'failure',
179
+ substatus: '502-fraud',
180
+ code: 502,
181
+ message: 'Potential fraud detected with this purchase',
182
+ });
183
+ }
184
+ return;
185
+ }
186
+ // Immediate success or failure
187
+ this.sendWebhookResponse('checkout.create', response);
188
+ }, webhookDelay);
189
+ }
190
+ async buildHistoryHash(params) {
191
+ return await hashToString(JSON.stringify({
192
+ type: 'HISTORY_RECORD',
193
+ amount: params.amount,
194
+ currency: params.currency,
195
+ customerId: params.customerId,
196
+ }));
197
+ }
198
+ ///
199
+ async validateConfirm(params) {
200
+ if (`${params.checkoutId}`.length !== 20 || !`${params.checkoutId}`.startsWith('cki_')) {
201
+ return {
202
+ _reqId: genReqId(),
203
+ status: 'failure',
204
+ substatus: '500-error',
205
+ code: 500,
206
+ message: 'Invalid checkout ID',
207
+ };
208
+ }
209
+ if (!INTERNAL_CHECKOUTS[`${params.checkoutId}`]) {
210
+ return {
211
+ _reqId: genReqId(),
212
+ status: 'failure',
213
+ substatus: '503-retry',
214
+ code: 504,
215
+ message: 'Expired checkout ID',
216
+ };
217
+ }
218
+ if (params.type === 'embedded') {
219
+ if (params.data.paymentToken.length !== 20 || !params.data.paymentToken.startsWith('pmt_')) {
220
+ return {
221
+ _reqId: genReqId(),
222
+ status: 'failure',
223
+ substatus: '500-error',
224
+ code: 500,
225
+ message: 'Invalid payment token',
226
+ };
227
+ }
228
+ }
229
+ if (params.type === 'embedded') {
230
+ const paymentToken = `pmt_${await generateTimeBasedID('payment-token')}`;
231
+ if (params.data.paymentToken !== paymentToken) {
232
+ return {
233
+ _reqId: genReqId(),
234
+ status: 'failure',
235
+ substatus: '503-retry',
236
+ code: 503,
237
+ message: 'Expired payment token',
238
+ };
239
+ }
240
+ }
241
+ if (params.type === 'raw-card') {
242
+ const { number, expMonth, expYear, cvc } = params.data;
243
+ if (!isValidCardNumber(number)) {
244
+ return {
245
+ _reqId: genReqId(),
246
+ status: 'failure',
247
+ substatus: '500-error',
248
+ code: 500,
249
+ message: 'Invalid card number',
250
+ };
251
+ }
252
+ if (!isValidExpiry(expMonth, expYear)) {
253
+ return {
254
+ _reqId: genReqId(),
255
+ status: 'failure',
256
+ substatus: '500-error',
257
+ code: 500,
258
+ message: 'Card expired',
259
+ };
260
+ }
261
+ if (!/^\d{3,4}$/.test(cvc)) {
262
+ return {
263
+ _reqId: genReqId(),
264
+ status: 'failure',
265
+ substatus: '500-error',
266
+ code: 500,
267
+ message: 'Invalid CVC',
268
+ };
269
+ }
270
+ }
271
+ return null;
272
+ }
273
+ async processConfirmDecision(params) {
274
+ const decision = Math.random();
275
+ if (decision > 0.95) {
276
+ return {
277
+ _reqId: genReqId(),
278
+ status: 'failure',
279
+ substatus: '502-fraud',
280
+ code: 502,
281
+ message: 'Potential fraud detected with this purchase',
282
+ };
283
+ }
284
+ if (decision > 0.65) {
285
+ return {
286
+ _reqId: genReqId(),
287
+ status: 'failure',
288
+ substatus: '503-retry',
289
+ code: 503,
290
+ message: 'Server is busy, please retry the request',
291
+ };
292
+ }
293
+ if (decision > 0.35) {
294
+ return {
295
+ _reqId: genReqId(),
296
+ status: 'success',
297
+ substatus: '202-deferred',
298
+ code: 202,
299
+ message: 'Authorizing, purchase information will likely be returned via webhook',
300
+ };
301
+ }
302
+ return this.buildInstantConfirmSuccess(params.checkoutId);
303
+ }
304
+ async buildInstantConfirmSuccess(checkoutId) {
305
+ const { historyRecordId } = INTERNAL_CHECKOUTS[`${checkoutId}`] ?? {};
306
+ if (!historyRecordId) {
307
+ return {
308
+ _reqId: genReqId(),
309
+ status: 'failure',
310
+ substatus: '500-error',
311
+ code: 500,
312
+ message: 'Missing history record',
313
+ };
314
+ }
315
+ const history = await readHistory();
316
+ const record = history.find((v) => v.id === historyRecordId);
317
+ if (!record) {
318
+ return {
319
+ _reqId: genReqId(),
320
+ status: 'failure',
321
+ substatus: '500-error',
322
+ code: 500,
323
+ message: 'Missing history record',
324
+ };
325
+ }
326
+ const confirmationId = 'cof_' +
327
+ (await hashToString(JSON.stringify({
328
+ type: 'CONFIRMATION_ID',
329
+ amount: record.amount,
330
+ currency: record.currency,
331
+ customerId: record.customerId,
332
+ })));
333
+ return {
334
+ _reqId: genReqId(),
335
+ status: 'success',
336
+ substatus: '201-immediate',
337
+ code: 201,
338
+ message: 'Approved',
339
+ data: {
340
+ confirmationId,
341
+ amount: record.amount,
342
+ currency: record.currency,
343
+ customerId: record.customerId ?? undefined,
344
+ },
345
+ };
346
+ }
347
+ scheduleConfirmWebhook(params, response) {
348
+ const webhookDelay = Math.random() * 3000;
349
+ setTimeout(() => {
350
+ if (response.status === 'success' && response.substatus === '202-deferred') {
351
+ if (Math.random() > 0.2) {
352
+ this.buildInstantConfirmSuccess(params.checkoutId).then((finalResponse) => {
353
+ this.sendWebhookResponse('checkout.confirm', finalResponse);
354
+ });
355
+ }
356
+ else if (Math.random() > 0.05) {
357
+ this.sendWebhookResponse('checkout.confirm', {
358
+ _reqId: response._reqId,
359
+ status: 'failure',
360
+ substatus: '503-retry',
361
+ code: 503,
362
+ message: 'Server error, please retry the request',
363
+ });
364
+ }
365
+ else {
366
+ this.sendWebhookResponse('checkout.confirm', {
367
+ _reqId: response._reqId,
368
+ status: 'failure',
369
+ substatus: '502-fraud',
370
+ code: 502,
371
+ message: 'Potential fraud detected with this purchase',
372
+ });
373
+ }
374
+ return;
375
+ }
376
+ this.sendWebhookResponse('checkout.confirm', response);
377
+ }, webhookDelay);
378
+ }
379
+ //
380
+ determineResponseCase(amount, sameRecords) {
381
+ // --- Base probabilities ---
382
+ let immediateWeight = 65;
383
+ let deferredWeight = 20;
384
+ let retryWeight = 10;
385
+ let fraudWeight = 0;
386
+ // --- Adjust based on repeated attempts ---
387
+ immediateWeight -= sameRecords * 10;
388
+ deferredWeight += sameRecords * 5;
389
+ retryWeight += sameRecords * 5;
390
+ fraudWeight += sameRecords * 15;
391
+ // --- Adjust based on amount ---
392
+ if (amount > 1000) {
393
+ deferredWeight += sameRecords * 5 + 10;
394
+ retryWeight += sameRecords * 5 + 10;
395
+ }
396
+ if (amount > 5000) {
397
+ immediateWeight -= sameRecords * 5 + 15;
398
+ retryWeight += sameRecords * 5 + 30;
399
+ fraudWeight += sameRecords * 10;
400
+ }
401
+ if (amount > 10000) {
402
+ fraudWeight += sameRecords * 30;
403
+ }
404
+ // Ensure no negative weights
405
+ immediateWeight = Math.max(0, immediateWeight);
406
+ deferredWeight = Math.max(0, deferredWeight);
407
+ retryWeight = Math.max(0, retryWeight);
408
+ fraudWeight = Math.max(0, fraudWeight);
409
+ // --- Weighted random selection ---
410
+ const total = immediateWeight + deferredWeight + retryWeight + fraudWeight;
411
+ const rand = Math.random() * total;
412
+ if (rand < immediateWeight) {
413
+ return 'success-immediate';
414
+ }
415
+ if (rand < immediateWeight + deferredWeight) {
416
+ return 'success-deferred';
417
+ }
418
+ if (rand < immediateWeight + deferredWeight + retryWeight) {
419
+ return 'failure-retry';
420
+ }
421
+ return 'failure-fraud';
422
+ }
423
+ async sendWebhookResponse(baseType, response) {
424
+ const statusSuffix = response.status === 'success' ? 'success' : 'failure';
425
+ const eventType = `${baseType}.${statusSuffix}`;
426
+ // Build all matching event types hierarchically
427
+ const matchingTypes = ['checkout', baseType, eventType];
428
+ const hooks = INTERNAL_WEBHOOKS.filter((w) => matchingTypes.includes(w.event));
429
+ const event = {
430
+ uid: `_${genReqId()}_`,
431
+ type: eventType,
432
+ createdAt: Date.now(),
433
+ data: response,
434
+ };
435
+ const payload = JSON.stringify(event);
436
+ await Promise.allSettled(hooks.map(async (hook) => {
437
+ const headers = {
438
+ 'Content-Type': 'application/json',
439
+ };
440
+ if (hook.secret) {
441
+ headers['x-henry-signature'] = signPayload(payload, hook.secret);
442
+ }
443
+ await fetch(hook.url, {
444
+ method: 'POST',
445
+ headers,
446
+ body: payload,
447
+ });
448
+ }));
449
+ return true;
450
+ }
451
+ }
@@ -0,0 +1 @@
1
+ export declare function renderEmbeddedCheckout(containerElementId: string, checkoutId: string, callbackFn: (paymentToken: string) => void): Promise<boolean>;
@@ -0,0 +1,178 @@
1
+ import { isValidCardNumber, isValidExpiry } from '../utils/card';
2
+ import { generateTimeBasedID } from '../utils/crypto';
3
+ export async function renderEmbeddedCheckout(containerElementId, checkoutId, callbackFn) {
4
+ // --- Environment Guard ---
5
+ if (typeof window === 'undefined' || typeof document === 'undefined') {
6
+ console.warn('EmbeddedCheckoutUI can only be used in a browser environment.');
7
+ return false;
8
+ }
9
+ // --- Checkout Validation ---
10
+ if (checkoutId.length !== 20 || !checkoutId.startsWith('cki_')) {
11
+ console.warn(`Invalid checkout ID: "${checkoutId}".`);
12
+ return false;
13
+ }
14
+ if (`${checkoutId}` !== `cki_${await generateTimeBasedID('checkout')}`) {
15
+ console.warn(`Expired checkout ID: "${checkoutId}".`);
16
+ return false;
17
+ }
18
+ const container = document.getElementById(containerElementId.startsWith('#') ? containerElementId.slice(1) : containerElementId);
19
+ if (!container) {
20
+ console.warn(`Container element "${containerElementId}" not found.`);
21
+ return false;
22
+ }
23
+ // --- Render UI ---
24
+ container.innerHTML = `
25
+ <div style="
26
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
27
+ background: #ffffff;
28
+ border-radius: 16px;
29
+ box-shadow: 0 10px 30px rgba(0,0,0,0.08);
30
+ padding: 24px;
31
+ max-width: 400px;
32
+ width: 100%;
33
+ box-sizing: border-box;
34
+ ">
35
+ <h3 style="margin:0 0 20px 0; font-size:18px; font-weight:600; color:#111827;">
36
+ "Secure" Checkout
37
+ </h3>
38
+
39
+ <div style="display:flex; flex-direction:column; gap:14px;">
40
+
41
+ ${inputBlock('card-number', 'Card Number', '4242 4242 4242 4242', 19)}
42
+ <div style="display:flex; gap:10px;">
43
+ <div style="flex:1;">
44
+ ${inputBlock('exp-month', 'Exp. Month', '12', 2)}
45
+ </div>
46
+ <div style="flex:1;">
47
+ ${inputBlock('exp-year', 'Exp. Year', '2030', 4)}
48
+ </div>
49
+ </div>
50
+ ${inputBlock('cvc', 'CVC', '123', 3)}
51
+
52
+ <button id="confirm-btn"
53
+ style="
54
+ margin-top:8px;
55
+ padding:12px;
56
+ border-radius:12px;
57
+ border:none;
58
+ font-size:14px;
59
+ font-weight:600;
60
+ background: linear-gradient(135deg, #6366f1, #4f46e5);
61
+ color:white;
62
+ cursor:pointer;
63
+ transition: all 0.15s ease;
64
+ box-shadow: 0 4px 14px rgba(79,70,229,0.3);
65
+ ">
66
+ Confirm Payment
67
+ </button>
68
+
69
+ <p style="font-size:11px; color:#9ca3af; text-align:center; margin-top:8px;">
70
+ 🔒 Payments are securely processed
71
+ </p>
72
+
73
+ </div>
74
+ </div>
75
+ `;
76
+ function inputBlock(id, label, placeholder, maxLength) {
77
+ return `
78
+ <div style="display:flex; flex-direction:column;">
79
+ <label style="font-size:12px; color:#6b7280; margin-bottom:6px;">
80
+ ${label}
81
+ </label>
82
+ <input
83
+ id="${id}"
84
+ type="text"
85
+ inputmode="numeric"
86
+ maxlength="${maxLength}"
87
+ pattern="[0-9]*"
88
+ placeholder="${placeholder}"
89
+ style="
90
+ padding:10px 12px;
91
+ border-radius:10px;
92
+ border:1px solid #e5e7eb;
93
+ font-size:14px;
94
+ outline:none;
95
+ transition:border 0.2s, box-shadow 0.2s;
96
+ "
97
+ />
98
+ <div id="${id}-error"
99
+ style="font-size:12px; color:#ef4444; margin-top:4px; display:none;">
100
+ </div>
101
+ </div>
102
+ `;
103
+ }
104
+ // --- DOM Refs ---
105
+ const cardNumberInput = container.querySelector('#card-number');
106
+ const expMonthInput = container.querySelector('#exp-month');
107
+ const expYearInput = container.querySelector('#exp-year');
108
+ const cvcInput = container.querySelector('#cvc');
109
+ const button = container.querySelector('#confirm-btn');
110
+ // --- Helpers ---
111
+ function showError(input, message) {
112
+ input.style.border = '1px solid #ef4444';
113
+ const error = container?.querySelector(`#${input.id}-error`);
114
+ if (error) {
115
+ error.textContent = message;
116
+ error.style.display = 'block';
117
+ }
118
+ }
119
+ function clearError(input) {
120
+ input.style.border = '1px solid #e5e7eb';
121
+ const error = container?.querySelector(`#${input.id}-error`);
122
+ if (error) {
123
+ error.textContent = '';
124
+ error.style.display = 'none';
125
+ }
126
+ }
127
+ function setLoading(state) {
128
+ button.disabled = state;
129
+ button.style.opacity = state ? '0.6' : '1';
130
+ button.textContent = state ? 'Processing...' : 'Confirm Payment';
131
+ }
132
+ function clearAllErrors() {
133
+ [cardNumberInput, expMonthInput, expYearInput, cvcInput].forEach(clearError);
134
+ }
135
+ // --- Formatting ---
136
+ cardNumberInput.addEventListener('input', () => {
137
+ let value = cardNumberInput.value.replace(/\D/g, '').slice(0, 16);
138
+ value = value.replace(/(.{4})/g, '$1 ').trim();
139
+ cardNumberInput.value = value;
140
+ clearError(cardNumberInput);
141
+ });
142
+ [expMonthInput, expYearInput, cvcInput].forEach((input) => input.addEventListener('input', () => clearError(input)));
143
+ // --- Submit ---
144
+ button.addEventListener('click', async () => {
145
+ clearAllErrors();
146
+ const number = cardNumberInput.value.replace(/\s+/g, '');
147
+ const expMonth = Number(expMonthInput.value);
148
+ const expYear = Number(expYearInput.value);
149
+ const cvc = cvcInput.value;
150
+ let hasError = false;
151
+ if (!number) {
152
+ showError(cardNumberInput, 'Card number required');
153
+ hasError = true;
154
+ }
155
+ else if (!isValidCardNumber(number)) {
156
+ showError(cardNumberInput, 'Invalid card number');
157
+ hasError = true;
158
+ }
159
+ if (!expMonthInput.value || expMonth < 1 || expMonth > 12) {
160
+ showError(expMonthInput, 'Invalid month');
161
+ hasError = true;
162
+ }
163
+ if (!expYearInput.value || !isValidExpiry(expMonth, expYear)) {
164
+ showError(expYearInput, 'Card expired');
165
+ hasError = true;
166
+ }
167
+ if (!cvc || !/^\d{3,4}$/.test(cvc)) {
168
+ showError(cvcInput, 'Invalid CVC');
169
+ hasError = true;
170
+ }
171
+ if (hasError)
172
+ return;
173
+ setLoading(true);
174
+ const paymentToken = `pmt_${await generateTimeBasedID('payment-token')}`;
175
+ callbackFn(paymentToken);
176
+ });
177
+ return true;
178
+ }
@@ -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
+ uid: 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,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 declare const sleep: (ms: number) => Promise<unknown>;
@@ -0,0 +1 @@
1
+ export const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
@@ -0,0 +1,2 @@
1
+ export declare function isValidCardNumber(number: string): boolean;
2
+ export declare function isValidExpiry(month: number, year: number): boolean;
@@ -0,0 +1,29 @@
1
+ export function isValidCardNumber(number) {
2
+ // Test card is valid
3
+ if (number === '4242424242424242')
4
+ return true;
5
+ // Cards must be 13-19 digits
6
+ if (!/^\d{13,19}$/.test(number))
7
+ return false;
8
+ // Luhn check
9
+ let sum = 0;
10
+ let shouldDouble = false;
11
+ for (let i = number.length - 1; i >= 0; i--) {
12
+ let digit = parseInt(number[i] ?? '');
13
+ if (shouldDouble) {
14
+ digit *= 2;
15
+ if (digit > 9)
16
+ digit -= 9;
17
+ }
18
+ sum += digit;
19
+ shouldDouble = !shouldDouble;
20
+ }
21
+ return sum % 10 === 0;
22
+ }
23
+ export function isValidExpiry(month, year) {
24
+ if (month < 1 || month > 12)
25
+ return false;
26
+ const now = new Date();
27
+ const expiry = new Date(year, month - 1);
28
+ return expiry > now;
29
+ }
@@ -0,0 +1,5 @@
1
+ export declare function generateID(length?: number): number;
2
+ export declare function generateTimeBasedID(extra?: string): Promise<string>;
3
+ export declare function hashToString(input: string): Promise<string>;
4
+ export declare function signPayload(payload: string, secret: string): string;
5
+ export declare function genReqId(): string;
@@ -0,0 +1,28 @@
1
+ import { sha256 } from '@noble/hashes/sha2.js';
2
+ import { bytesToHex } from '@noble/hashes/utils.js';
3
+ import crypto from 'crypto';
4
+ export function generateID(length = 12) {
5
+ const digits = [];
6
+ const bytes = crypto.randomBytes(length);
7
+ for (let i = 0; i < length; i++) {
8
+ digits.push((bytes[i] ?? 0) % 10);
9
+ }
10
+ return parseInt(digits.join(''));
11
+ }
12
+ export async function generateTimeBasedID(extra = '') {
13
+ const now = Date.now(); // current time in ms
14
+ const oneMinute = 60 * 1000;
15
+ const ms = Math.floor(now / oneMinute) * oneMinute;
16
+ return await hashToString(`${ms}---${extra}`);
17
+ }
18
+ export async function hashToString(input) {
19
+ const hash = bytesToHex(sha256(new TextEncoder().encode(input)));
20
+ // 16 hex characters = 64 bits
21
+ return hash.slice(0, 16);
22
+ }
23
+ export function signPayload(payload, secret) {
24
+ return crypto.createHmac('sha256', secret).update(payload).digest('hex');
25
+ }
26
+ export function genReqId() {
27
+ return crypto.randomUUID().split('-')[0] ?? '';
28
+ }
@@ -0,0 +1,16 @@
1
+ export type HistoryRecord = {
2
+ id: string;
3
+ amount: number;
4
+ currency: 'USD' | 'EUR' | 'JPY';
5
+ customerId: string | null;
6
+ updatedAt: number;
7
+ createdAt: number;
8
+ };
9
+ /**
10
+ * Reads all history records from the JSON store.
11
+ */
12
+ export declare function readHistory(): Promise<HistoryRecord[]>;
13
+ /**
14
+ * Writes a new record to the JSON store.
15
+ */
16
+ export declare function writeHistory(params: Omit<HistoryRecord, 'createdAt' | 'updatedAt'>): Promise<HistoryRecord>;
@@ -0,0 +1,40 @@
1
+ const path = new URL('../db-store/history.json', import.meta.url);
2
+ async function ensureFileExists() {
3
+ const file = Bun.file(path);
4
+ if (!(await file.exists())) {
5
+ await Bun.write(path, '[]');
6
+ }
7
+ }
8
+ /**
9
+ * Reads all history records from the JSON store.
10
+ */
11
+ export async function readHistory() {
12
+ await ensureFileExists();
13
+ const file = Bun.file(path);
14
+ const text = await file.text();
15
+ if (!text)
16
+ return [];
17
+ try {
18
+ return JSON.parse(text);
19
+ }
20
+ catch {
21
+ // If file somehow becomes corrupted, reset it
22
+ await Bun.write(path, '[]');
23
+ return [];
24
+ }
25
+ }
26
+ /**
27
+ * Writes a new record to the JSON store.
28
+ */
29
+ export async function writeHistory(params) {
30
+ const history = await readHistory();
31
+ const now = Date.now();
32
+ const record = {
33
+ ...params,
34
+ createdAt: now,
35
+ updatedAt: now,
36
+ };
37
+ history.push(record);
38
+ await Bun.write(path, JSON.stringify(history, null, 2));
39
+ return record;
40
+ }
package/package.json ADDED
@@ -0,0 +1,28 @@
1
+ {
2
+ "name": "@henrylabs-interview/payments",
3
+ "version": "0.2.16",
4
+ "type": "module",
5
+ "main": "./dist/index.js",
6
+ "types": "./dist/index.d.ts",
7
+ "exports": {
8
+ ".": {
9
+ "types": "./dist/index.d.ts",
10
+ "default": "./dist/index.js"
11
+ }
12
+ },
13
+ "files": [
14
+ "dist"
15
+ ],
16
+ "scripts": {
17
+ "build": "rm -rf dist && bunx tsc -p tsconfig.build.json",
18
+ "dev": "bun run src/index.ts",
19
+ "publish": "bun run build && npm publish --access public"
20
+ },
21
+ "devDependencies": {
22
+ "@types/bun": "latest",
23
+ "typescript": "^5"
24
+ },
25
+ "dependencies": {
26
+ "@noble/hashes": "^2.0.1"
27
+ }
28
+ }