@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 +205 -0
- package/dist/index.d.ts +23 -0
- package/dist/index.js +36 -0
- package/dist/resources/checkout.d.ts +100 -0
- package/dist/resources/checkout.js +451 -0
- package/dist/resources/embedded.d.ts +1 -0
- package/dist/resources/embedded.js +178 -0
- package/dist/resources/webhooks.d.ts +24 -0
- package/dist/resources/webhooks.js +20 -0
- package/dist/utils/async.d.ts +1 -0
- package/dist/utils/async.js +1 -0
- package/dist/utils/card.d.ts +2 -0
- package/dist/utils/card.js +29 -0
- package/dist/utils/crypto.d.ts +5 -0
- package/dist/utils/crypto.js +28 -0
- package/dist/utils/store.d.ts +16 -0
- package/dist/utils/store.js +40 -0
- package/package.json +28 -0
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.
|
package/dist/index.d.ts
ADDED
|
@@ -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,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
|
+
}
|