@voxepay/checkout 0.0.1-security → 0.5.19
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.
Potentially problematic release.
This version of @voxepay/checkout might be problematic. Click here for more details.
- package/README.md +140 -3
- package/dist/api/client.d.ts +146 -0
- package/dist/components/modal.d.ts +190 -0
- package/dist/index.cjs.js +2272 -0
- package/dist/index.cjs.js.map +1 -0
- package/dist/index.d.ts +30 -0
- package/dist/index.esm.js +2253 -0
- package/dist/index.esm.js.map +1 -0
- package/dist/types.d.ts +170 -0
- package/dist/utils/card-validator.d.ts +47 -0
- package/dist/utils/encryption.d.ts +28 -0
- package/dist/utils/formatter.d.ts +31 -0
- package/dist/voxepay-checkout.min.js +2 -0
- package/dist/voxepay-checkout.min.js.map +1 -0
- package/dist/voxepay.d.ts +63 -0
- package/package.json +41 -3
- package/src/api/client.ts +301 -0
- package/src/components/modal.ts +1783 -0
- package/src/index.ts +76 -0
- package/src/types.ts +178 -0
- package/src/utils/card-validator.ts +198 -0
- package/src/utils/encryption.ts +182 -0
- package/src/utils/formatter.ts +91 -0
- package/src/voxepay.ts +200 -0
package/src/index.ts
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @voxepay/checkout - Modern Payment Checkout SDK
|
|
3
|
+
*
|
|
4
|
+
* A beautiful, modern payment modal for the web by VoxePay.
|
|
5
|
+
*
|
|
6
|
+
* @example
|
|
7
|
+
* ```html
|
|
8
|
+
* <script src="https://unpkg.com/@voxepay/checkout"></script>
|
|
9
|
+
* <script>
|
|
10
|
+
* VoxePay.init({ apiKey: 'pk_live_xxxxx' });
|
|
11
|
+
*
|
|
12
|
+
* VoxePay.checkout({
|
|
13
|
+
* amount: 4999,
|
|
14
|
+
* currency: 'NGN',
|
|
15
|
+
* onSuccess: (result) => console.log('Paid!', result),
|
|
16
|
+
* onError: (error) => console.error('Failed', error),
|
|
17
|
+
* });
|
|
18
|
+
* </script>
|
|
19
|
+
* ```
|
|
20
|
+
*
|
|
21
|
+
* @packageDocumentation
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
// Main SDK
|
|
25
|
+
export { VoxePay, VoxePaySDK } from './voxepay';
|
|
26
|
+
|
|
27
|
+
// Types
|
|
28
|
+
export type {
|
|
29
|
+
VoxePayConfig,
|
|
30
|
+
VoxePayTheme,
|
|
31
|
+
CheckoutOptions,
|
|
32
|
+
PaymentResult,
|
|
33
|
+
PaymentError,
|
|
34
|
+
CardInfo,
|
|
35
|
+
BankTransferDetails,
|
|
36
|
+
PaymentMethod,
|
|
37
|
+
} from './types';
|
|
38
|
+
|
|
39
|
+
// Utilities (for advanced usage)
|
|
40
|
+
export {
|
|
41
|
+
detectCardBrand,
|
|
42
|
+
validateCardNumber,
|
|
43
|
+
validateExpiry,
|
|
44
|
+
validateCVV,
|
|
45
|
+
luhnCheck,
|
|
46
|
+
} from './utils/card-validator';
|
|
47
|
+
|
|
48
|
+
export {
|
|
49
|
+
formatCardNumber,
|
|
50
|
+
formatExpiry,
|
|
51
|
+
formatCVV,
|
|
52
|
+
formatAmount,
|
|
53
|
+
getCurrencySymbol,
|
|
54
|
+
} from './utils/formatter';
|
|
55
|
+
|
|
56
|
+
export {
|
|
57
|
+
generateAuthData,
|
|
58
|
+
formatExpiryForApi,
|
|
59
|
+
cleanPan,
|
|
60
|
+
} from './utils/encryption';
|
|
61
|
+
|
|
62
|
+
export type { AuthDataParams } from './utils/encryption';
|
|
63
|
+
|
|
64
|
+
export type {
|
|
65
|
+
InitiatePaymentRequest,
|
|
66
|
+
InitiatePaymentResponse,
|
|
67
|
+
ValidateOTPRequest,
|
|
68
|
+
ValidateOTPResponse,
|
|
69
|
+
ResendOTPRequest,
|
|
70
|
+
VirtualAccountResponse,
|
|
71
|
+
PaymentStatusResponse,
|
|
72
|
+
DVAPaymentStatus,
|
|
73
|
+
} from './api/client';
|
|
74
|
+
|
|
75
|
+
// Default export for convenience
|
|
76
|
+
export { VoxePay as default } from './voxepay';
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* VoxePay Checkout Type Definitions
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Configuration for initializing VoxePay
|
|
7
|
+
*/
|
|
8
|
+
export interface VoxePayConfig {
|
|
9
|
+
/** Optional API key (not required for public transfer/status flow) */
|
|
10
|
+
apiKey?: string;
|
|
11
|
+
/** Your VoxePay organization ID */
|
|
12
|
+
organizationId: string;
|
|
13
|
+
/** API base URL (defaults to https://devpay.voxepay.app) */
|
|
14
|
+
baseUrl?: string;
|
|
15
|
+
/** Public payment link slug used for bank transfer initiation endpoint */
|
|
16
|
+
paymentLinkSlug?: string;
|
|
17
|
+
/** Theme mode: 'dark' (default), 'light', or 'auto' (follows system) */
|
|
18
|
+
theme?: 'dark' | 'light' | 'auto';
|
|
19
|
+
/** Locale for formatting (e.g., 'en-US', 'en-NG') */
|
|
20
|
+
locale?: string;
|
|
21
|
+
/** Custom CSS variables to override default theme */
|
|
22
|
+
customStyles?: Partial<VoxePayTheme>;
|
|
23
|
+
/**
|
|
24
|
+
* Simulate webhook on payment status polling (useful for testing in dev/sandbox).
|
|
25
|
+
* When true, the API will trigger a simulated webhook on each status check.
|
|
26
|
+
*/
|
|
27
|
+
simulateWebhook?: boolean;
|
|
28
|
+
/**
|
|
29
|
+
* Bypass key required when simulateWebhook is true.
|
|
30
|
+
* Only needed in sandbox/testing environments.
|
|
31
|
+
*/
|
|
32
|
+
bypassKey?: string;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Custom theme variables
|
|
37
|
+
*/
|
|
38
|
+
export interface VoxePayTheme {
|
|
39
|
+
'--voxepay-primary': string;
|
|
40
|
+
'--voxepay-secondary': string;
|
|
41
|
+
'--voxepay-accent': string;
|
|
42
|
+
'--voxepay-bg': string;
|
|
43
|
+
'--voxepay-surface': string;
|
|
44
|
+
'--voxepay-border': string;
|
|
45
|
+
'--voxepay-text': string;
|
|
46
|
+
'--voxepay-text-muted': string;
|
|
47
|
+
'--voxepay-border-radius': string;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Bank transfer account details shown to the user
|
|
52
|
+
*/
|
|
53
|
+
export interface BankTransferDetails {
|
|
54
|
+
/** Account number to transfer to */
|
|
55
|
+
accountNumber: string;
|
|
56
|
+
/** Name of the bank */
|
|
57
|
+
bankName: string;
|
|
58
|
+
/** Account holder name */
|
|
59
|
+
accountName: string;
|
|
60
|
+
/** Payment reference code (user should include in transfer) */
|
|
61
|
+
reference: string;
|
|
62
|
+
/** Expiry time in seconds (how long the account is valid for, e.g. 1800 = 30 minutes) */
|
|
63
|
+
expiresIn: number;
|
|
64
|
+
/** ISO timestamp when the virtual account expires (from API response) */
|
|
65
|
+
expiresAt?: string;
|
|
66
|
+
/** Fee details associated with this transfer */
|
|
67
|
+
feeDetails?: {
|
|
68
|
+
transactionFee?: number;
|
|
69
|
+
transactionFeeVat?: number;
|
|
70
|
+
totalFees: number;
|
|
71
|
+
grossAmount: number;
|
|
72
|
+
currency?: string;
|
|
73
|
+
};
|
|
74
|
+
/** Total amount to be paid including fees */
|
|
75
|
+
totalPayableAmount?: number;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Supported payment method types */
|
|
79
|
+
export type PaymentMethod = 'card' | 'bank_transfer';
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Options for opening a checkout session
|
|
83
|
+
*/
|
|
84
|
+
export interface CheckoutOptions {
|
|
85
|
+
/** Payment amount in smallest currency unit (e.g., kobo for NGN, cents for USD) */
|
|
86
|
+
amount: number;
|
|
87
|
+
/** ISO 4217 currency code (e.g., 'NGN', 'USD', 'EUR') */
|
|
88
|
+
currency: string;
|
|
89
|
+
/** Short description of the payment (shown in modal) */
|
|
90
|
+
description?: string;
|
|
91
|
+
/** Customer's email address (optional, for receipts) */
|
|
92
|
+
customerEmail?: string;
|
|
93
|
+
/** Customer's phone number */
|
|
94
|
+
customerPhone?: string;
|
|
95
|
+
/** Customer's full name */
|
|
96
|
+
customerName?: string;
|
|
97
|
+
/** Additional metadata to attach to the payment */
|
|
98
|
+
metadata?: Record<string, unknown>;
|
|
99
|
+
/** Pre-calculated fee details if available */
|
|
100
|
+
feeDetails?: {
|
|
101
|
+
transactionFee?: number;
|
|
102
|
+
transactionFeeVat?: number;
|
|
103
|
+
totalFees: number;
|
|
104
|
+
grossAmount: number;
|
|
105
|
+
currency?: string;
|
|
106
|
+
};
|
|
107
|
+
/** Payment methods to show. Defaults to ['card', 'bank_transfer'] */
|
|
108
|
+
paymentMethods?: PaymentMethod[];
|
|
109
|
+
/** Static bank transfer details (if known upfront) */
|
|
110
|
+
bankTransferDetails?: BankTransferDetails;
|
|
111
|
+
/** Callback to request bank transfer details dynamically (called when user selects bank transfer) */
|
|
112
|
+
onBankTransferRequested?: () => Promise<BankTransferDetails>;
|
|
113
|
+
/** @internal SDK config passed from VoxePaySDK to modal */
|
|
114
|
+
_sdkConfig?: {
|
|
115
|
+
apiKey?: string;
|
|
116
|
+
organizationId: string;
|
|
117
|
+
baseUrl?: string;
|
|
118
|
+
paymentLinkSlug?: string;
|
|
119
|
+
simulateWebhook?: boolean;
|
|
120
|
+
bypassKey?: string;
|
|
121
|
+
};
|
|
122
|
+
/** Callback when payment succeeds */
|
|
123
|
+
onSuccess: (result: PaymentResult) => void;
|
|
124
|
+
/** Callback when payment fails */
|
|
125
|
+
onError: (error: PaymentError) => void;
|
|
126
|
+
/** Callback when modal is closed (optional) */
|
|
127
|
+
onClose?: () => void;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Result returned on successful payment
|
|
132
|
+
*/
|
|
133
|
+
export interface PaymentResult {
|
|
134
|
+
/** Unique payment ID */
|
|
135
|
+
id: string;
|
|
136
|
+
/** Payment status */
|
|
137
|
+
status: 'success' | 'pending';
|
|
138
|
+
/** Amount charged in smallest currency unit */
|
|
139
|
+
amount: number;
|
|
140
|
+
/** Currency code */
|
|
141
|
+
currency: string;
|
|
142
|
+
/** ISO timestamp of the payment */
|
|
143
|
+
timestamp: string;
|
|
144
|
+
/** Transaction reference (if available) */
|
|
145
|
+
reference?: string;
|
|
146
|
+
/** Payment method used */
|
|
147
|
+
paymentMethod?: PaymentMethod;
|
|
148
|
+
/** Additional data from payment processor */
|
|
149
|
+
data?: Record<string, unknown>;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Error returned on payment failure
|
|
154
|
+
*/
|
|
155
|
+
export interface PaymentError {
|
|
156
|
+
/** Error code */
|
|
157
|
+
code: string;
|
|
158
|
+
/** Human-readable error message */
|
|
159
|
+
message: string;
|
|
160
|
+
/** Whether the error is recoverable (user can retry) */
|
|
161
|
+
recoverable?: boolean;
|
|
162
|
+
/** Additional error details */
|
|
163
|
+
details?: Record<string, unknown>;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Card information (sanitized - no full card numbers)
|
|
168
|
+
*/
|
|
169
|
+
export interface CardInfo {
|
|
170
|
+
/** Card brand (visa, mastercard, verve, etc.) */
|
|
171
|
+
brand: string;
|
|
172
|
+
/** Last 4 digits of the card */
|
|
173
|
+
last4: string;
|
|
174
|
+
/** Expiry month (1-12) */
|
|
175
|
+
expiryMonth: number;
|
|
176
|
+
/** Expiry year (full year) */
|
|
177
|
+
expiryYear: number;
|
|
178
|
+
}
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Card validation utilities for VoxePay Checkout
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
export interface CardBrand {
|
|
6
|
+
name: string;
|
|
7
|
+
code: string;
|
|
8
|
+
pattern: RegExp;
|
|
9
|
+
lengths: number[];
|
|
10
|
+
cvvLength: number;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export const CARD_BRANDS: CardBrand[] = [
|
|
14
|
+
{
|
|
15
|
+
name: 'Visa',
|
|
16
|
+
code: 'visa',
|
|
17
|
+
pattern: /^4/,
|
|
18
|
+
lengths: [13, 16, 19],
|
|
19
|
+
cvvLength: 3,
|
|
20
|
+
},
|
|
21
|
+
{
|
|
22
|
+
name: 'Mastercard',
|
|
23
|
+
code: 'mastercard',
|
|
24
|
+
pattern: /^(5[1-5]|2[2-7])/,
|
|
25
|
+
lengths: [16],
|
|
26
|
+
cvvLength: 3,
|
|
27
|
+
},
|
|
28
|
+
{
|
|
29
|
+
name: 'Verve',
|
|
30
|
+
code: 'verve',
|
|
31
|
+
pattern: /^(506[0-9]|507[0-9]|6500)/,
|
|
32
|
+
lengths: [16, 18, 19],
|
|
33
|
+
cvvLength: 3,
|
|
34
|
+
},
|
|
35
|
+
{
|
|
36
|
+
name: 'American Express',
|
|
37
|
+
code: 'amex',
|
|
38
|
+
pattern: /^3[47]/,
|
|
39
|
+
lengths: [15],
|
|
40
|
+
cvvLength: 4,
|
|
41
|
+
},
|
|
42
|
+
{
|
|
43
|
+
name: 'Discover',
|
|
44
|
+
code: 'discover',
|
|
45
|
+
pattern: /^(6011|65|64[4-9])/,
|
|
46
|
+
lengths: [16, 19],
|
|
47
|
+
cvvLength: 3,
|
|
48
|
+
},
|
|
49
|
+
];
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Detect card brand from card number
|
|
53
|
+
*/
|
|
54
|
+
export function detectCardBrand(cardNumber: string): CardBrand | null {
|
|
55
|
+
const cleaned = cardNumber.replace(/\s/g, '');
|
|
56
|
+
for (const brand of CARD_BRANDS) {
|
|
57
|
+
if (brand.pattern.test(cleaned)) {
|
|
58
|
+
return brand;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
return null;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Luhn algorithm for card validation
|
|
66
|
+
*/
|
|
67
|
+
export function luhnCheck(cardNumber: string): boolean {
|
|
68
|
+
const cleaned = cardNumber.replace(/\s/g, '');
|
|
69
|
+
if (!/^\d+$/.test(cleaned)) return false;
|
|
70
|
+
|
|
71
|
+
let sum = 0;
|
|
72
|
+
let isEven = false;
|
|
73
|
+
|
|
74
|
+
for (let i = cleaned.length - 1; i >= 0; i--) {
|
|
75
|
+
let digit = parseInt(cleaned[i], 10);
|
|
76
|
+
|
|
77
|
+
if (isEven) {
|
|
78
|
+
digit *= 2;
|
|
79
|
+
if (digit > 9) {
|
|
80
|
+
digit -= 9;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
sum += digit;
|
|
85
|
+
isEven = !isEven;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
return sum % 10 === 0;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Validate card number
|
|
93
|
+
*/
|
|
94
|
+
export function validateCardNumber(cardNumber: string): { valid: boolean; error?: string } {
|
|
95
|
+
const cleaned = cardNumber.replace(/\s/g, '');
|
|
96
|
+
|
|
97
|
+
if (!cleaned) {
|
|
98
|
+
return { valid: false, error: 'Card number is required' };
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
if (!/^\d+$/.test(cleaned)) {
|
|
102
|
+
return { valid: false, error: 'Invalid card number' };
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const brand = detectCardBrand(cleaned);
|
|
106
|
+
|
|
107
|
+
if (!brand) {
|
|
108
|
+
return { valid: false, error: 'Unsupported card type' };
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
if (!brand.lengths.includes(cleaned.length)) {
|
|
112
|
+
return { valid: false, error: 'Invalid card number length' };
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
if (!luhnCheck(cleaned)) {
|
|
116
|
+
return { valid: false, error: 'Invalid card number' };
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
return { valid: true };
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Validate expiry date
|
|
124
|
+
*/
|
|
125
|
+
export function validateExpiry(expiry: string): { valid: boolean; error?: string } {
|
|
126
|
+
const cleaned = expiry.replace(/\s/g, '');
|
|
127
|
+
|
|
128
|
+
if (!cleaned) {
|
|
129
|
+
return { valid: false, error: 'Expiry date is required' };
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const match = cleaned.match(/^(\d{2})\/(\d{2})$/);
|
|
133
|
+
if (!match) {
|
|
134
|
+
return { valid: false, error: 'Invalid format (MM/YY)' };
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const month = parseInt(match[1], 10);
|
|
138
|
+
const year = parseInt(match[2], 10) + 2000;
|
|
139
|
+
|
|
140
|
+
if (month < 1 || month > 12) {
|
|
141
|
+
return { valid: false, error: 'Invalid month' };
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const now = new Date();
|
|
145
|
+
const currentYear = now.getFullYear();
|
|
146
|
+
const currentMonth = now.getMonth() + 1;
|
|
147
|
+
|
|
148
|
+
if (year < currentYear || (year === currentYear && month < currentMonth)) {
|
|
149
|
+
return { valid: false, error: 'Card has expired' };
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
if (year > currentYear + 20) {
|
|
153
|
+
return { valid: false, error: 'Invalid expiry year' };
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
return { valid: true };
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Validate CVV
|
|
161
|
+
*/
|
|
162
|
+
export function validateCVV(cvv: string, cardNumber?: string): { valid: boolean; error?: string } {
|
|
163
|
+
const cleaned = cvv.replace(/\s/g, '');
|
|
164
|
+
|
|
165
|
+
if (!cleaned) {
|
|
166
|
+
return { valid: false, error: 'CVV is required' };
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
if (!/^\d+$/.test(cleaned)) {
|
|
170
|
+
return { valid: false, error: 'Invalid CVV' };
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
const brand = cardNumber ? detectCardBrand(cardNumber) : null;
|
|
174
|
+
const expectedLength = brand?.cvvLength || 3;
|
|
175
|
+
|
|
176
|
+
if (cleaned.length !== expectedLength && cleaned.length !== 3 && cleaned.length !== 4) {
|
|
177
|
+
return { valid: false, error: `CVV must be ${expectedLength} digits` };
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
return { valid: true };
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Validate card PIN
|
|
185
|
+
*/
|
|
186
|
+
export function validatePIN(pin: string): { valid: boolean; error?: string } {
|
|
187
|
+
const cleaned = pin.replace(/\s/g, '');
|
|
188
|
+
|
|
189
|
+
if (!cleaned) {
|
|
190
|
+
return { valid: false, error: 'PIN is required' };
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
if (!/^\d{4}$/.test(cleaned)) {
|
|
194
|
+
return { valid: false, error: 'PIN must be 4 digits' };
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
return { valid: true };
|
|
198
|
+
}
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure JavaScript RSA Encryption for card data
|
|
3
|
+
* No Node.js crypto dependency — works directly in the browser
|
|
4
|
+
* Uses native BigInt for correct RSA arithmetic (aligned with node-forge)
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
// ============ Native BigInt helpers for RSA ============
|
|
8
|
+
|
|
9
|
+
const _bigIntFromBytes = (bytes: number[]): bigint => {
|
|
10
|
+
let result = 0n;
|
|
11
|
+
for (const b of bytes) result = (result << 8n) | BigInt(b);
|
|
12
|
+
return result;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
const _bigIntToBytes = (n: bigint, length: number): number[] => {
|
|
16
|
+
const bytes: number[] = [];
|
|
17
|
+
let rem = n;
|
|
18
|
+
while (rem > 0n) { bytes.unshift(Number(rem & 0xFFn)); rem >>= 8n; }
|
|
19
|
+
while (bytes.length < length) bytes.unshift(0);
|
|
20
|
+
return bytes;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
const _bigIntModPow = (base: bigint, exp: bigint, mod: bigint): bigint => {
|
|
24
|
+
let result = 1n;
|
|
25
|
+
let b = base % mod;
|
|
26
|
+
while (exp > 0n) {
|
|
27
|
+
if (exp & 1n) result = (result * b) % mod;
|
|
28
|
+
exp >>= 1n;
|
|
29
|
+
b = (b * b) % mod;
|
|
30
|
+
}
|
|
31
|
+
return result;
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Convert a string to its hex representation (matches node-forge reference)
|
|
37
|
+
*/
|
|
38
|
+
const toHex = (str: string): string => {
|
|
39
|
+
let hex = '';
|
|
40
|
+
for (let i = 0; i < str.length; i++) {
|
|
41
|
+
hex += str.charCodeAt(i).toString(16);
|
|
42
|
+
}
|
|
43
|
+
return hex;
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Convert hex string to byte array
|
|
48
|
+
*/
|
|
49
|
+
const hexToBytes = (hex: string): number[] => {
|
|
50
|
+
const bytes: number[] = [];
|
|
51
|
+
for (let i = 0; i < hex.length; i += 2) {
|
|
52
|
+
bytes.push(parseInt(hex.substring(i, i + 2), 16));
|
|
53
|
+
}
|
|
54
|
+
return bytes;
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Convert byte array to base64 string
|
|
59
|
+
*/
|
|
60
|
+
const bytesToBase64 = (bytes: number[]): string => {
|
|
61
|
+
if (typeof btoa !== 'undefined') {
|
|
62
|
+
// Browser
|
|
63
|
+
return btoa(String.fromCharCode(...bytes));
|
|
64
|
+
}
|
|
65
|
+
// Node.js fallback
|
|
66
|
+
return Buffer.from(bytes).toString('base64');
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
// ============ PKCS#1 v1.5 Padding ============
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Apply PKCS#1 v1.5 type 2 padding for encryption
|
|
74
|
+
*/
|
|
75
|
+
const pkcs1Pad = (data: number[], keyByteLen: number): number[] => {
|
|
76
|
+
if (data.length > keyByteLen - 11) {
|
|
77
|
+
throw new Error('Message too long for RSA encryption');
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const padLen = keyByteLen - data.length - 3;
|
|
81
|
+
const padded = new Array(keyByteLen);
|
|
82
|
+
padded[0] = 0x00;
|
|
83
|
+
padded[1] = 0x02;
|
|
84
|
+
|
|
85
|
+
// Fill with random non-zero bytes
|
|
86
|
+
for (let i = 0; i < padLen; i++) {
|
|
87
|
+
// Use crypto.getRandomValues if available, otherwise Math.random
|
|
88
|
+
let r = 0;
|
|
89
|
+
if (typeof crypto !== 'undefined' && crypto.getRandomValues) {
|
|
90
|
+
const arr = new Uint8Array(1);
|
|
91
|
+
while (r === 0) {
|
|
92
|
+
crypto.getRandomValues(arr);
|
|
93
|
+
r = arr[0];
|
|
94
|
+
}
|
|
95
|
+
} else {
|
|
96
|
+
while (r === 0) {
|
|
97
|
+
r = Math.floor(Math.random() * 255) + 1;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
padded[2 + i] = r;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
padded[2 + padLen] = 0x00;
|
|
104
|
+
for (let i = 0; i < data.length; i++) {
|
|
105
|
+
padded[3 + padLen + i] = data[i];
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
return padded;
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
// ============ RSA Encryption Config ============
|
|
112
|
+
|
|
113
|
+
const RSA_CONFIG = {
|
|
114
|
+
modulus:
|
|
115
|
+
'009c7b3ba621a26c4b02f48cfc07ef6ee0aed8e12b4bd11c5cc0abf80d5206be69e1891e60fc88e2d565e2fabe4d0cf630e318a6c721c3ded718d0c530cdf050387ad0a30a336899bbda877d0ec7c7c3ffe693988bfae0ffbab71b25468c7814924f022cb5fda36e0d2c30a7161fa1c6fb5fbd7d05adbef7e68d48f8b6c5f511827c4b1c5ed15b6f20555affc4d0857ef7ab2b5c18ba22bea5d3a79bd1834badb5878d8c7a4b19da20c1f62340b1f7fbf01d2f2e97c9714a9df376ac0ea58072b2b77aeb7872b54a89667519de44d0fc73540beeaec4cb778a45eebfbefe2d817a8a8319b2bc6d9fa714f5289ec7c0dbc43496d71cf2a642cb679b0fc4072fd2cf',
|
|
116
|
+
publicExponent: '010001',
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
// ============ Public API ============
|
|
120
|
+
|
|
121
|
+
export interface AuthDataParams {
|
|
122
|
+
version: string;
|
|
123
|
+
pan: string;
|
|
124
|
+
pin: string;
|
|
125
|
+
expiryDate: string;
|
|
126
|
+
cvv: string;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Generates encrypted auth data for card payment
|
|
131
|
+
* Uses pure JavaScript RSA — no Node.js crypto dependency
|
|
132
|
+
*
|
|
133
|
+
* @param params - Card details for encryption
|
|
134
|
+
* @returns Base64 encoded encrypted auth data
|
|
135
|
+
*/
|
|
136
|
+
export const generateAuthData = async ({
|
|
137
|
+
version,
|
|
138
|
+
pan,
|
|
139
|
+
pin,
|
|
140
|
+
expiryDate,
|
|
141
|
+
cvv,
|
|
142
|
+
}: AuthDataParams): Promise<string> => {
|
|
143
|
+
try {
|
|
144
|
+
// Build auth string: "1Z{pan}Z{pin}Z{expDate}Z{cvv}" — matches node-forge reference
|
|
145
|
+
const authString = `${version}Z${pan}Z${pin}Z${expiryDate}Z${cvv}`;
|
|
146
|
+
const messageBytes = hexToBytes(toHex(authString));
|
|
147
|
+
|
|
148
|
+
// Parse RSA key and determine key byte length (strip leading zero byte)
|
|
149
|
+
const modulus = BigInt('0x' + RSA_CONFIG.modulus);
|
|
150
|
+
const exponent = BigInt('0x' + RSA_CONFIG.publicExponent);
|
|
151
|
+
const modHex = RSA_CONFIG.modulus.replace(/^0+/, '');
|
|
152
|
+
const keyByteLen = Math.ceil(modHex.length / 2);
|
|
153
|
+
|
|
154
|
+
// PKCS#1 v1.5 padding then RSA encrypt via native BigInt
|
|
155
|
+
const padded = pkcs1Pad(messageBytes, keyByteLen);
|
|
156
|
+
const m = _bigIntFromBytes(padded);
|
|
157
|
+
const c = _bigIntModPow(m, exponent, modulus);
|
|
158
|
+
|
|
159
|
+
return bytesToBase64(_bigIntToBytes(c, keyByteLen));
|
|
160
|
+
} catch (error) {
|
|
161
|
+
console.error('[VoxePay] Encryption error:', error);
|
|
162
|
+
throw new Error('Failed to encrypt card data');
|
|
163
|
+
}
|
|
164
|
+
};
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Converts MM/YY format to YYMM format required by API
|
|
168
|
+
*/
|
|
169
|
+
export const formatExpiryForApi = (expiry: string): string => {
|
|
170
|
+
const cleaned = expiry.replace(/\s|\//g, '');
|
|
171
|
+
if (cleaned.length !== 4) return '';
|
|
172
|
+
const month = cleaned.substring(0, 2);
|
|
173
|
+
const year = cleaned.substring(2, 4);
|
|
174
|
+
return `${year}${month}`;
|
|
175
|
+
};
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Removes spaces and formatting from PAN
|
|
179
|
+
*/
|
|
180
|
+
export const cleanPan = (pan: string): string => {
|
|
181
|
+
return pan.replace(/\s/g, '');
|
|
182
|
+
};
|