9pay-integrate 1.0.0
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 +241 -0
- package/dist/index.cjs +317 -0
- package/dist/index.d.cts +195 -0
- package/dist/index.d.ts +195 -0
- package/dist/index.js +268 -0
- package/dist/next/index.cjs +278 -0
- package/dist/next/index.d.cts +101 -0
- package/dist/next/index.d.ts +101 -0
- package/dist/next/index.js +239 -0
- package/package.json +65 -0
package/dist/index.d.cts
ADDED
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Core type definitions for 9Pay integration.
|
|
3
|
+
* Framework-agnostic — no Next.js, React, or Express dependencies.
|
|
4
|
+
*/
|
|
5
|
+
interface NinePayConfig {
|
|
6
|
+
merchantKey: string;
|
|
7
|
+
secretKey: string;
|
|
8
|
+
checksumKey: string;
|
|
9
|
+
baseUrl?: string;
|
|
10
|
+
returnUrl: string;
|
|
11
|
+
cancelUrl?: string;
|
|
12
|
+
}
|
|
13
|
+
interface NinePayPaymentOptions {
|
|
14
|
+
/** 0: All, 1: Domestic, 2: International */
|
|
15
|
+
card_origin_allow?: 0 | 1 | 2;
|
|
16
|
+
/** Comma-separated: "VISA,MASTER,JCB,AMEX" */
|
|
17
|
+
card_brand_allow?: string;
|
|
18
|
+
/** Comma-separated BIN codes */
|
|
19
|
+
bin_allow?: string;
|
|
20
|
+
/** Card type filter */
|
|
21
|
+
card_type_allow?: string;
|
|
22
|
+
lang?: "en" | "vi";
|
|
23
|
+
method?: string;
|
|
24
|
+
}
|
|
25
|
+
interface CreatePaymentParams {
|
|
26
|
+
orderId: string | number;
|
|
27
|
+
amount: number;
|
|
28
|
+
currency: string;
|
|
29
|
+
description: string;
|
|
30
|
+
options?: NinePayPaymentOptions;
|
|
31
|
+
}
|
|
32
|
+
interface VerifyCallbackResult {
|
|
33
|
+
success: boolean;
|
|
34
|
+
orderId?: string;
|
|
35
|
+
amount?: number;
|
|
36
|
+
currency?: string;
|
|
37
|
+
status?: number;
|
|
38
|
+
/** Raw decoded payload for consumer access */
|
|
39
|
+
rawParams: Record<string, unknown>;
|
|
40
|
+
error?: string;
|
|
41
|
+
}
|
|
42
|
+
interface VerificationResult<T = Record<string, unknown>> {
|
|
43
|
+
valid: boolean;
|
|
44
|
+
data: T | null;
|
|
45
|
+
error?: string;
|
|
46
|
+
}
|
|
47
|
+
type OrderStatus = "neworder" | "pending" | "completed" | "failed";
|
|
48
|
+
interface CreateOrderParams {
|
|
49
|
+
orderId?: string;
|
|
50
|
+
totalAmount: number;
|
|
51
|
+
currency: string;
|
|
52
|
+
paymentMethod: string;
|
|
53
|
+
metadata?: Record<string, unknown>;
|
|
54
|
+
}
|
|
55
|
+
interface OrderRepository<TOrder = unknown> {
|
|
56
|
+
createOrder(params: CreateOrderParams): Promise<TOrder>;
|
|
57
|
+
getOrderStatus(orderId: string): Promise<OrderStatus | null>;
|
|
58
|
+
updateOrderStatus(orderId: string, status: "completed" | "failed"): Promise<TOrder | null>;
|
|
59
|
+
}
|
|
60
|
+
interface NotificationContext {
|
|
61
|
+
orderId: string;
|
|
62
|
+
status: "completed" | "failed";
|
|
63
|
+
amount: number;
|
|
64
|
+
currency: string;
|
|
65
|
+
paymentMethod: string;
|
|
66
|
+
metadata?: Record<string, unknown>;
|
|
67
|
+
}
|
|
68
|
+
type NotificationHandler = (ctx: NotificationContext) => Promise<void> | void;
|
|
69
|
+
interface NotificationHooks {
|
|
70
|
+
onSuccess?: NotificationHandler;
|
|
71
|
+
onFailure?: NotificationHandler;
|
|
72
|
+
}
|
|
73
|
+
interface IpnRawPayload {
|
|
74
|
+
result: string;
|
|
75
|
+
checksum: string;
|
|
76
|
+
}
|
|
77
|
+
interface IpnProcessResult {
|
|
78
|
+
success: boolean;
|
|
79
|
+
message: string;
|
|
80
|
+
statusCode: number;
|
|
81
|
+
orderId?: string;
|
|
82
|
+
}
|
|
83
|
+
interface IpnProcessorOptions {
|
|
84
|
+
config: NinePayConfig;
|
|
85
|
+
orderRepository: OrderRepository;
|
|
86
|
+
notifications?: NotificationHooks;
|
|
87
|
+
}
|
|
88
|
+
interface PaymentStatusResponse {
|
|
89
|
+
success: boolean;
|
|
90
|
+
status: OrderStatus | null;
|
|
91
|
+
message?: string;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Validate and normalize a partial NinePayConfig.
|
|
96
|
+
* Throws if required fields are missing — fail fast at init time.
|
|
97
|
+
*/
|
|
98
|
+
declare function validateConfig(config: Partial<NinePayConfig>): NinePayConfig;
|
|
99
|
+
/**
|
|
100
|
+
* Build NinePayConfig from any environment-like object.
|
|
101
|
+
* Accepts Record<string, string | undefined> so it works with
|
|
102
|
+
* process.env, Vercel env, or custom config loaders.
|
|
103
|
+
*/
|
|
104
|
+
declare function configFromEnv(env: Record<string, string | undefined>): NinePayConfig;
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Create a signed 9Pay payment redirect URL.
|
|
108
|
+
*
|
|
109
|
+
* The user is redirected to this URL to complete payment on 9Pay's portal.
|
|
110
|
+
* After payment, 9Pay redirects the browser to config.returnUrl (callback)
|
|
111
|
+
* and sends a server-to-server POST to config.returnUrl (IPN).
|
|
112
|
+
*/
|
|
113
|
+
declare function createPaymentUrl(config: NinePayConfig, params: CreatePaymentParams): string;
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Build sorted HTTP query string from key-value pairs.
|
|
117
|
+
* Keys are sorted alphabetically — required by 9Pay for valid signatures.
|
|
118
|
+
*/
|
|
119
|
+
declare function buildHttpQuery(data: Record<string, string | number>): string;
|
|
120
|
+
/**
|
|
121
|
+
* Build HMAC-SHA256 signature for 9Pay payment creation request.
|
|
122
|
+
* Message format: POST\n{baseUrl}/payments/create\n{time}\n{httpQuery}
|
|
123
|
+
* Returns Base64-encoded signature.
|
|
124
|
+
*/
|
|
125
|
+
declare function buildSignature(baseUrl: string, time: number, httpQuery: string, secretKey: string): string;
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Verify 9Pay callback/IPN checksum.
|
|
129
|
+
*
|
|
130
|
+
* 9Pay computes: SHA256(result + checksumKey).toUpperCase()
|
|
131
|
+
* We recompute and compare. If valid, decode result from base64 → JSON.
|
|
132
|
+
*
|
|
133
|
+
* @param result Base64-encoded JSON payload from 9Pay
|
|
134
|
+
* @param checksum SHA256 hex hash from 9Pay
|
|
135
|
+
* @param checksumKey Shared secret key
|
|
136
|
+
*/
|
|
137
|
+
declare function verifyChecksum(result: string, checksum: string, checksumKey: string): VerificationResult;
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Verify 9Pay browser redirect callback.
|
|
141
|
+
*
|
|
142
|
+
* Takes URL search params (from GET /api/checkout/9pay/callback),
|
|
143
|
+
* verifies the checksum, extracts orderId and payment info.
|
|
144
|
+
*
|
|
145
|
+
* Returns an object — consumer decides what to redirect to.
|
|
146
|
+
* This is Option B: package does NOT redirect, only returns data.
|
|
147
|
+
*/
|
|
148
|
+
declare function verifyCallback(searchParams: URLSearchParams, config: NinePayConfig): VerifyCallbackResult;
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Extract IPN payload from FormData.
|
|
152
|
+
* 9Pay sends POST with result and checksum fields.
|
|
153
|
+
*/
|
|
154
|
+
declare function extractIpnFromFormData(formData: FormData): IpnRawPayload;
|
|
155
|
+
/**
|
|
156
|
+
* Extract IPN payload from URL-encoded string body.
|
|
157
|
+
* 9Pay may send application/x-www-form-urlencoded instead of FormData.
|
|
158
|
+
*/
|
|
159
|
+
declare function extractIpnFromUrlEncoded(body: string): IpnRawPayload;
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Process a 9Pay IPN (Instant Payment Notification) — adapter-agnostic.
|
|
163
|
+
*
|
|
164
|
+
* This is the core IPN logic, completely independent of Next.js, Express,
|
|
165
|
+
* or any framework. It:
|
|
166
|
+
* 1. Verifies the checksum
|
|
167
|
+
* 2. Decodes the payment info
|
|
168
|
+
* 3. Extracts the orderId (invoice_no)
|
|
169
|
+
* 4. Checks payment status (5 = success)
|
|
170
|
+
* 5. Calls orderRepository.updateOrderStatus()
|
|
171
|
+
* 6. Calls notification hooks (onSuccess / onFailure)
|
|
172
|
+
*
|
|
173
|
+
* The Next.js adapter (or Express, Hono, etc.) is responsible for:
|
|
174
|
+
* - Parsing the incoming request into IpnRawPayload
|
|
175
|
+
* - Returning the appropriate HTTP response
|
|
176
|
+
*/
|
|
177
|
+
declare function processIpn(payload: IpnRawPayload, options: IpnProcessorOptions): Promise<IpnProcessResult>;
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* 9Pay status codes and defaults.
|
|
181
|
+
*/
|
|
182
|
+
declare const NINEPAY_STATUS: {
|
|
183
|
+
readonly PENDING: 1;
|
|
184
|
+
readonly PROCESSING: 2;
|
|
185
|
+
readonly CANCELLED: 3;
|
|
186
|
+
readonly REFUNDED: 4;
|
|
187
|
+
readonly SUCCESS: 5;
|
|
188
|
+
readonly FAILED: 6;
|
|
189
|
+
};
|
|
190
|
+
/** Status code indicating successful payment */
|
|
191
|
+
declare const SUCCESS_STATUS: 5;
|
|
192
|
+
/** Default 9Pay base URL */
|
|
193
|
+
declare const DEFAULT_BASE_URL = "https://payment.9pay.vn";
|
|
194
|
+
|
|
195
|
+
export { type CreateOrderParams, type CreatePaymentParams, DEFAULT_BASE_URL, type IpnProcessResult, type IpnProcessorOptions, type IpnRawPayload, NINEPAY_STATUS, type NinePayConfig, type NinePayPaymentOptions, type NotificationContext, type NotificationHandler, type NotificationHooks, type OrderRepository, type OrderStatus, type PaymentStatusResponse, SUCCESS_STATUS, type VerificationResult, type VerifyCallbackResult, buildHttpQuery, buildSignature, configFromEnv, createPaymentUrl, extractIpnFromFormData, extractIpnFromUrlEncoded, processIpn, validateConfig, verifyCallback, verifyChecksum };
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Core type definitions for 9Pay integration.
|
|
3
|
+
* Framework-agnostic — no Next.js, React, or Express dependencies.
|
|
4
|
+
*/
|
|
5
|
+
interface NinePayConfig {
|
|
6
|
+
merchantKey: string;
|
|
7
|
+
secretKey: string;
|
|
8
|
+
checksumKey: string;
|
|
9
|
+
baseUrl?: string;
|
|
10
|
+
returnUrl: string;
|
|
11
|
+
cancelUrl?: string;
|
|
12
|
+
}
|
|
13
|
+
interface NinePayPaymentOptions {
|
|
14
|
+
/** 0: All, 1: Domestic, 2: International */
|
|
15
|
+
card_origin_allow?: 0 | 1 | 2;
|
|
16
|
+
/** Comma-separated: "VISA,MASTER,JCB,AMEX" */
|
|
17
|
+
card_brand_allow?: string;
|
|
18
|
+
/** Comma-separated BIN codes */
|
|
19
|
+
bin_allow?: string;
|
|
20
|
+
/** Card type filter */
|
|
21
|
+
card_type_allow?: string;
|
|
22
|
+
lang?: "en" | "vi";
|
|
23
|
+
method?: string;
|
|
24
|
+
}
|
|
25
|
+
interface CreatePaymentParams {
|
|
26
|
+
orderId: string | number;
|
|
27
|
+
amount: number;
|
|
28
|
+
currency: string;
|
|
29
|
+
description: string;
|
|
30
|
+
options?: NinePayPaymentOptions;
|
|
31
|
+
}
|
|
32
|
+
interface VerifyCallbackResult {
|
|
33
|
+
success: boolean;
|
|
34
|
+
orderId?: string;
|
|
35
|
+
amount?: number;
|
|
36
|
+
currency?: string;
|
|
37
|
+
status?: number;
|
|
38
|
+
/** Raw decoded payload for consumer access */
|
|
39
|
+
rawParams: Record<string, unknown>;
|
|
40
|
+
error?: string;
|
|
41
|
+
}
|
|
42
|
+
interface VerificationResult<T = Record<string, unknown>> {
|
|
43
|
+
valid: boolean;
|
|
44
|
+
data: T | null;
|
|
45
|
+
error?: string;
|
|
46
|
+
}
|
|
47
|
+
type OrderStatus = "neworder" | "pending" | "completed" | "failed";
|
|
48
|
+
interface CreateOrderParams {
|
|
49
|
+
orderId?: string;
|
|
50
|
+
totalAmount: number;
|
|
51
|
+
currency: string;
|
|
52
|
+
paymentMethod: string;
|
|
53
|
+
metadata?: Record<string, unknown>;
|
|
54
|
+
}
|
|
55
|
+
interface OrderRepository<TOrder = unknown> {
|
|
56
|
+
createOrder(params: CreateOrderParams): Promise<TOrder>;
|
|
57
|
+
getOrderStatus(orderId: string): Promise<OrderStatus | null>;
|
|
58
|
+
updateOrderStatus(orderId: string, status: "completed" | "failed"): Promise<TOrder | null>;
|
|
59
|
+
}
|
|
60
|
+
interface NotificationContext {
|
|
61
|
+
orderId: string;
|
|
62
|
+
status: "completed" | "failed";
|
|
63
|
+
amount: number;
|
|
64
|
+
currency: string;
|
|
65
|
+
paymentMethod: string;
|
|
66
|
+
metadata?: Record<string, unknown>;
|
|
67
|
+
}
|
|
68
|
+
type NotificationHandler = (ctx: NotificationContext) => Promise<void> | void;
|
|
69
|
+
interface NotificationHooks {
|
|
70
|
+
onSuccess?: NotificationHandler;
|
|
71
|
+
onFailure?: NotificationHandler;
|
|
72
|
+
}
|
|
73
|
+
interface IpnRawPayload {
|
|
74
|
+
result: string;
|
|
75
|
+
checksum: string;
|
|
76
|
+
}
|
|
77
|
+
interface IpnProcessResult {
|
|
78
|
+
success: boolean;
|
|
79
|
+
message: string;
|
|
80
|
+
statusCode: number;
|
|
81
|
+
orderId?: string;
|
|
82
|
+
}
|
|
83
|
+
interface IpnProcessorOptions {
|
|
84
|
+
config: NinePayConfig;
|
|
85
|
+
orderRepository: OrderRepository;
|
|
86
|
+
notifications?: NotificationHooks;
|
|
87
|
+
}
|
|
88
|
+
interface PaymentStatusResponse {
|
|
89
|
+
success: boolean;
|
|
90
|
+
status: OrderStatus | null;
|
|
91
|
+
message?: string;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Validate and normalize a partial NinePayConfig.
|
|
96
|
+
* Throws if required fields are missing — fail fast at init time.
|
|
97
|
+
*/
|
|
98
|
+
declare function validateConfig(config: Partial<NinePayConfig>): NinePayConfig;
|
|
99
|
+
/**
|
|
100
|
+
* Build NinePayConfig from any environment-like object.
|
|
101
|
+
* Accepts Record<string, string | undefined> so it works with
|
|
102
|
+
* process.env, Vercel env, or custom config loaders.
|
|
103
|
+
*/
|
|
104
|
+
declare function configFromEnv(env: Record<string, string | undefined>): NinePayConfig;
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Create a signed 9Pay payment redirect URL.
|
|
108
|
+
*
|
|
109
|
+
* The user is redirected to this URL to complete payment on 9Pay's portal.
|
|
110
|
+
* After payment, 9Pay redirects the browser to config.returnUrl (callback)
|
|
111
|
+
* and sends a server-to-server POST to config.returnUrl (IPN).
|
|
112
|
+
*/
|
|
113
|
+
declare function createPaymentUrl(config: NinePayConfig, params: CreatePaymentParams): string;
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Build sorted HTTP query string from key-value pairs.
|
|
117
|
+
* Keys are sorted alphabetically — required by 9Pay for valid signatures.
|
|
118
|
+
*/
|
|
119
|
+
declare function buildHttpQuery(data: Record<string, string | number>): string;
|
|
120
|
+
/**
|
|
121
|
+
* Build HMAC-SHA256 signature for 9Pay payment creation request.
|
|
122
|
+
* Message format: POST\n{baseUrl}/payments/create\n{time}\n{httpQuery}
|
|
123
|
+
* Returns Base64-encoded signature.
|
|
124
|
+
*/
|
|
125
|
+
declare function buildSignature(baseUrl: string, time: number, httpQuery: string, secretKey: string): string;
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Verify 9Pay callback/IPN checksum.
|
|
129
|
+
*
|
|
130
|
+
* 9Pay computes: SHA256(result + checksumKey).toUpperCase()
|
|
131
|
+
* We recompute and compare. If valid, decode result from base64 → JSON.
|
|
132
|
+
*
|
|
133
|
+
* @param result Base64-encoded JSON payload from 9Pay
|
|
134
|
+
* @param checksum SHA256 hex hash from 9Pay
|
|
135
|
+
* @param checksumKey Shared secret key
|
|
136
|
+
*/
|
|
137
|
+
declare function verifyChecksum(result: string, checksum: string, checksumKey: string): VerificationResult;
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Verify 9Pay browser redirect callback.
|
|
141
|
+
*
|
|
142
|
+
* Takes URL search params (from GET /api/checkout/9pay/callback),
|
|
143
|
+
* verifies the checksum, extracts orderId and payment info.
|
|
144
|
+
*
|
|
145
|
+
* Returns an object — consumer decides what to redirect to.
|
|
146
|
+
* This is Option B: package does NOT redirect, only returns data.
|
|
147
|
+
*/
|
|
148
|
+
declare function verifyCallback(searchParams: URLSearchParams, config: NinePayConfig): VerifyCallbackResult;
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Extract IPN payload from FormData.
|
|
152
|
+
* 9Pay sends POST with result and checksum fields.
|
|
153
|
+
*/
|
|
154
|
+
declare function extractIpnFromFormData(formData: FormData): IpnRawPayload;
|
|
155
|
+
/**
|
|
156
|
+
* Extract IPN payload from URL-encoded string body.
|
|
157
|
+
* 9Pay may send application/x-www-form-urlencoded instead of FormData.
|
|
158
|
+
*/
|
|
159
|
+
declare function extractIpnFromUrlEncoded(body: string): IpnRawPayload;
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Process a 9Pay IPN (Instant Payment Notification) — adapter-agnostic.
|
|
163
|
+
*
|
|
164
|
+
* This is the core IPN logic, completely independent of Next.js, Express,
|
|
165
|
+
* or any framework. It:
|
|
166
|
+
* 1. Verifies the checksum
|
|
167
|
+
* 2. Decodes the payment info
|
|
168
|
+
* 3. Extracts the orderId (invoice_no)
|
|
169
|
+
* 4. Checks payment status (5 = success)
|
|
170
|
+
* 5. Calls orderRepository.updateOrderStatus()
|
|
171
|
+
* 6. Calls notification hooks (onSuccess / onFailure)
|
|
172
|
+
*
|
|
173
|
+
* The Next.js adapter (or Express, Hono, etc.) is responsible for:
|
|
174
|
+
* - Parsing the incoming request into IpnRawPayload
|
|
175
|
+
* - Returning the appropriate HTTP response
|
|
176
|
+
*/
|
|
177
|
+
declare function processIpn(payload: IpnRawPayload, options: IpnProcessorOptions): Promise<IpnProcessResult>;
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* 9Pay status codes and defaults.
|
|
181
|
+
*/
|
|
182
|
+
declare const NINEPAY_STATUS: {
|
|
183
|
+
readonly PENDING: 1;
|
|
184
|
+
readonly PROCESSING: 2;
|
|
185
|
+
readonly CANCELLED: 3;
|
|
186
|
+
readonly REFUNDED: 4;
|
|
187
|
+
readonly SUCCESS: 5;
|
|
188
|
+
readonly FAILED: 6;
|
|
189
|
+
};
|
|
190
|
+
/** Status code indicating successful payment */
|
|
191
|
+
declare const SUCCESS_STATUS: 5;
|
|
192
|
+
/** Default 9Pay base URL */
|
|
193
|
+
declare const DEFAULT_BASE_URL = "https://payment.9pay.vn";
|
|
194
|
+
|
|
195
|
+
export { type CreateOrderParams, type CreatePaymentParams, DEFAULT_BASE_URL, type IpnProcessResult, type IpnProcessorOptions, type IpnRawPayload, NINEPAY_STATUS, type NinePayConfig, type NinePayPaymentOptions, type NotificationContext, type NotificationHandler, type NotificationHooks, type OrderRepository, type OrderStatus, type PaymentStatusResponse, SUCCESS_STATUS, type VerificationResult, type VerifyCallbackResult, buildHttpQuery, buildSignature, configFromEnv, createPaymentUrl, extractIpnFromFormData, extractIpnFromUrlEncoded, processIpn, validateConfig, verifyCallback, verifyChecksum };
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
// src/core/constants.ts
|
|
2
|
+
var NINEPAY_STATUS = {
|
|
3
|
+
PENDING: 1,
|
|
4
|
+
PROCESSING: 2,
|
|
5
|
+
CANCELLED: 3,
|
|
6
|
+
REFUNDED: 4,
|
|
7
|
+
SUCCESS: 5,
|
|
8
|
+
FAILED: 6
|
|
9
|
+
};
|
|
10
|
+
var SUCCESS_STATUS = NINEPAY_STATUS.SUCCESS;
|
|
11
|
+
var DEFAULT_BASE_URL = "https://payment.9pay.vn";
|
|
12
|
+
|
|
13
|
+
// src/core/config.ts
|
|
14
|
+
function validateConfig(config) {
|
|
15
|
+
const errors = [];
|
|
16
|
+
if (!config.merchantKey) errors.push("merchantKey is required");
|
|
17
|
+
if (!config.secretKey) errors.push("secretKey is required");
|
|
18
|
+
if (!config.checksumKey) errors.push("checksumKey is required");
|
|
19
|
+
if (!config.returnUrl) errors.push("returnUrl is required");
|
|
20
|
+
if (errors.length > 0) {
|
|
21
|
+
throw new Error(
|
|
22
|
+
`9pay-integrate configuration error:
|
|
23
|
+
- ${errors.join("\n - ")}`
|
|
24
|
+
);
|
|
25
|
+
}
|
|
26
|
+
return {
|
|
27
|
+
merchantKey: config.merchantKey,
|
|
28
|
+
secretKey: config.secretKey,
|
|
29
|
+
checksumKey: config.checksumKey,
|
|
30
|
+
baseUrl: config.baseUrl || DEFAULT_BASE_URL,
|
|
31
|
+
returnUrl: config.returnUrl,
|
|
32
|
+
cancelUrl: config.cancelUrl || config.returnUrl
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
function configFromEnv(env) {
|
|
36
|
+
return validateConfig({
|
|
37
|
+
merchantKey: env.NINEPAY_MERCHANT_KEY,
|
|
38
|
+
secretKey: env.NINEPAY_SECRET_KEY,
|
|
39
|
+
checksumKey: env.NINEPAY_CHECKSUM_KEY,
|
|
40
|
+
baseUrl: env.NINEPAY_BASE_URL,
|
|
41
|
+
returnUrl: env.NINEPAY_RETURN_URL,
|
|
42
|
+
cancelUrl: env.NINEPAY_CANCEL_URL
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// src/core/signing.ts
|
|
47
|
+
import * as crypto from "crypto";
|
|
48
|
+
function buildHttpQuery(data) {
|
|
49
|
+
const params = new URLSearchParams();
|
|
50
|
+
const sortedKeys = Object.keys(data).sort();
|
|
51
|
+
for (const key of sortedKeys) {
|
|
52
|
+
if (data[key] !== void 0 && data[key] !== null) {
|
|
53
|
+
params.append(key, String(data[key]));
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
return params.toString();
|
|
57
|
+
}
|
|
58
|
+
function buildSignature(baseUrl, time, httpQuery, secretKey) {
|
|
59
|
+
const message = `POST
|
|
60
|
+
${baseUrl}/payments/create
|
|
61
|
+
${time}
|
|
62
|
+
${httpQuery}`;
|
|
63
|
+
return crypto.createHmac("sha256", secretKey).update(message).digest().toString("base64");
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// src/core/payment-url.ts
|
|
67
|
+
function createPaymentUrl(config, params) {
|
|
68
|
+
const time = Math.floor(Date.now() / 1e3);
|
|
69
|
+
const parameters = {
|
|
70
|
+
merchantKey: config.merchantKey,
|
|
71
|
+
time,
|
|
72
|
+
invoice_no: params.orderId,
|
|
73
|
+
amount: params.amount,
|
|
74
|
+
currency: params.currency,
|
|
75
|
+
description: params.description,
|
|
76
|
+
return_url: config.returnUrl,
|
|
77
|
+
...config.cancelUrl ? { back_url: config.cancelUrl } : {},
|
|
78
|
+
...params.options
|
|
79
|
+
};
|
|
80
|
+
const httpQuery = buildHttpQuery(parameters);
|
|
81
|
+
const signature = buildSignature(
|
|
82
|
+
config.baseUrl || "https://payment.9pay.vn",
|
|
83
|
+
time,
|
|
84
|
+
httpQuery,
|
|
85
|
+
config.secretKey
|
|
86
|
+
);
|
|
87
|
+
const sortedParameters = {};
|
|
88
|
+
Object.keys(parameters).sort().forEach((key) => {
|
|
89
|
+
sortedParameters[key] = parameters[key];
|
|
90
|
+
});
|
|
91
|
+
const baseEncode = Buffer.from(JSON.stringify(sortedParameters)).toString(
|
|
92
|
+
"base64"
|
|
93
|
+
);
|
|
94
|
+
const redirectParams = new URLSearchParams({ baseEncode, signature });
|
|
95
|
+
return `${config.baseUrl || "https://payment.9pay.vn"}/portal?${redirectParams.toString()}`;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// src/core/verification.ts
|
|
99
|
+
import * as crypto2 from "crypto";
|
|
100
|
+
function verifyChecksum(result, checksum, checksumKey) {
|
|
101
|
+
if (!result || !checksum) {
|
|
102
|
+
return { valid: false, data: null, error: "Missing result or checksum" };
|
|
103
|
+
}
|
|
104
|
+
const expected = crypto2.createHash("sha256").update(result + checksumKey).digest("hex").toUpperCase();
|
|
105
|
+
if (expected !== checksum) {
|
|
106
|
+
return { valid: false, data: null, error: "Checksum mismatch" };
|
|
107
|
+
}
|
|
108
|
+
try {
|
|
109
|
+
const decoded = JSON.parse(
|
|
110
|
+
Buffer.from(result, "base64").toString("utf-8")
|
|
111
|
+
);
|
|
112
|
+
return { valid: true, data: decoded };
|
|
113
|
+
} catch {
|
|
114
|
+
return { valid: false, data: null, error: "Failed to decode result" };
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// src/core/verify-callback.ts
|
|
119
|
+
function verifyCallback(searchParams, config) {
|
|
120
|
+
const result = searchParams.get("result") || "";
|
|
121
|
+
const checksum = searchParams.get("checksum") || "";
|
|
122
|
+
const verification = verifyChecksum(result, checksum, config.checksumKey);
|
|
123
|
+
if (!verification.valid || !verification.data) {
|
|
124
|
+
return {
|
|
125
|
+
success: false,
|
|
126
|
+
rawParams: {},
|
|
127
|
+
error: verification.error || "Invalid callback payload"
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
const paymentInfo = verification.data;
|
|
131
|
+
const invoiceNo = paymentInfo.invoice_no;
|
|
132
|
+
const orderId = typeof invoiceNo === "string" && invoiceNo.trim().length > 0 ? invoiceNo : typeof invoiceNo === "number" ? String(invoiceNo) : null;
|
|
133
|
+
if (!orderId) {
|
|
134
|
+
return {
|
|
135
|
+
success: false,
|
|
136
|
+
rawParams: paymentInfo,
|
|
137
|
+
error: "Missing invoice_no in callback payload"
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
return {
|
|
141
|
+
success: true,
|
|
142
|
+
orderId,
|
|
143
|
+
amount: Number(paymentInfo.amount ?? 0),
|
|
144
|
+
currency: String(paymentInfo.currency ?? ""),
|
|
145
|
+
status: Number(paymentInfo.status ?? 0),
|
|
146
|
+
rawParams: paymentInfo
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// src/core/ipn.ts
|
|
151
|
+
function extractIpnFromFormData(formData) {
|
|
152
|
+
return {
|
|
153
|
+
result: String(formData.get("result") || ""),
|
|
154
|
+
checksum: String(formData.get("checksum") || "")
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
function extractIpnFromUrlEncoded(body) {
|
|
158
|
+
const params = new URLSearchParams(body);
|
|
159
|
+
return {
|
|
160
|
+
result: params.get("result") || "",
|
|
161
|
+
checksum: params.get("checksum") || ""
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// src/core/ipn-processor.ts
|
|
166
|
+
async function processIpn(payload, options) {
|
|
167
|
+
const { config, orderRepository, notifications } = options;
|
|
168
|
+
const verification = verifyChecksum(
|
|
169
|
+
payload.result,
|
|
170
|
+
payload.checksum,
|
|
171
|
+
config.checksumKey
|
|
172
|
+
);
|
|
173
|
+
if (!verification.valid || !verification.data) {
|
|
174
|
+
return {
|
|
175
|
+
success: false,
|
|
176
|
+
message: verification.error || "Invalid checksum",
|
|
177
|
+
statusCode: 403
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
const paymentInfo = verification.data;
|
|
181
|
+
const invoiceNo = paymentInfo.invoice_no;
|
|
182
|
+
const orderId = typeof invoiceNo === "string" && invoiceNo.trim().length > 0 ? invoiceNo : typeof invoiceNo === "number" ? String(invoiceNo) : null;
|
|
183
|
+
if (!orderId) {
|
|
184
|
+
return {
|
|
185
|
+
success: false,
|
|
186
|
+
message: "Missing invoice_no in IPN payload",
|
|
187
|
+
statusCode: 400
|
|
188
|
+
};
|
|
189
|
+
}
|
|
190
|
+
const isSuccess = Number(paymentInfo.status) === SUCCESS_STATUS;
|
|
191
|
+
const amount = Number(paymentInfo.amount ?? 0);
|
|
192
|
+
const currency = String(paymentInfo.currency ?? "");
|
|
193
|
+
if (isSuccess) {
|
|
194
|
+
try {
|
|
195
|
+
await orderRepository.updateOrderStatus(orderId, "completed");
|
|
196
|
+
} catch (error) {
|
|
197
|
+
return {
|
|
198
|
+
success: false,
|
|
199
|
+
message: `Failed to update order: ${error instanceof Error ? error.message : String(error)}`,
|
|
200
|
+
statusCode: 500,
|
|
201
|
+
orderId
|
|
202
|
+
};
|
|
203
|
+
}
|
|
204
|
+
if (notifications?.onSuccess) {
|
|
205
|
+
try {
|
|
206
|
+
await notifications.onSuccess({
|
|
207
|
+
orderId,
|
|
208
|
+
status: "completed",
|
|
209
|
+
amount,
|
|
210
|
+
currency,
|
|
211
|
+
paymentMethod: "9pay",
|
|
212
|
+
metadata: paymentInfo
|
|
213
|
+
});
|
|
214
|
+
} catch {
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
return {
|
|
218
|
+
success: true,
|
|
219
|
+
message: "Order updated successfully",
|
|
220
|
+
statusCode: 200,
|
|
221
|
+
orderId
|
|
222
|
+
};
|
|
223
|
+
}
|
|
224
|
+
try {
|
|
225
|
+
await orderRepository.updateOrderStatus(orderId, "failed");
|
|
226
|
+
} catch (error) {
|
|
227
|
+
return {
|
|
228
|
+
success: false,
|
|
229
|
+
message: `Failed to update order: ${error instanceof Error ? error.message : String(error)}`,
|
|
230
|
+
statusCode: 500,
|
|
231
|
+
orderId
|
|
232
|
+
};
|
|
233
|
+
}
|
|
234
|
+
if (notifications?.onFailure) {
|
|
235
|
+
try {
|
|
236
|
+
await notifications.onFailure({
|
|
237
|
+
orderId,
|
|
238
|
+
status: "failed",
|
|
239
|
+
amount,
|
|
240
|
+
currency,
|
|
241
|
+
paymentMethod: "9pay",
|
|
242
|
+
metadata: paymentInfo
|
|
243
|
+
});
|
|
244
|
+
} catch {
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
return {
|
|
248
|
+
success: false,
|
|
249
|
+
message: "Payment failed",
|
|
250
|
+
statusCode: 200,
|
|
251
|
+
orderId
|
|
252
|
+
};
|
|
253
|
+
}
|
|
254
|
+
export {
|
|
255
|
+
DEFAULT_BASE_URL,
|
|
256
|
+
NINEPAY_STATUS,
|
|
257
|
+
SUCCESS_STATUS,
|
|
258
|
+
buildHttpQuery,
|
|
259
|
+
buildSignature,
|
|
260
|
+
configFromEnv,
|
|
261
|
+
createPaymentUrl,
|
|
262
|
+
extractIpnFromFormData,
|
|
263
|
+
extractIpnFromUrlEncoded,
|
|
264
|
+
processIpn,
|
|
265
|
+
validateConfig,
|
|
266
|
+
verifyCallback,
|
|
267
|
+
verifyChecksum
|
|
268
|
+
};
|