@henrylabs-interview/payment-processor 0.2.5 → 0.2.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/resources/checkout.d.ts +0 -2
- package/dist/resources/checkout.js +3 -31
- package/dist/resources/embedded.js +121 -132
- package/dist/utils/card.d.ts +2 -0
- package/dist/utils/card.js +29 -0
- package/package.json +1 -1
|
@@ -92,8 +92,6 @@ export declare class Checkout {
|
|
|
92
92
|
private processConfirmDecision;
|
|
93
93
|
private buildInstantConfirmSuccess;
|
|
94
94
|
private scheduleConfirmWebhook;
|
|
95
|
-
private isValidCardNumber;
|
|
96
|
-
private isValidExpiry;
|
|
97
95
|
private determineResponseCase;
|
|
98
96
|
private sendWebhookResponse;
|
|
99
97
|
}
|
|
@@ -2,6 +2,7 @@ import { readHistory, writeHistory } from '../utils/store';
|
|
|
2
2
|
import { generateTimeBasedID, hashToString, signPayload } from '../utils/crypto';
|
|
3
3
|
import { INTERNAL_WEBHOOKS } from './webhooks';
|
|
4
4
|
import { sleep } from '../utils/async';
|
|
5
|
+
import { isValidCardNumber, isValidExpiry } from '../utils/card';
|
|
5
6
|
// checkoutId -> { historyRecordId }
|
|
6
7
|
const INTERNAL_CHECKOUTS = {};
|
|
7
8
|
export class Checkout {
|
|
@@ -218,7 +219,7 @@ export class Checkout {
|
|
|
218
219
|
}
|
|
219
220
|
if (params.type === 'raw-card') {
|
|
220
221
|
const { number, expMonth, expYear, cvc } = params.data;
|
|
221
|
-
if (!
|
|
222
|
+
if (!isValidCardNumber(number)) {
|
|
222
223
|
return {
|
|
223
224
|
status: 'failure',
|
|
224
225
|
substatus: '502-fraud',
|
|
@@ -226,7 +227,7 @@ export class Checkout {
|
|
|
226
227
|
message: 'Invalid card number',
|
|
227
228
|
};
|
|
228
229
|
}
|
|
229
|
-
if (!
|
|
230
|
+
if (!isValidExpiry(expMonth, expYear)) {
|
|
230
231
|
return {
|
|
231
232
|
status: 'failure',
|
|
232
233
|
substatus: '503-retry',
|
|
@@ -337,35 +338,6 @@ export class Checkout {
|
|
|
337
338
|
}, webhookDelay);
|
|
338
339
|
}
|
|
339
340
|
//
|
|
340
|
-
isValidCardNumber(number) {
|
|
341
|
-
// Test card is valid
|
|
342
|
-
if (number === '4242424242424242')
|
|
343
|
-
return true;
|
|
344
|
-
// Cards must be 13-19 digits
|
|
345
|
-
if (!/^\d{13,19}$/.test(number))
|
|
346
|
-
return false;
|
|
347
|
-
// Luhn check
|
|
348
|
-
let sum = 0;
|
|
349
|
-
let shouldDouble = false;
|
|
350
|
-
for (let i = number.length - 1; i >= 0; i--) {
|
|
351
|
-
let digit = parseInt(number[i] ?? '');
|
|
352
|
-
if (shouldDouble) {
|
|
353
|
-
digit *= 2;
|
|
354
|
-
if (digit > 9)
|
|
355
|
-
digit -= 9;
|
|
356
|
-
}
|
|
357
|
-
sum += digit;
|
|
358
|
-
shouldDouble = !shouldDouble;
|
|
359
|
-
}
|
|
360
|
-
return sum % 10 === 0;
|
|
361
|
-
}
|
|
362
|
-
isValidExpiry(month, year) {
|
|
363
|
-
if (month < 1 || month > 12)
|
|
364
|
-
return false;
|
|
365
|
-
const now = new Date();
|
|
366
|
-
const expiry = new Date(year, month - 1);
|
|
367
|
-
return expiry > now;
|
|
368
|
-
}
|
|
369
341
|
determineResponseCase(amount, sameRecords) {
|
|
370
342
|
// --- Base probabilities ---
|
|
371
343
|
let immediateWeight = 65;
|
|
@@ -1,11 +1,13 @@
|
|
|
1
|
+
import { isValidCardNumber, isValidExpiry } from '../utils/card';
|
|
1
2
|
import { generateTimeBasedID } from '../utils/crypto';
|
|
2
3
|
export async function renderEmbeddedCheckout(containerElementId, checkoutId, callbackFn) {
|
|
3
|
-
//
|
|
4
|
+
// --- Environment Guard ---
|
|
4
5
|
if (typeof window === 'undefined' || typeof document === 'undefined') {
|
|
5
6
|
console.warn('EmbeddedCheckoutUI can only be used in a browser environment.');
|
|
6
7
|
return false;
|
|
7
8
|
}
|
|
8
|
-
|
|
9
|
+
// --- Checkout Validation ---
|
|
10
|
+
if (checkoutId.length !== 20 || !checkoutId.startsWith('cki_')) {
|
|
9
11
|
console.warn(`Invalid checkout ID: "${checkoutId}".`);
|
|
10
12
|
return false;
|
|
11
13
|
}
|
|
@@ -18,6 +20,7 @@ export async function renderEmbeddedCheckout(containerElementId, checkoutId, cal
|
|
|
18
20
|
console.warn(`Container element "${containerElementId}" not found.`);
|
|
19
21
|
return false;
|
|
20
22
|
}
|
|
23
|
+
// --- Render UI ---
|
|
21
24
|
container.innerHTML = `
|
|
22
25
|
<div style="
|
|
23
26
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
|
@@ -25,161 +28,147 @@ export async function renderEmbeddedCheckout(containerElementId, checkoutId, cal
|
|
|
25
28
|
border-radius: 16px;
|
|
26
29
|
box-shadow: 0 10px 30px rgba(0,0,0,0.08);
|
|
27
30
|
padding: 24px;
|
|
28
|
-
max-width:
|
|
31
|
+
max-width: 400px;
|
|
29
32
|
width: 100%;
|
|
30
33
|
box-sizing: border-box;
|
|
31
34
|
">
|
|
32
|
-
<h3 style="
|
|
33
|
-
|
|
34
|
-
font-size: 18px;
|
|
35
|
-
font-weight: 600;
|
|
36
|
-
color: #111827;
|
|
37
|
-
">
|
|
38
|
-
Henry Labs — "Secure" Checkout
|
|
35
|
+
<h3 style="margin:0 0 20px 0; font-size:18px; font-weight:600; color:#111827;">
|
|
36
|
+
Secure Checkout
|
|
39
37
|
</h3>
|
|
40
38
|
|
|
41
39
|
<div style="display:flex; flex-direction:column; gap:14px;">
|
|
42
40
|
|
|
43
|
-
|
|
44
|
-
<label style="font-size:12px; color:#6b7280; margin-bottom:6px;">
|
|
45
|
-
Card Number
|
|
46
|
-
</label>
|
|
47
|
-
<input
|
|
48
|
-
id="card-number"
|
|
49
|
-
type="text"
|
|
50
|
-
placeholder="4242 4242 4242 4242"
|
|
51
|
-
maxlength="19"
|
|
52
|
-
inputmode="numeric"
|
|
53
|
-
pattern="[0-9 ]*"
|
|
54
|
-
style="
|
|
55
|
-
padding: 10px 12px;
|
|
56
|
-
border-radius: 10px;
|
|
57
|
-
border: 1px solid #e5e7eb;
|
|
58
|
-
font-size: 14px;
|
|
59
|
-
outline: none;
|
|
60
|
-
transition: border 0.2s, box-shadow 0.2s;
|
|
61
|
-
"
|
|
62
|
-
/>
|
|
63
|
-
</div>
|
|
64
|
-
|
|
41
|
+
${inputBlock('card-number', 'Card Number', '4242 4242 4242 4242')}
|
|
65
42
|
<div style="display:flex; gap:10px;">
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
<label style="font-size:12px; color:#6b7280; margin-bottom:6px;">
|
|
69
|
-
Exp. Month
|
|
70
|
-
</label>
|
|
71
|
-
<input
|
|
72
|
-
id="exp-month"
|
|
73
|
-
type="number"
|
|
74
|
-
placeholder="12"
|
|
75
|
-
min="1"
|
|
76
|
-
max="12"
|
|
77
|
-
maxlength="2"
|
|
78
|
-
inputmode="numeric"
|
|
79
|
-
pattern="[0-9]*"
|
|
80
|
-
style="
|
|
81
|
-
padding: 10px 12px;
|
|
82
|
-
border-radius: 10px;
|
|
83
|
-
border: 1px solid #e5e7eb;
|
|
84
|
-
font-size: 14px;
|
|
85
|
-
outline: none;
|
|
86
|
-
transition: border 0.2s, box-shadow 0.2s;
|
|
87
|
-
"
|
|
88
|
-
/>
|
|
43
|
+
<div style="flex:1;">
|
|
44
|
+
${inputBlock('exp-month', 'Exp. Month', '12')}
|
|
89
45
|
</div>
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
<label style="font-size:12px; color:#6b7280; margin-bottom:6px;">
|
|
93
|
-
Exp. Year
|
|
94
|
-
</label>
|
|
95
|
-
<input
|
|
96
|
-
id="exp-year"
|
|
97
|
-
type="number"
|
|
98
|
-
placeholder="2030"
|
|
99
|
-
maxlength="4"
|
|
100
|
-
inputmode="numeric"
|
|
101
|
-
pattern="[0-9]*"
|
|
102
|
-
style="
|
|
103
|
-
padding: 10px 12px;
|
|
104
|
-
border-radius: 10px;
|
|
105
|
-
border: 1px solid #e5e7eb;
|
|
106
|
-
font-size: 14px;
|
|
107
|
-
outline: none;
|
|
108
|
-
transition: border 0.2s, box-shadow 0.2s;
|
|
109
|
-
"
|
|
110
|
-
/>
|
|
46
|
+
<div style="flex:1;">
|
|
47
|
+
${inputBlock('exp-year', 'Exp. Year', '2030')}
|
|
111
48
|
</div>
|
|
112
|
-
|
|
113
|
-
</div>
|
|
114
|
-
|
|
115
|
-
<div style="display:flex; flex-direction:column;">
|
|
116
|
-
<label style="font-size:12px; color:#6b7280; margin-bottom:6px;">
|
|
117
|
-
CVC
|
|
118
|
-
</label>
|
|
119
|
-
<input id="cvc"
|
|
120
|
-
type="text"
|
|
121
|
-
placeholder="123"
|
|
122
|
-
maxlength="4"
|
|
123
|
-
inputmode="numeric"
|
|
124
|
-
pattern="[0-9]*"
|
|
125
|
-
style="
|
|
126
|
-
padding: 10px 12px;
|
|
127
|
-
border-radius: 10px;
|
|
128
|
-
border: 1px solid #e5e7eb;
|
|
129
|
-
font-size: 14px;
|
|
130
|
-
outline: none;
|
|
131
|
-
transition: border 0.2s, box-shadow 0.2s;
|
|
132
|
-
"
|
|
133
|
-
/>
|
|
134
49
|
</div>
|
|
50
|
+
${inputBlock('cvc', 'CVC', '123')}
|
|
135
51
|
|
|
136
52
|
<button id="confirm-btn"
|
|
137
53
|
style="
|
|
138
|
-
margin-top:
|
|
139
|
-
padding:
|
|
140
|
-
border-radius:
|
|
141
|
-
border:
|
|
142
|
-
font-size:
|
|
143
|
-
font-weight:
|
|
54
|
+
margin-top:8px;
|
|
55
|
+
padding:12px;
|
|
56
|
+
border-radius:12px;
|
|
57
|
+
border:none;
|
|
58
|
+
font-size:14px;
|
|
59
|
+
font-weight:600;
|
|
144
60
|
background: linear-gradient(135deg, #6366f1, #4f46e5);
|
|
145
|
-
color:
|
|
146
|
-
cursor:
|
|
147
|
-
transition:
|
|
61
|
+
color:white;
|
|
62
|
+
cursor:pointer;
|
|
63
|
+
transition: all 0.15s ease;
|
|
148
64
|
box-shadow: 0 4px 14px rgba(79,70,229,0.3);
|
|
149
|
-
"
|
|
150
|
-
onmouseover="this.style.boxShadow='0 6px 18px rgba(79,70,229,0.4)'"
|
|
151
|
-
onmouseout="this.style.boxShadow='0 4px 14px rgba(79,70,229,0.3)'"
|
|
152
|
-
onmousedown="this.style.transform='scale(0.98)'"
|
|
153
|
-
onmouseup="this.style.transform='scale(1)'"
|
|
154
|
-
>
|
|
65
|
+
">
|
|
155
66
|
Confirm Payment
|
|
156
67
|
</button>
|
|
157
68
|
|
|
158
|
-
<p style="
|
|
159
|
-
font-size: 11px;
|
|
160
|
-
color: #9ca3af;
|
|
161
|
-
text-align: center;
|
|
162
|
-
margin-top: 8px;
|
|
163
|
-
">
|
|
69
|
+
<p style="font-size:11px; color:#9ca3af; text-align:center; margin-top:8px;">
|
|
164
70
|
🔒 Payments are securely processed
|
|
165
71
|
</p>
|
|
166
72
|
|
|
167
73
|
</div>
|
|
168
74
|
</div>
|
|
169
|
-
`;
|
|
75
|
+
`;
|
|
76
|
+
function inputBlock(id, label, placeholder) {
|
|
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="tel"
|
|
85
|
+
placeholder="${placeholder}"
|
|
86
|
+
inputmode="numeric"
|
|
87
|
+
style="
|
|
88
|
+
padding:10px 12px;
|
|
89
|
+
border-radius:10px;
|
|
90
|
+
border:1px solid #e5e7eb;
|
|
91
|
+
font-size:14px;
|
|
92
|
+
outline:none;
|
|
93
|
+
transition:border 0.2s, box-shadow 0.2s;
|
|
94
|
+
"
|
|
95
|
+
/>
|
|
96
|
+
<div id="${id}-error"
|
|
97
|
+
style="font-size:12px; color:#ef4444; margin-top:4px; display:none;">
|
|
98
|
+
</div>
|
|
99
|
+
</div>
|
|
100
|
+
`;
|
|
101
|
+
}
|
|
102
|
+
// --- DOM Refs ---
|
|
103
|
+
const cardNumberInput = container.querySelector('#card-number');
|
|
104
|
+
const expMonthInput = container.querySelector('#exp-month');
|
|
105
|
+
const expYearInput = container.querySelector('#exp-year');
|
|
106
|
+
const cvcInput = container.querySelector('#cvc');
|
|
170
107
|
const button = container.querySelector('#confirm-btn');
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
const
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
108
|
+
// --- Helpers ---
|
|
109
|
+
function showError(input, message) {
|
|
110
|
+
input.style.border = '1px solid #ef4444';
|
|
111
|
+
const error = container?.querySelector(`#${input.id}-error`);
|
|
112
|
+
if (error) {
|
|
113
|
+
error.textContent = message;
|
|
114
|
+
error.style.display = 'block';
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
function clearError(input) {
|
|
118
|
+
input.style.border = '1px solid #e5e7eb';
|
|
119
|
+
const error = container?.querySelector(`#${input.id}-error`);
|
|
120
|
+
if (error) {
|
|
121
|
+
error.textContent = '';
|
|
122
|
+
error.style.display = 'none';
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
function setLoading(state) {
|
|
126
|
+
button.disabled = state;
|
|
127
|
+
button.style.opacity = state ? '0.6' : '1';
|
|
128
|
+
button.textContent = state ? 'Processing...' : 'Confirm Payment';
|
|
129
|
+
}
|
|
130
|
+
function clearAllErrors() {
|
|
131
|
+
[cardNumberInput, expMonthInput, expYearInput, cvcInput].forEach(clearError);
|
|
132
|
+
}
|
|
133
|
+
// --- Formatting ---
|
|
134
|
+
cardNumberInput.addEventListener('input', () => {
|
|
135
|
+
let value = cardNumberInput.value.replace(/\D/g, '').slice(0, 16);
|
|
136
|
+
value = value.replace(/(.{4})/g, '$1 ').trim();
|
|
137
|
+
cardNumberInput.value = value;
|
|
138
|
+
clearError(cardNumberInput);
|
|
139
|
+
});
|
|
140
|
+
[expMonthInput, expYearInput, cvcInput].forEach((input) => input.addEventListener('input', () => clearError(input)));
|
|
141
|
+
// --- Submit ---
|
|
142
|
+
button.addEventListener('click', async () => {
|
|
143
|
+
clearAllErrors();
|
|
144
|
+
const number = cardNumberInput.value.replace(/\s+/g, '');
|
|
145
|
+
const expMonth = Number(expMonthInput.value);
|
|
146
|
+
const expYear = Number(expYearInput.value);
|
|
147
|
+
const cvc = cvcInput.value;
|
|
148
|
+
let hasError = false;
|
|
149
|
+
if (!number) {
|
|
150
|
+
showError(cardNumberInput, 'Card number required');
|
|
151
|
+
hasError = true;
|
|
152
|
+
}
|
|
153
|
+
else if (!isValidCardNumber(number)) {
|
|
154
|
+
showError(cardNumberInput, 'Invalid card number');
|
|
155
|
+
hasError = true;
|
|
156
|
+
}
|
|
157
|
+
if (!expMonthInput.value || expMonth < 1 || expMonth > 12) {
|
|
158
|
+
showError(expMonthInput, 'Invalid month');
|
|
159
|
+
hasError = true;
|
|
160
|
+
}
|
|
161
|
+
if (!expYearInput.value || !isValidExpiry(expMonth, expYear)) {
|
|
162
|
+
showError(expYearInput, 'Card expired');
|
|
163
|
+
hasError = true;
|
|
164
|
+
}
|
|
165
|
+
if (!cvc || !/^\d{3,4}$/.test(cvc)) {
|
|
166
|
+
showError(cvcInput, 'Invalid CVC');
|
|
167
|
+
hasError = true;
|
|
168
|
+
}
|
|
169
|
+
if (hasError)
|
|
170
|
+
return;
|
|
171
|
+
setLoading(true);
|
|
183
172
|
const paymentToken = `pmt_${await generateTimeBasedID('payment-token')}`;
|
|
184
173
|
callbackFn(paymentToken);
|
|
185
174
|
});
|
|
@@ -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
|
+
}
|