@henrylabs-interview/payment-processor 0.1.11 → 0.1.13
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 +120 -66
- package/dist/index.d.ts +1 -1
- package/dist/resources/checkout.d.ts +0 -9
- package/dist/resources/checkout.js +25 -26
- package/dist/resources/embedded.d.ts +1 -1
- package/dist/resources/embedded.js +13 -16
- package/dist/utils/crypto.d.ts +1 -0
- package/dist/utils/crypto.js +6 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,8 +1,15 @@
|
|
|
1
|
-
# Henry Labs
|
|
1
|
+
# Henry Labs – Interview: Payment Processor
|
|
2
2
|
|
|
3
|
-
A lightweight payments SDK for creating and confirming checkouts, with built-in webhook support.
|
|
3
|
+
A lightweight payments SDK for creating and confirming checkouts, with built-in webhook support and an embeddable checkout UI.
|
|
4
4
|
|
|
5
|
-
This SDK simulates a real-world payment processing system, including
|
|
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.
|
|
6
13
|
|
|
7
14
|
---
|
|
8
15
|
|
|
@@ -20,35 +27,48 @@ bun run build
|
|
|
20
27
|
|
|
21
28
|
---
|
|
22
29
|
|
|
23
|
-
|
|
30
|
+
# Overview
|
|
24
31
|
|
|
25
|
-
The SDK provides
|
|
32
|
+
The SDK provides:
|
|
26
33
|
|
|
27
|
-
-
|
|
28
|
-
-
|
|
29
|
-
-
|
|
34
|
+
- Checkout creation
|
|
35
|
+
- Checkout confirmation
|
|
36
|
+
- Webhook endpoint registration
|
|
37
|
+
- Embeddable checkout UI (frontend)
|
|
38
|
+
- API key validation
|
|
30
39
|
|
|
31
|
-
The system performs internal validation, fraud screening, and
|
|
32
|
-
|
|
40
|
+
The system performs internal validation, fraud screening, and simulated load conditions.
|
|
41
|
+
|
|
42
|
+
Operations may:
|
|
33
43
|
|
|
34
44
|
- Succeed immediately
|
|
35
|
-
-
|
|
36
|
-
- Require
|
|
37
|
-
- Fail due to
|
|
45
|
+
- Resolve asynchronously
|
|
46
|
+
- Require retries
|
|
47
|
+
- Fail due to fraud controls
|
|
38
48
|
- Fail due to temporary system overload
|
|
39
49
|
|
|
40
|
-
Consumers should always handle both synchronous responses and webhook events.
|
|
41
|
-
|
|
42
50
|
---
|
|
43
51
|
|
|
44
|
-
|
|
52
|
+
# Usage
|
|
45
53
|
|
|
46
|
-
|
|
54
|
+
---
|
|
55
|
+
|
|
56
|
+
## Initialize (Backend)
|
|
47
57
|
|
|
48
58
|
```ts
|
|
49
|
-
import {
|
|
59
|
+
import { PaymentProcessor } from 'henry-labs/take-home';
|
|
50
60
|
|
|
51
|
-
const
|
|
61
|
+
const processor = new PaymentProcessor({
|
|
62
|
+
apiKey: ...,
|
|
63
|
+
});
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
# Checkout API
|
|
67
|
+
|
|
68
|
+
Available under:
|
|
69
|
+
|
|
70
|
+
```ts
|
|
71
|
+
processor.checkout;
|
|
52
72
|
```
|
|
53
73
|
|
|
54
74
|
---
|
|
@@ -56,96 +76,130 @@ const sdk = new PaymentSDK();
|
|
|
56
76
|
## Create a Checkout
|
|
57
77
|
|
|
58
78
|
```ts
|
|
59
|
-
const response = await
|
|
79
|
+
const response = await processor.checkout.create({
|
|
60
80
|
amount: 1000,
|
|
61
81
|
currency: 'USD',
|
|
62
82
|
customerId: 'cust_123',
|
|
63
83
|
});
|
|
64
84
|
```
|
|
65
85
|
|
|
66
|
-
|
|
86
|
+
---
|
|
67
87
|
|
|
68
|
-
|
|
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
|
+
```
|
|
69
102
|
|
|
70
|
-
|
|
71
|
-
- Return a pending authorization state
|
|
72
|
-
- Return a retryable error
|
|
73
|
-
- Return a fraud-related failure
|
|
74
|
-
- Fail due to temporary internal errors
|
|
103
|
+
# Embedded Checkout (Frontend)
|
|
75
104
|
|
|
76
|
-
|
|
105
|
+
The SDK provides an optional embeddable checkout UI for collecting card details in the browser.
|
|
77
106
|
|
|
78
107
|
---
|
|
79
108
|
|
|
80
|
-
##
|
|
109
|
+
## Initialize Embedded Checkout
|
|
81
110
|
|
|
82
111
|
```ts
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
number: '4242424242424242',
|
|
88
|
-
expMonth: 12,
|
|
89
|
-
expYear: 2030,
|
|
90
|
-
cvc: '123',
|
|
91
|
-
},
|
|
112
|
+
import { EmbeddedCheckout } from 'henry-labs/take-home';
|
|
113
|
+
|
|
114
|
+
const embedded = new EmbeddedCheckout({
|
|
115
|
+
checkoutId: 'chk_123',
|
|
92
116
|
});
|
|
93
117
|
```
|
|
94
118
|
|
|
95
|
-
|
|
119
|
+
---
|
|
120
|
+
|
|
121
|
+
## Render Embedded Checkout
|
|
122
|
+
|
|
123
|
+
```ts
|
|
124
|
+
embedded.render('checkout-container', (paymentToken) => {
|
|
125
|
+
console.log('Received payment token:', paymentToken);
|
|
96
126
|
|
|
97
|
-
|
|
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
|
|
98
139
|
|
|
99
|
-
-
|
|
100
|
-
-
|
|
101
|
-
-
|
|
102
|
-
-
|
|
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
|
|
103
145
|
|
|
104
|
-
|
|
105
|
-
Webhook handling is required to receive final outcomes in deferred cases.
|
|
146
|
+
The embedded checkout is intended for **browser environments only**.
|
|
106
147
|
|
|
107
148
|
---
|
|
108
149
|
|
|
109
|
-
|
|
150
|
+
# Webhooks
|
|
110
151
|
|
|
111
|
-
|
|
152
|
+
Available under:
|
|
112
153
|
|
|
113
154
|
```ts
|
|
114
|
-
|
|
155
|
+
processor.webhooks;
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
---
|
|
159
|
+
|
|
160
|
+
## Register Webhook Endpoint
|
|
161
|
+
|
|
162
|
+
```ts
|
|
163
|
+
processor.webhooks.createEndpoint({
|
|
115
164
|
url: 'https://example.com/webhooks',
|
|
116
165
|
event: 'checkout.confirm',
|
|
117
166
|
secret: 'whsec_...',
|
|
118
167
|
});
|
|
119
168
|
```
|
|
120
169
|
|
|
121
|
-
|
|
170
|
+
---
|
|
122
171
|
|
|
123
|
-
|
|
172
|
+
## Webhook Events
|
|
124
173
|
|
|
125
|
-
|
|
126
|
-
- Checkout confirmation
|
|
127
|
-
- Success outcomes
|
|
128
|
-
- Failure outcomes
|
|
174
|
+
Events may be emitted for:
|
|
129
175
|
|
|
130
|
-
|
|
176
|
+
- `checkout.create`
|
|
177
|
+
- `checkout.confirm`
|
|
178
|
+
- `checkout.success`
|
|
179
|
+
- `checkout.failure`
|
|
131
180
|
|
|
132
|
-
|
|
181
|
+
Events are triggered for:
|
|
133
182
|
|
|
134
|
-
|
|
183
|
+
- Immediate outcomes
|
|
184
|
+
- Asynchronous resolutions
|
|
185
|
+
- Retry scenarios
|
|
135
186
|
|
|
136
187
|
---
|
|
137
188
|
|
|
138
|
-
##
|
|
189
|
+
## Webhook Security
|
|
139
190
|
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
-
|
|
143
|
-
-
|
|
144
|
-
-
|
|
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.
|
|
145
198
|
|
|
146
199
|
---
|
|
147
200
|
|
|
148
|
-
|
|
201
|
+
# Disclaimer
|
|
149
202
|
|
|
150
203
|
This SDK is intended for evaluation and sandbox purposes only.
|
|
151
|
-
|
|
204
|
+
|
|
205
|
+
It does **not** process real payments and should not be used in production.
|
package/dist/index.d.ts
CHANGED
|
@@ -64,15 +64,6 @@ interface CheckoutConfirmFailure extends CheckoutConfirmGeneric {
|
|
|
64
64
|
status: 'failure';
|
|
65
65
|
substatus: '500-error' | '502-fraud' | '503-retry';
|
|
66
66
|
}
|
|
67
|
-
export declare const INTERNAL_CHECKOUTS: Record<string, {
|
|
68
|
-
historyRecordId: number;
|
|
69
|
-
}>;
|
|
70
|
-
export declare const INTERNAL_CARDS: Record<string, {
|
|
71
|
-
number: string;
|
|
72
|
-
expMonth: number;
|
|
73
|
-
expYear: number;
|
|
74
|
-
cvc: string;
|
|
75
|
-
}>;
|
|
76
67
|
export declare class Checkout {
|
|
77
68
|
/**
|
|
78
69
|
* Create a new checkout session
|
|
@@ -1,11 +1,9 @@
|
|
|
1
1
|
import { readHistory, writeHistory } from '../utils/store';
|
|
2
|
-
import { generateID, hashToNumber, signPayload } from '../utils/crypto';
|
|
2
|
+
import { generateID, generateTimeBasedID, hashToNumber, signPayload } from '../utils/crypto';
|
|
3
3
|
import { INTERNAL_WEBHOOKS } from './webhooks';
|
|
4
4
|
import { sleep } from '../utils/async';
|
|
5
5
|
// checkoutId -> { historyRecordId }
|
|
6
|
-
|
|
7
|
-
// paymentToken -> card details
|
|
8
|
-
export const INTERNAL_CARDS = {};
|
|
6
|
+
const INTERNAL_CHECKOUTS = {};
|
|
9
7
|
export class Checkout {
|
|
10
8
|
/**
|
|
11
9
|
* Create a new checkout session
|
|
@@ -132,7 +130,7 @@ export class Checkout {
|
|
|
132
130
|
};
|
|
133
131
|
}
|
|
134
132
|
createCheckoutRecord(hashId) {
|
|
135
|
-
const checkoutId =
|
|
133
|
+
const checkoutId = generateTimeBasedID('checkout');
|
|
136
134
|
INTERNAL_CHECKOUTS[`${checkoutId}`] = {
|
|
137
135
|
historyRecordId: hashId,
|
|
138
136
|
};
|
|
@@ -181,7 +179,7 @@ export class Checkout {
|
|
|
181
179
|
}
|
|
182
180
|
///
|
|
183
181
|
async validateConfirm(params) {
|
|
184
|
-
if (
|
|
182
|
+
if (`${params.checkoutId}`.length !== 12) {
|
|
185
183
|
return {
|
|
186
184
|
status: 'failure',
|
|
187
185
|
substatus: '500-error',
|
|
@@ -189,14 +187,30 @@ export class Checkout {
|
|
|
189
187
|
message: 'Invalid checkout ID',
|
|
190
188
|
};
|
|
191
189
|
}
|
|
190
|
+
if (!INTERNAL_CHECKOUTS[`${params.checkoutId}`]) {
|
|
191
|
+
return {
|
|
192
|
+
status: 'failure',
|
|
193
|
+
substatus: '503-retry',
|
|
194
|
+
code: 504,
|
|
195
|
+
message: 'Expired checkout ID',
|
|
196
|
+
};
|
|
197
|
+
}
|
|
198
|
+
if (params.type === 'embedded' && params.data.paymentToken.length !== 12) {
|
|
199
|
+
return {
|
|
200
|
+
status: 'failure',
|
|
201
|
+
substatus: '500-error',
|
|
202
|
+
code: 500,
|
|
203
|
+
message: 'Invalid payment token',
|
|
204
|
+
};
|
|
205
|
+
}
|
|
192
206
|
if (params.type === 'embedded') {
|
|
193
|
-
const
|
|
194
|
-
if (
|
|
207
|
+
const paymentToken = `pmt_${generateTimeBasedID('payment-token')}`;
|
|
208
|
+
if (params.data.paymentToken !== paymentToken) {
|
|
195
209
|
return {
|
|
196
210
|
status: 'failure',
|
|
197
|
-
substatus: '
|
|
198
|
-
code:
|
|
199
|
-
message: '
|
|
211
|
+
substatus: '503-retry',
|
|
212
|
+
code: 503,
|
|
213
|
+
message: 'Expired payment token',
|
|
200
214
|
};
|
|
201
215
|
}
|
|
202
216
|
}
|
|
@@ -230,21 +244,6 @@ export class Checkout {
|
|
|
230
244
|
return null;
|
|
231
245
|
}
|
|
232
246
|
async processConfirmDecision(params) {
|
|
233
|
-
if (params.type === 'raw-card') {
|
|
234
|
-
// Add new card to internal storage
|
|
235
|
-
const cardDetails = {
|
|
236
|
-
number: params.data.number,
|
|
237
|
-
expMonth: params.data.expMonth,
|
|
238
|
-
expYear: params.data.expYear > 2000 ? params.data.expYear : params.data.expYear + 2000,
|
|
239
|
-
cvc: params.data.cvc,
|
|
240
|
-
};
|
|
241
|
-
const paymentToken = 'pmt_' +
|
|
242
|
-
hashToNumber(JSON.stringify({
|
|
243
|
-
type: 'PAYMENT_TOKEN',
|
|
244
|
-
...cardDetails,
|
|
245
|
-
}));
|
|
246
|
-
INTERNAL_CARDS[`${paymentToken}`] = cardDetails;
|
|
247
|
-
}
|
|
248
247
|
const decision = Math.random();
|
|
249
248
|
if (decision > 0.85) {
|
|
250
249
|
return {
|
|
@@ -1 +1 @@
|
|
|
1
|
-
export declare function renderEmbeddedCheckout(containerElementId: string, checkoutId:
|
|
1
|
+
export declare function renderEmbeddedCheckout(containerElementId: string, checkoutId: number, callbackFn: (paymentToken: string) => void): boolean;
|
|
@@ -1,16 +1,18 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { INTERNAL_CARDS, INTERNAL_CHECKOUTS } from './checkout';
|
|
1
|
+
import { generateTimeBasedID } from '../utils/crypto';
|
|
3
2
|
export function renderEmbeddedCheckout(containerElementId, checkoutId, callbackFn) {
|
|
4
3
|
// Ensure browser environment
|
|
5
4
|
if (typeof window === 'undefined' || typeof document === 'undefined') {
|
|
6
5
|
console.warn('EmbeddedCheckoutUI can only be used in a browser environment.');
|
|
7
6
|
return false;
|
|
8
7
|
}
|
|
9
|
-
|
|
10
|
-
if (!historyRecordId) {
|
|
8
|
+
if (checkoutId.toString().length !== 12) {
|
|
11
9
|
console.warn(`Invalid checkout ID: "${checkoutId}".`);
|
|
12
10
|
return false;
|
|
13
11
|
}
|
|
12
|
+
if (`${checkoutId}` === `${generateTimeBasedID('checkout')}`) {
|
|
13
|
+
console.warn(`Expired checkout ID: "${checkoutId}".`);
|
|
14
|
+
return false;
|
|
15
|
+
}
|
|
14
16
|
const container = document.getElementById(containerElementId);
|
|
15
17
|
if (!container) {
|
|
16
18
|
console.warn(`Container element "${containerElementId}" not found.`);
|
|
@@ -147,18 +149,13 @@ export function renderEmbeddedCheckout(containerElementId, checkoutId, callbackF
|
|
|
147
149
|
const expYear = Number(container.querySelector('#exp-year')?.value);
|
|
148
150
|
const cvc = container.querySelector('#cvc')?.value ?? '';
|
|
149
151
|
// Add new card to internal storage
|
|
150
|
-
const cardDetails = {
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
};
|
|
156
|
-
const paymentToken =
|
|
157
|
-
hashToNumber(JSON.stringify({
|
|
158
|
-
type: 'PAYMENT_TOKEN',
|
|
159
|
-
...cardDetails,
|
|
160
|
-
}));
|
|
161
|
-
INTERNAL_CARDS[`${paymentToken}`] = cardDetails;
|
|
152
|
+
// const cardDetails = {
|
|
153
|
+
// number: number,
|
|
154
|
+
// expMonth: expMonth,
|
|
155
|
+
// expYear: expYear > 2000 ? expYear : expYear + 2000,
|
|
156
|
+
// cvc: cvc,
|
|
157
|
+
// };
|
|
158
|
+
const paymentToken = `pmt_${generateTimeBasedID('payment-token')}`;
|
|
162
159
|
callbackFn(paymentToken);
|
|
163
160
|
});
|
|
164
161
|
return true;
|
package/dist/utils/crypto.d.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
1
|
export declare function generateID(length?: number): number;
|
|
2
|
+
export declare function generateTimeBasedID(extra?: string): number;
|
|
2
3
|
export declare function hashToNumber(input: string, length?: number): number;
|
|
3
4
|
export declare function signPayload(payload: string, secret: string): string;
|
package/dist/utils/crypto.js
CHANGED
|
@@ -7,6 +7,12 @@ export function generateID(length = 12) {
|
|
|
7
7
|
}
|
|
8
8
|
return parseInt(digits.join(''));
|
|
9
9
|
}
|
|
10
|
+
export function generateTimeBasedID(extra = '') {
|
|
11
|
+
const now = Date.now(); // current time in ms
|
|
12
|
+
const oneMinute = 60 * 1000;
|
|
13
|
+
const ms = Math.floor(now / oneMinute) * oneMinute;
|
|
14
|
+
return hashToNumber(`${ms}---${extra}`);
|
|
15
|
+
}
|
|
10
16
|
export function hashToNumber(input, length = 12) {
|
|
11
17
|
if (length > 16) {
|
|
12
18
|
throw new Error('Length is greater than max length of 16!');
|