ipaynow-nodejs-sdk 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 +205 -0
- package/package.json +22 -0
- package/src/client.js +106 -0
- package/src/config.js +87 -0
- package/src/errors.js +36 -0
- package/src/form.js +14 -0
- package/src/index.js +50 -0
- package/src/notify.js +52 -0
- package/src/params.js +366 -0
- package/src/requests.js +269 -0
- package/src/responses.js +128 -0
- package/src/signer.js +110 -0
package/README.md
ADDED
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
# iPayNow Node.js SDK
|
|
2
|
+
|
|
3
|
+
现在支付 Node.js SDK,封装统一下单、订单查询、退款、撤销、回调验签和前端表单提交字段生成。基于原生 ESM 编写,运行时依赖仅使用 Node.js 内置模块(`node:crypto`)与全局 `fetch`。
|
|
4
|
+
|
|
5
|
+
## 环境要求
|
|
6
|
+
|
|
7
|
+
- Node.js 18+(需要全局 `fetch`、`AbortSignal.timeout` 与 `node:test`,建议使用 20+)
|
|
8
|
+
- 本包为 ESM(`package.json` 中 `"type": "module"`),只能使用 `import` 语法引入,不支持 `require`
|
|
9
|
+
|
|
10
|
+
## 安装
|
|
11
|
+
|
|
12
|
+
```bash
|
|
13
|
+
npm install ipaynow-nodejs-sdk
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
## 快速开始
|
|
17
|
+
|
|
18
|
+
以 H5 统一下单为例:
|
|
19
|
+
|
|
20
|
+
```javascript
|
|
21
|
+
import {
|
|
22
|
+
IpaynowClient,
|
|
23
|
+
IpaynowConfig,
|
|
24
|
+
H5UnifiedOrderParams,
|
|
25
|
+
H5UnifiedOrderRequest,
|
|
26
|
+
} from "ipaynow-nodejs-sdk";
|
|
27
|
+
|
|
28
|
+
const config = new IpaynowConfig({
|
|
29
|
+
appId: "appId",
|
|
30
|
+
appKey: "appKey",
|
|
31
|
+
baseUrl: "https://pay.ipaynow.cn",
|
|
32
|
+
});
|
|
33
|
+
const client = new IpaynowClient(config);
|
|
34
|
+
|
|
35
|
+
const params = new H5UnifiedOrderParams({
|
|
36
|
+
mhtOrderNo: "ORDER202607030001",
|
|
37
|
+
mhtOrderName: "测试订单",
|
|
38
|
+
mhtOrderDetail: "测试订单",
|
|
39
|
+
mhtOrderAmt: 1,
|
|
40
|
+
mhtOrderTimeOut: 120,
|
|
41
|
+
mhtOrderStartTime: "20260703120000",
|
|
42
|
+
notifyUrl: "https://example.com/notify",
|
|
43
|
+
frontNotifyUrl: "https://example.com/front",
|
|
44
|
+
payChannelType: "12",
|
|
45
|
+
outputType: "1",
|
|
46
|
+
consumerCreateIp: "127.0.0.1",
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
const response = await client.execute(new H5UnifiedOrderRequest(params));
|
|
50
|
+
if (response.isSuccess()) {
|
|
51
|
+
const tn = response.tn();
|
|
52
|
+
console.log(tn);
|
|
53
|
+
}
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
`Params` 构造函数也接受普通对象字面量(不强制先 `new`),`Request` 构造函数会在收到普通对象时自动包装为对应的 `Params` 实例:
|
|
57
|
+
|
|
58
|
+
```javascript
|
|
59
|
+
const response = await client.execute(
|
|
60
|
+
new H5UnifiedOrderRequest({ mhtOrderNo: "ORDER202607030001", mhtOrderAmt: 1 })
|
|
61
|
+
);
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
## 前端 Form 表单提交
|
|
65
|
+
|
|
66
|
+
需要让浏览器直接向现在支付网关提交表单时,使用 `buildFormFields` 生成完整请求字段。入参与 `execute` 一致,SDK 不发起 HTTP 请求:
|
|
67
|
+
|
|
68
|
+
```javascript
|
|
69
|
+
const fields = client.buildFormFields(new H5UnifiedOrderRequest(params));
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
返回字段包含业务参数、公共参数和签名字段,例如:
|
|
73
|
+
|
|
74
|
+
- `funcode`
|
|
75
|
+
- `version`
|
|
76
|
+
- `appId`
|
|
77
|
+
- `mhtCharset`
|
|
78
|
+
- `mhtSignType` 或 `signType`
|
|
79
|
+
- `mhtSignature`
|
|
80
|
+
|
|
81
|
+
表单提交地址由请求路径决定:
|
|
82
|
+
|
|
83
|
+
```javascript
|
|
84
|
+
const request = new H5UnifiedOrderRequest(params);
|
|
85
|
+
const action = config.endpoint(request.path);
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
## 支持接口
|
|
89
|
+
|
|
90
|
+
| funcode | 操作 | Params | Request | Response |
|
|
91
|
+
| --- | --- | --- | --- | --- |
|
|
92
|
+
| `WP001` (v1.0.0) | JSAPI 统一下单 | `JsapiUnifiedOrderParams` | `JsapiUnifiedOrderRequest` | `JsapiUnifiedOrderResponse` |
|
|
93
|
+
| `WP001` (v1.0.3) | 聚合码统一下单 | `AggregateCodeUnifiedOrderParams` | `AggregateCodeUnifiedOrderRequest` | `AggregateCodeUnifiedOrderResponse` |
|
|
94
|
+
| `WP001` (v1.0.0) | 小程序统一下单 | `MiniProgramUnifiedOrderParams` | `MiniProgramUnifiedOrderRequest` | `MiniProgramUnifiedOrderResponse` |
|
|
95
|
+
| `WP001` (v1.0.0) | H5 统一下单 | `H5UnifiedOrderParams` | `H5UnifiedOrderRequest` | `H5UnifiedOrderResponse` |
|
|
96
|
+
| `WP001` (v1.0.3) | APP 统一下单 | `AppUnifiedOrderParams` | `AppUnifiedOrderRequest` | `AppUnifiedOrderResponse` |
|
|
97
|
+
| `MQ002` | 订单查询 | `OrderQueryParams` | `OrderQueryRequest` | `OrderQueryResponse` |
|
|
98
|
+
| `R001` | 退款 | `RefundParams` | `RefundRequest` | `RefundResponse` |
|
|
99
|
+
| `Q001` | 退款查询 | `RefundQueryParams` | `RefundQueryRequest` | `RefundQueryResponse` |
|
|
100
|
+
| `R002` | 撤销 | `ReverseParams` | `ReverseRequest` | `ReverseResponse` |
|
|
101
|
+
| `Q002` | 撤销查询 | `ReverseQueryParams` | `ReverseQueryRequest` | `ReverseQueryResponse` |
|
|
102
|
+
| `N001` | 支付结果回调 | - | - | `PaymentNotifyResponse`(经 `NotifyParser.parsePayment`) |
|
|
103
|
+
| `RN001` | 退款结果回调 | - | - | `RefundNotifyResponse`(经 `NotifyParser.parseRefund`) |
|
|
104
|
+
|
|
105
|
+
统一下单、订单查询走网关根路径 `/`;退款、退款查询走 `/refund/refundOrder`;撤销、撤销查询走 `/refund/cancelOrder`(撤销查询实际路径为 `/refund/refundOrder`,与源码一致)。所有 `Request` 构造函数第二个参数均可传入版本号字符串或 `{ version }` 覆盖默认版本,例如 `new OrderQueryRequest(params, "1.0.1")`。
|
|
106
|
+
|
|
107
|
+
## 响应判断
|
|
108
|
+
|
|
109
|
+
`isSuccess()` 按操作类型使用不同判定字段(见 `src/responses.js` 真实实现):
|
|
110
|
+
|
|
111
|
+
- 统一下单族(JSAPI/聚合码/小程序/H5/APP):`responseCode === "A001"`(继承自 `IpaynowResponse`)。
|
|
112
|
+
- 订单查询:同样使用 `responseCode === "A001"`。
|
|
113
|
+
- 退款、退款查询、撤销、撤销查询:`responseCode === "R000"`。
|
|
114
|
+
- 支付结果回调(`PaymentNotifyResponse`):`transStatus === "A001"`。
|
|
115
|
+
- 退款结果回调(`RefundNotifyResponse`):`tradeStatus === "A001"`。
|
|
116
|
+
|
|
117
|
+
统一下单响应额外提供便捷访问器:
|
|
118
|
+
|
|
119
|
+
```javascript
|
|
120
|
+
response.tn(); // 支付要素,兼容大写字段 TN
|
|
121
|
+
response.nowPayOrderNo(); // 现在支付流水号
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
订单查询响应提供:`response.transStatus()`、`response.nowPayOrderNo()`、`response.payTime()`、`response.channelOrderNo()`。
|
|
125
|
+
|
|
126
|
+
也可以直接读取原始字段:
|
|
127
|
+
|
|
128
|
+
```javascript
|
|
129
|
+
const code = response.code(); // responseCode
|
|
130
|
+
const message = response.message(); // responseMsg
|
|
131
|
+
const rawBody = response.rawBody(); // 原始报文串
|
|
132
|
+
const fields = response.fields(); // 全部字段的副本
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
## 扩展字段
|
|
136
|
+
|
|
137
|
+
`Params` 构造函数支持 `extraParams`(普通对象),用于传递 SDK 尚未显式建模的业务字段:
|
|
138
|
+
|
|
139
|
+
```javascript
|
|
140
|
+
const params = new H5UnifiedOrderParams({
|
|
141
|
+
mhtOrderNo: "ORDER202607030001",
|
|
142
|
+
mhtOrderAmt: 1,
|
|
143
|
+
extraParams: { mhtGoodsTag: "TAG001" },
|
|
144
|
+
});
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
`extraParams` 中的字段与命名字段合并时,`extraParams` 后写覆盖前写(与已建模字段同名时以 `extraParams` 为准)。SDK 只在客户端组装阶段(`IpaynowClient#signedFields`)以固定顺序追加公共字段和签名字段,因此业务参数无法覆盖以下保留字段:
|
|
148
|
+
|
|
149
|
+
- `funcode`
|
|
150
|
+
- `version`
|
|
151
|
+
- `appId`
|
|
152
|
+
- `mhtCharset`
|
|
153
|
+
- `mhtSignType`
|
|
154
|
+
- `signType`
|
|
155
|
+
- `mhtSignature`
|
|
156
|
+
- `signature`
|
|
157
|
+
|
|
158
|
+
## 回调验签
|
|
159
|
+
|
|
160
|
+
```javascript
|
|
161
|
+
import { NotifyParser, NotifyAck } from "ipaynow-nodejs-sdk";
|
|
162
|
+
|
|
163
|
+
const notify = NotifyParser.parsePayment(requestBody, appKey);
|
|
164
|
+
if (notify.isSuccess()) {
|
|
165
|
+
// 处理支付成功业务逻辑
|
|
166
|
+
}
|
|
167
|
+
// 按现在支付协议要求同步返回 success=Y / success=N
|
|
168
|
+
const ackBody = NotifyAck.success(); // 或 NotifyAck.fail()
|
|
169
|
+
```
|
|
170
|
+
|
|
171
|
+
退款回调:
|
|
172
|
+
|
|
173
|
+
```javascript
|
|
174
|
+
const notify = NotifyParser.parseRefund(requestBody, appKey);
|
|
175
|
+
```
|
|
176
|
+
|
|
177
|
+
两者验签失败均抛出 `IpaynowError`(`codeName: "SIGN_ERROR"`)。
|
|
178
|
+
|
|
179
|
+
## 错误处理
|
|
180
|
+
|
|
181
|
+
SDK 只抛出一种异常类型 `IpaynowError`,通过 `code` / `codeName` 属性区分具体失败原因:
|
|
182
|
+
|
|
183
|
+
```javascript
|
|
184
|
+
import { IpaynowError } from "ipaynow-nodejs-sdk";
|
|
185
|
+
|
|
186
|
+
try {
|
|
187
|
+
await client.execute(request);
|
|
188
|
+
} catch (err) {
|
|
189
|
+
if (err instanceof IpaynowError) {
|
|
190
|
+
console.error(err.codeName, err.code, err.message);
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
```
|
|
194
|
+
|
|
195
|
+
错误码表(`src/errors.js`):
|
|
196
|
+
|
|
197
|
+
| codeName | code | 默认信息 |
|
|
198
|
+
| --- | --- | --- |
|
|
199
|
+
| `SYSTEM_ERROR` | `E0001` | 组件内部异常 |
|
|
200
|
+
| `HTTP_EXCEPTION` | `E0002` | HTTP异常 |
|
|
201
|
+
| `CONNECT_TIMEOUT` | `E0003` | ConnectTimeOut |
|
|
202
|
+
| `SOCKET_TIMEOUT` | `E0004` | SocketTimeOut |
|
|
203
|
+
| `SIGN_ERROR` | `E0012` | 渠道报文验签失败 |
|
|
204
|
+
|
|
205
|
+
注意:默认传输基于全局 `fetch`,无法区分“建连超时”与“读取超时”,超时统一映射为 `SOCKET_TIMEOUT`;`connectTimeoutMillis + readTimeoutMillis` 之和作为整体请求超时预算传给 `AbortSignal.timeout`。
|
package/package.json
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "ipaynow-nodejs-sdk",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "现在支付(iPayNow)Node.js SDK:统一下单、订单查询、退款、撤销、回调验签。",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "src/index.js",
|
|
7
|
+
"files": [
|
|
8
|
+
"src/",
|
|
9
|
+
"README.md"
|
|
10
|
+
],
|
|
11
|
+
"scripts": {
|
|
12
|
+
"test": "node --test"
|
|
13
|
+
},
|
|
14
|
+
"author": "iPayNow",
|
|
15
|
+
"license": "UNLICENSED",
|
|
16
|
+
"homepage": "https://www.ipaynow.cn",
|
|
17
|
+
"repository": {
|
|
18
|
+
"type": "git",
|
|
19
|
+
"url": "git+https://github.com/ipaynow/ipaynow-sdk.git",
|
|
20
|
+
"directory": "ipaynow-nodejs-sdk"
|
|
21
|
+
}
|
|
22
|
+
}
|
package/src/client.js
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import { sign, verify, clean } from "./signer.js";
|
|
2
|
+
import { parseResponse } from "./form.js";
|
|
3
|
+
import { IpaynowError } from "./errors.js";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* 基于全局 fetch 的默认传输实现。
|
|
7
|
+
* fetch 无法区分“建连超时”与“读取超时”(AbortError/TimeoutError 一概抛出),
|
|
8
|
+
* 因此超时统一映射为 SOCKET_TIMEOUT,并在注释中说明这一限制。
|
|
9
|
+
* @param {import("./config.js").IpaynowConfig} config
|
|
10
|
+
* @returns {(url: string, params: Record<string, string>) => Promise<string>}
|
|
11
|
+
*/
|
|
12
|
+
function createFetchTransport(config) {
|
|
13
|
+
return async function transport(url, params) {
|
|
14
|
+
const body = new URLSearchParams(params);
|
|
15
|
+
const funcode = params.funcode;
|
|
16
|
+
let response;
|
|
17
|
+
try {
|
|
18
|
+
response = await fetch(url, {
|
|
19
|
+
method: "POST",
|
|
20
|
+
headers: { "content-type": "application/x-www-form-urlencoded" },
|
|
21
|
+
body,
|
|
22
|
+
// fetch 没有独立的建连/读取超时旋钮,用两者之和近似整体请求预算。
|
|
23
|
+
signal: AbortSignal.timeout(config.connectTimeoutMillis + config.readTimeoutMillis),
|
|
24
|
+
});
|
|
25
|
+
} catch (err) {
|
|
26
|
+
if (err.name === "TimeoutError" || err.name === "AbortError") {
|
|
27
|
+
throw new IpaynowError("SOCKET_TIMEOUT", `现在支付请求超时,功能码=${funcode}`, { cause: err });
|
|
28
|
+
}
|
|
29
|
+
throw new IpaynowError("HTTP_EXCEPTION", `现在支付请求异常,功能码=${funcode}`, { cause: err });
|
|
30
|
+
}
|
|
31
|
+
const text = await response.text();
|
|
32
|
+
if (!response.ok) {
|
|
33
|
+
throw new IpaynowError(
|
|
34
|
+
"HTTP_EXCEPTION",
|
|
35
|
+
`现在支付HTTP请求失败,状态码=${response.status},功能码=${funcode},响应报文=${text}`
|
|
36
|
+
);
|
|
37
|
+
}
|
|
38
|
+
return text;
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* iPayNow 客户端:组装公共字段 → 签名 → POST 表单 → 解析响应 → 验签 → 返回类型化 Response。
|
|
44
|
+
*/
|
|
45
|
+
export class IpaynowClient {
|
|
46
|
+
/**
|
|
47
|
+
* @param {import("./config.js").IpaynowConfig} config
|
|
48
|
+
* @param {(url: string, params: Record<string, string>) => Promise<string>} [transport] 可注入的传输实现,便于测试
|
|
49
|
+
*/
|
|
50
|
+
constructor(config, transport) {
|
|
51
|
+
this.config = config;
|
|
52
|
+
this.transport = transport ?? createFetchTransport(config);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* @param {import("./requests.js").IpaynowRequest} request
|
|
57
|
+
* @returns {Promise<import("./responses.js").IpaynowResponse>}
|
|
58
|
+
*/
|
|
59
|
+
async execute(request) {
|
|
60
|
+
const funcode = request.funcode;
|
|
61
|
+
const fields = this.signedFields(request);
|
|
62
|
+
|
|
63
|
+
const responseText = await this.transport(this.config.endpoint(request.path), fields);
|
|
64
|
+
const responseFields = parseResponse(responseText);
|
|
65
|
+
|
|
66
|
+
if (!verify(responseFields, this.config, request.responseSignatureField, request.excludeSignType)) {
|
|
67
|
+
throw new IpaynowError("SIGN_ERROR", `现在支付响应验签失败,功能码=${funcode}`);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const response = new request.responseClass();
|
|
71
|
+
response.load(responseFields, responseText);
|
|
72
|
+
return response;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* 返回签名后的完整表单字段(供前端跳转表单场景),不发请求。
|
|
77
|
+
* @param {import("./requests.js").IpaynowRequest} request
|
|
78
|
+
* @returns {Record<string, string>}
|
|
79
|
+
*/
|
|
80
|
+
buildFormFields(request) {
|
|
81
|
+
return this.signedFields(request);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* @param {import("./requests.js").IpaynowRequest} request
|
|
86
|
+
* @returns {Record<string, string>}
|
|
87
|
+
*/
|
|
88
|
+
signedFields(request) {
|
|
89
|
+
const fields = {
|
|
90
|
+
...request.toFields(),
|
|
91
|
+
funcode: request.funcode,
|
|
92
|
+
version: request.version,
|
|
93
|
+
appId: this.config.appId,
|
|
94
|
+
mhtCharset: request.mhtCharset,
|
|
95
|
+
};
|
|
96
|
+
fields[request.signTypeField] = this.config.signType;
|
|
97
|
+
fields[request.signatureField] = sign(
|
|
98
|
+
fields,
|
|
99
|
+
this.config,
|
|
100
|
+
request.excludeSignType,
|
|
101
|
+
request.signatureField,
|
|
102
|
+
request.responseSignatureField
|
|
103
|
+
);
|
|
104
|
+
return clean(fields);
|
|
105
|
+
}
|
|
106
|
+
}
|
package/src/config.js
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import crypto from "node:crypto";
|
|
2
|
+
|
|
3
|
+
function requireNonBlank(value, name) {
|
|
4
|
+
if (typeof value !== "string" || value.trim() === "") {
|
|
5
|
+
throw new TypeError(`${name} 不能为空`);
|
|
6
|
+
}
|
|
7
|
+
return value;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export const SignType = Object.freeze({
|
|
11
|
+
MD5: "MD5",
|
|
12
|
+
RSA: "RSA",
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
function decodeBase64(value, name) {
|
|
16
|
+
if (value.includes("-----BEGIN") || value.includes("-----END")) {
|
|
17
|
+
throw new TypeError(`${name} 仅支持裸 base64,不支持 PEM`);
|
|
18
|
+
}
|
|
19
|
+
try {
|
|
20
|
+
return Buffer.from(value.replace(/\s+/g, ""), "base64");
|
|
21
|
+
} catch (error) {
|
|
22
|
+
throw new TypeError(`${name} 非法,需传裸 base64`, { cause: error });
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* iPayNow SDK 配置:appId / appKey / baseUrl / 超时时间。
|
|
28
|
+
*/
|
|
29
|
+
export class IpaynowConfig {
|
|
30
|
+
/**
|
|
31
|
+
* @param {{appId: string, appKey?: string, signType?: string, mchPrivateKey?: string, ipaynowPublicKey?: string, baseUrl?: string, connectTimeoutMillis?: number, readTimeoutMillis?: number}} options
|
|
32
|
+
*/
|
|
33
|
+
constructor({
|
|
34
|
+
appId,
|
|
35
|
+
appKey,
|
|
36
|
+
signType = SignType.MD5,
|
|
37
|
+
mchPrivateKey,
|
|
38
|
+
ipaynowPublicKey,
|
|
39
|
+
baseUrl = "https://pay.ipaynow.cn",
|
|
40
|
+
connectTimeoutMillis = 5000,
|
|
41
|
+
readTimeoutMillis = 15000,
|
|
42
|
+
} = {}) {
|
|
43
|
+
this.appId = requireNonBlank(appId, "appId");
|
|
44
|
+
this.signType = signType ?? SignType.MD5;
|
|
45
|
+
if (this.signType === SignType.MD5) {
|
|
46
|
+
this.appKey = requireNonBlank(appKey, "appKey");
|
|
47
|
+
this.mchPrivateKey = null;
|
|
48
|
+
this.ipaynowPublicKey = null;
|
|
49
|
+
this.mchPrivateKeyObject = null;
|
|
50
|
+
this.ipaynowPublicKeyObject = null;
|
|
51
|
+
} else if (this.signType === SignType.RSA) {
|
|
52
|
+
this.appKey = null;
|
|
53
|
+
this.mchPrivateKey = requireNonBlank(mchPrivateKey, "mchPrivateKey");
|
|
54
|
+
this.ipaynowPublicKey = requireNonBlank(ipaynowPublicKey, "ipaynowPublicKey");
|
|
55
|
+
this.mchPrivateKeyObject = crypto.createPrivateKey({
|
|
56
|
+
key: decodeBase64(this.mchPrivateKey, "mchPrivateKey"),
|
|
57
|
+
type: "pkcs8",
|
|
58
|
+
format: "der",
|
|
59
|
+
});
|
|
60
|
+
this.ipaynowPublicKeyObject = crypto.createPublicKey({
|
|
61
|
+
key: decodeBase64(this.ipaynowPublicKey, "ipaynowPublicKey"),
|
|
62
|
+
type: "spki",
|
|
63
|
+
format: "der",
|
|
64
|
+
});
|
|
65
|
+
} else {
|
|
66
|
+
throw new TypeError(`不支持的签名类型: ${this.signType}`);
|
|
67
|
+
}
|
|
68
|
+
requireNonBlank(baseUrl, "baseUrl");
|
|
69
|
+
if (!(connectTimeoutMillis > 0) || !(readTimeoutMillis > 0)) {
|
|
70
|
+
throw new TypeError(
|
|
71
|
+
`超时时间必须大于 0:connectTimeoutMillis=${connectTimeoutMillis},readTimeoutMillis=${readTimeoutMillis}`
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
this.baseUrl = baseUrl.replace(/\/+$/, "");
|
|
75
|
+
this.connectTimeoutMillis = connectTimeoutMillis;
|
|
76
|
+
this.readTimeoutMillis = readTimeoutMillis;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* @param {string} path
|
|
81
|
+
* @returns {string}
|
|
82
|
+
*/
|
|
83
|
+
endpoint(path) {
|
|
84
|
+
const normalizedPath = !path ? "/" : path.startsWith("/") ? path : `/${path}`;
|
|
85
|
+
return `${this.baseUrl}${normalizedPath}`;
|
|
86
|
+
}
|
|
87
|
+
}
|
package/src/errors.js
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* iPayNow SDK 统一错误码。调用方只捕获 IpaynowError 一种异常类型,
|
|
3
|
+
* 通过 `code`/`codeName` 属性区分具体失败原因。
|
|
4
|
+
*/
|
|
5
|
+
export const IpaynowErrorCode = Object.freeze({
|
|
6
|
+
SYSTEM_ERROR: "E0001",
|
|
7
|
+
HTTP_EXCEPTION: "E0002",
|
|
8
|
+
CONNECT_TIMEOUT: "E0003",
|
|
9
|
+
SOCKET_TIMEOUT: "E0004",
|
|
10
|
+
SIGN_ERROR: "E0012",
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
const DEFAULT_MESSAGES = Object.freeze({
|
|
14
|
+
SYSTEM_ERROR: "组件内部异常",
|
|
15
|
+
HTTP_EXCEPTION: "HTTP异常",
|
|
16
|
+
CONNECT_TIMEOUT: "ConnectTimeOut",
|
|
17
|
+
SOCKET_TIMEOUT: "SocketTimeOut",
|
|
18
|
+
SIGN_ERROR: "渠道报文验签失败",
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* iPayNow SDK 统一异常。
|
|
23
|
+
*/
|
|
24
|
+
export class IpaynowError extends Error {
|
|
25
|
+
/**
|
|
26
|
+
* @param {keyof typeof IpaynowErrorCode} codeName
|
|
27
|
+
* @param {string} [message]
|
|
28
|
+
* @param {{cause?: unknown}} [options]
|
|
29
|
+
*/
|
|
30
|
+
constructor(codeName, message, options) {
|
|
31
|
+
super(message ?? DEFAULT_MESSAGES[codeName], options);
|
|
32
|
+
this.name = "IpaynowError";
|
|
33
|
+
this.codeName = codeName;
|
|
34
|
+
this.code = IpaynowErrorCode[codeName];
|
|
35
|
+
}
|
|
36
|
+
}
|
package/src/form.js
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 解析 `k=v&k2=v2` 表单串为字段表(值已 URL-decode)。
|
|
3
|
+
* @param {string} text
|
|
4
|
+
* @returns {Record<string, string>}
|
|
5
|
+
*/
|
|
6
|
+
export function parseResponse(text) {
|
|
7
|
+
const trimmed = typeof text === "string" ? text.trim() : "";
|
|
8
|
+
if (!trimmed) return {};
|
|
9
|
+
const out = {};
|
|
10
|
+
for (const [key, value] of new URLSearchParams(trimmed)) {
|
|
11
|
+
out[key] = value;
|
|
12
|
+
}
|
|
13
|
+
return out;
|
|
14
|
+
}
|
package/src/index.js
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
export { IpaynowError, IpaynowErrorCode } from "./errors.js";
|
|
2
|
+
export { IpaynowConfig, SignType } from "./config.js";
|
|
3
|
+
export { sign, verify, clean, md5Hex } from "./signer.js";
|
|
4
|
+
export { parseResponse } from "./form.js";
|
|
5
|
+
export { IpaynowClient } from "./client.js";
|
|
6
|
+
export { NotifyParser, NotifyAck } from "./notify.js";
|
|
7
|
+
|
|
8
|
+
export {
|
|
9
|
+
JsapiUnifiedOrderParams,
|
|
10
|
+
AggregateCodeUnifiedOrderParams,
|
|
11
|
+
MiniProgramUnifiedOrderParams,
|
|
12
|
+
H5UnifiedOrderParams,
|
|
13
|
+
AppUnifiedOrderParams,
|
|
14
|
+
OrderQueryParams,
|
|
15
|
+
RefundParams,
|
|
16
|
+
RefundQueryParams,
|
|
17
|
+
ReverseParams,
|
|
18
|
+
ReverseQueryParams,
|
|
19
|
+
} from "./params.js";
|
|
20
|
+
|
|
21
|
+
export {
|
|
22
|
+
IpaynowRequest,
|
|
23
|
+
JsapiUnifiedOrderRequest,
|
|
24
|
+
AggregateCodeUnifiedOrderRequest,
|
|
25
|
+
MiniProgramUnifiedOrderRequest,
|
|
26
|
+
H5UnifiedOrderRequest,
|
|
27
|
+
AppUnifiedOrderRequest,
|
|
28
|
+
OrderQueryRequest,
|
|
29
|
+
RefundRequest,
|
|
30
|
+
RefundQueryRequest,
|
|
31
|
+
ReverseRequest,
|
|
32
|
+
ReverseQueryRequest,
|
|
33
|
+
} from "./requests.js";
|
|
34
|
+
|
|
35
|
+
export {
|
|
36
|
+
IpaynowResponse,
|
|
37
|
+
UnifiedOrderResponse,
|
|
38
|
+
JsapiUnifiedOrderResponse,
|
|
39
|
+
AggregateCodeUnifiedOrderResponse,
|
|
40
|
+
MiniProgramUnifiedOrderResponse,
|
|
41
|
+
H5UnifiedOrderResponse,
|
|
42
|
+
AppUnifiedOrderResponse,
|
|
43
|
+
OrderQueryResponse,
|
|
44
|
+
RefundResponse,
|
|
45
|
+
RefundQueryResponse,
|
|
46
|
+
ReverseResponse,
|
|
47
|
+
ReverseQueryResponse,
|
|
48
|
+
PaymentNotifyResponse,
|
|
49
|
+
RefundNotifyResponse,
|
|
50
|
+
} from "./responses.js";
|
package/src/notify.js
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { parseResponse } from "./form.js";
|
|
2
|
+
import { verify } from "./signer.js";
|
|
3
|
+
import { IpaynowError } from "./errors.js";
|
|
4
|
+
import { PaymentNotifyResponse, RefundNotifyResponse } from "./responses.js";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* @param {string} body
|
|
8
|
+
* @param {import("./config.js").IpaynowConfig} config
|
|
9
|
+
* @param {boolean} excludeSignType
|
|
10
|
+
* @param {new () => import("./responses.js").IpaynowResponse} ResponseClass
|
|
11
|
+
*/
|
|
12
|
+
function parse(body, config, excludeSignType, ResponseClass) {
|
|
13
|
+
const fields = parseResponse(body);
|
|
14
|
+
if (!verify(fields, config, "signature", excludeSignType)) {
|
|
15
|
+
throw new IpaynowError("SIGN_ERROR", "现在支付回调通知验签失败");
|
|
16
|
+
}
|
|
17
|
+
const response = new ResponseClass();
|
|
18
|
+
response.load(fields, body);
|
|
19
|
+
return response;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export const NotifyParser = Object.freeze({
|
|
23
|
+
/**
|
|
24
|
+
* N001 支付回调,signType 参与签名。
|
|
25
|
+
* @param {string} body
|
|
26
|
+
* @param {import("./config.js").IpaynowConfig} config
|
|
27
|
+
* @returns {PaymentNotifyResponse}
|
|
28
|
+
*/
|
|
29
|
+
parsePayment(body, config) {
|
|
30
|
+
return parse(body, config, false, PaymentNotifyResponse);
|
|
31
|
+
},
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* RN001 退款回调,signType 不参与签名。
|
|
35
|
+
* @param {string} body
|
|
36
|
+
* @param {import("./config.js").IpaynowConfig} config
|
|
37
|
+
* @returns {RefundNotifyResponse}
|
|
38
|
+
*/
|
|
39
|
+
parseRefund(body, config) {
|
|
40
|
+
return parse(body, config, true, RefundNotifyResponse);
|
|
41
|
+
},
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
/** 回调同步应答体 */
|
|
45
|
+
export const NotifyAck = Object.freeze({
|
|
46
|
+
success() {
|
|
47
|
+
return "success=Y";
|
|
48
|
+
},
|
|
49
|
+
fail() {
|
|
50
|
+
return "success=N";
|
|
51
|
+
},
|
|
52
|
+
});
|