@vobs/payment 0.1.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/LICENSE +21 -0
- package/dist/alipay-official.d.ts +34 -0
- package/dist/alipay-official.js +195 -0
- package/dist/handlers.d.ts +33 -0
- package/dist/handlers.js +95 -0
- package/dist/index.d.ts +35 -0
- package/dist/index.js +35 -0
- package/dist/jeepay.d.ts +39 -0
- package/dist/jeepay.js +205 -0
- package/dist/lakala.d.ts +41 -0
- package/dist/lakala.js +242 -0
- package/dist/manager.d.ts +63 -0
- package/dist/manager.js +399 -0
- package/dist/mock-driver.d.ts +22 -0
- package/dist/mock-driver.js +58 -0
- package/dist/state-machine.d.ts +41 -0
- package/dist/state-machine.js +60 -0
- package/dist/store.d.ts +23 -0
- package/dist/store.js +44 -0
- package/dist/types.d.ts +157 -0
- package/dist/types.js +11 -0
- package/dist/verify.d.ts +34 -0
- package/dist/verify.js +55 -0
- package/dist/wechat-official.d.ts +40 -0
- package/dist/wechat-official.js +207 -0
- package/package.json +41 -0
package/dist/jeepay.js
ADDED
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
/** @license MIT
|
|
2
|
+
* Copyright (c) 2026 vobsjs
|
|
3
|
+
* @vobs/payment
|
|
4
|
+
*/
|
|
5
|
+
/**
|
|
6
|
+
* Jeepay (Jiquan Payment) aggregator driver — open-source aggregator payment system (LGPL-3.0).
|
|
7
|
+
*
|
|
8
|
+
* Integration shape: merchant business system → Jeepay payment gateway (self-hosted or official cloud pay.jeepay.vip).
|
|
9
|
+
* Docs: docs.jeequan.com/docs/jeepay/payment_api (refer to the latest official docs for field details).
|
|
10
|
+
*
|
|
11
|
+
* Scene mapping (wayCode → OrderInit):
|
|
12
|
+
* - `pc` (no openid) → `WX_NATIVE` / `ALI_QR` → payDataType=codeUrl → `{kind:'qr'}`
|
|
13
|
+
* - `h5` + openid (official account) → `WX_JSAPI` → payDataType=wxpay → `{kind:'jsapi'}`
|
|
14
|
+
* - `h5` (no openid) → `ALI_WAP` / `WX_H5` → payDataType=payUrl → `{kind:'redirect'}`
|
|
15
|
+
* - `miniapp` (webview) → `WX_LITE` → payDataType=wxpay → `{kind:'jsapi'}`
|
|
16
|
+
* - extras.wayCode can be set explicitly (the business decides WeChat/Alipay channel)
|
|
17
|
+
*
|
|
18
|
+
* Security:
|
|
19
|
+
* - Request/callback signing: MD5 (non-empty params in ASCII lexicographic order + &key=appSecret → uppercase MD5)
|
|
20
|
+
* - Callbacks use the channel self-verification path; Jeepay notifications have no unique ID,
|
|
21
|
+
* so anti-replay relies on signature verification + HTTPS + state-machine idempotency
|
|
22
|
+
* (duplicate notifications for an already-paid order return success without re-booking)
|
|
23
|
+
*
|
|
24
|
+
* Dependencies: node:crypto (MD5). Zero third-party.
|
|
25
|
+
*/
|
|
26
|
+
import { createHash } from 'node:crypto';
|
|
27
|
+
/** Primitive value → string (params only allow primitives; objects/functions excluded). */
|
|
28
|
+
function primitiveString(v) {
|
|
29
|
+
return String(v);
|
|
30
|
+
}
|
|
31
|
+
/** Parse form-encoded / object payloads → string param table. */
|
|
32
|
+
function toParams(payload) {
|
|
33
|
+
const out = {};
|
|
34
|
+
if (typeof payload === 'string') {
|
|
35
|
+
for (const pair of payload.split('&')) {
|
|
36
|
+
const eq = pair.indexOf('=');
|
|
37
|
+
if (eq > 0) {
|
|
38
|
+
const k = decodeURIComponent(pair.slice(0, eq));
|
|
39
|
+
const v = decodeURIComponent(pair.slice(eq + 1));
|
|
40
|
+
out[k] = v;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
else if (payload && typeof payload === 'object') {
|
|
45
|
+
for (const [k, v] of Object.entries(payload)) {
|
|
46
|
+
if (v !== undefined && v !== null) {
|
|
47
|
+
if (typeof v === 'object' || typeof v === 'function') {
|
|
48
|
+
out[k] = JSON.stringify(v) ?? '';
|
|
49
|
+
}
|
|
50
|
+
else {
|
|
51
|
+
// primitive branch: safe stringify (TS typeof narrowing on unknown leaves {}; the assert excludes it)
|
|
52
|
+
out[k] = primitiveString(v);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
return out;
|
|
58
|
+
}
|
|
59
|
+
export function createJeepayDriver(_store, options) {
|
|
60
|
+
const fetchImpl = options.fetchImpl ?? ((...args) => fetch(...args));
|
|
61
|
+
/** MD5 sign: non-empty params (excluding sign) ASCII-sorted + &key=appSecret → MD5 → uppercase. */
|
|
62
|
+
function md5Sign(params) {
|
|
63
|
+
const str = Object.keys(params)
|
|
64
|
+
.filter((k) => k !== 'sign' && params[k] !== undefined && params[k] !== null && params[k] !== '')
|
|
65
|
+
.sort()
|
|
66
|
+
.map((k) => `${k}=${params[k]}`)
|
|
67
|
+
.join('&');
|
|
68
|
+
return createHash('md5').update(`${str}&key=${options.appSecret}`).digest('hex').toUpperCase();
|
|
69
|
+
}
|
|
70
|
+
function md5Verify(params) {
|
|
71
|
+
const sign = params['sign'];
|
|
72
|
+
if (!sign)
|
|
73
|
+
return false;
|
|
74
|
+
return md5Sign(params) === sign.toUpperCase();
|
|
75
|
+
}
|
|
76
|
+
/** Scene → wayCode (extras.wayCode wins when set). */
|
|
77
|
+
function resolveWayCode(input) {
|
|
78
|
+
const explicit = input.extras?.['wayCode'];
|
|
79
|
+
if (typeof explicit === 'string' && explicit)
|
|
80
|
+
return explicit;
|
|
81
|
+
const openid = input.extras?.['openid'];
|
|
82
|
+
if (input.scene === 'pc')
|
|
83
|
+
return 'WX_NATIVE';
|
|
84
|
+
if (input.scene === 'miniapp')
|
|
85
|
+
return 'WX_LITE';
|
|
86
|
+
if (typeof openid === 'string')
|
|
87
|
+
return 'WX_JSAPI';
|
|
88
|
+
return 'ALI_WAP';
|
|
89
|
+
}
|
|
90
|
+
/** Channel extra params (openid, etc.) → JSON string. */
|
|
91
|
+
function channelExtraOf(input) {
|
|
92
|
+
const extras = (input.extras ?? {});
|
|
93
|
+
const openid = extras['openid'];
|
|
94
|
+
if (typeof openid === 'string') {
|
|
95
|
+
return JSON.stringify({ openid });
|
|
96
|
+
}
|
|
97
|
+
return undefined;
|
|
98
|
+
}
|
|
99
|
+
async function createOrder(input) {
|
|
100
|
+
const params = {
|
|
101
|
+
mchNo: options.mchNo,
|
|
102
|
+
appId: options.appId,
|
|
103
|
+
mchOrderNo: input.orderId,
|
|
104
|
+
wayCode: resolveWayCode(input),
|
|
105
|
+
amount: String(input.amount),
|
|
106
|
+
currency: 'cny',
|
|
107
|
+
subject: input.subject,
|
|
108
|
+
body: input.subject,
|
|
109
|
+
notifyUrl: input.notifyUrl,
|
|
110
|
+
};
|
|
111
|
+
const clientIp = input.extras?.['clientIp'];
|
|
112
|
+
if (typeof clientIp === 'string' && clientIp)
|
|
113
|
+
params['clientIp'] = clientIp;
|
|
114
|
+
const channelExtra = channelExtraOf(input);
|
|
115
|
+
if (channelExtra)
|
|
116
|
+
params['channelExtra'] = channelExtra;
|
|
117
|
+
params['sign'] = md5Sign(params);
|
|
118
|
+
const res = await fetchImpl(`${options.baseUrl}/api/pay/unifiedOrder`, {
|
|
119
|
+
method: 'POST',
|
|
120
|
+
headers: { 'Content-Type': 'application/json' },
|
|
121
|
+
body: JSON.stringify(params),
|
|
122
|
+
});
|
|
123
|
+
const body = (await res.json());
|
|
124
|
+
if (body.code !== 0 || !body.data) {
|
|
125
|
+
throw new Error(`jeepay_error:${body.code ?? 'unknown'}:${body.msg ?? ''}`);
|
|
126
|
+
}
|
|
127
|
+
const data = body.data;
|
|
128
|
+
const payData = data.payData ?? '';
|
|
129
|
+
if (data.payDataType === 'codeUrl') {
|
|
130
|
+
return {
|
|
131
|
+
kind: 'qr',
|
|
132
|
+
qrUrl: payData,
|
|
133
|
+
expiresAt: new Date(Date.now() + 5 * 60 * 1000).toISOString(),
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
if (data.payDataType === 'payUrl') {
|
|
137
|
+
return { kind: 'redirect', payUrl: payData };
|
|
138
|
+
}
|
|
139
|
+
if (data.payDataType === 'wxpay') {
|
|
140
|
+
return { kind: 'jsapi', payParams: JSON.parse(payData) };
|
|
141
|
+
}
|
|
142
|
+
throw new Error(`unsupported_pay_data_type:${data.payDataType ?? 'unknown'}`);
|
|
143
|
+
}
|
|
144
|
+
function verifyCallback(payload, _headers) {
|
|
145
|
+
const params = toParams(payload);
|
|
146
|
+
if (!md5Verify(params)) {
|
|
147
|
+
return { ok: false, reason: 'bad_signature' };
|
|
148
|
+
}
|
|
149
|
+
// state: 2 = success (0 unpaid / 1 paying / 2 success / 3 closed)
|
|
150
|
+
const state = Number(params['state'] ?? params['orderState']);
|
|
151
|
+
if (state !== 2) {
|
|
152
|
+
return { ok: false, reason: `state:${state}` };
|
|
153
|
+
}
|
|
154
|
+
const orderId = params['mchOrderNo'];
|
|
155
|
+
const amount = params['amount'];
|
|
156
|
+
if (!orderId || amount === undefined) {
|
|
157
|
+
return { ok: false, reason: 'bad_payload' };
|
|
158
|
+
}
|
|
159
|
+
return {
|
|
160
|
+
ok: true,
|
|
161
|
+
orderId,
|
|
162
|
+
paidAmount: Number(amount),
|
|
163
|
+
...(params['payOrderId'] ? { channelTxnId: params['payOrderId'] } : {}),
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
async function queryOrder(orderId) {
|
|
167
|
+
try {
|
|
168
|
+
const params = {
|
|
169
|
+
mchNo: options.mchNo,
|
|
170
|
+
appId: options.appId,
|
|
171
|
+
mchOrderNo: orderId,
|
|
172
|
+
};
|
|
173
|
+
params['sign'] = md5Sign(params);
|
|
174
|
+
const res = await fetchImpl(`${options.baseUrl}/api/pay/query`, {
|
|
175
|
+
method: 'POST',
|
|
176
|
+
headers: { 'Content-Type': 'application/json' },
|
|
177
|
+
body: JSON.stringify(params),
|
|
178
|
+
});
|
|
179
|
+
const body = (await res.json());
|
|
180
|
+
if (body.code !== 0 || !body.data) {
|
|
181
|
+
return { state: 'unknown', raw: body };
|
|
182
|
+
}
|
|
183
|
+
// orderState: 0 unpaid / 1 paying / 2 success / 3 closed
|
|
184
|
+
const state = body.data.orderState;
|
|
185
|
+
if (state === 2) {
|
|
186
|
+
return { state: 'paid' };
|
|
187
|
+
}
|
|
188
|
+
if (state === 3) {
|
|
189
|
+
return { state: 'closed' };
|
|
190
|
+
}
|
|
191
|
+
return { state: 'created' };
|
|
192
|
+
}
|
|
193
|
+
catch {
|
|
194
|
+
return { state: 'unknown', raw: { error: 'query_failed' } };
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
return {
|
|
198
|
+
name: 'jeepay',
|
|
199
|
+
scenes: ['pc', 'h5', 'miniapp'],
|
|
200
|
+
createOrder,
|
|
201
|
+
verifyCallback,
|
|
202
|
+
queryOrder,
|
|
203
|
+
successResponseText: 'SUCCESS',
|
|
204
|
+
};
|
|
205
|
+
}
|
package/dist/lakala.d.ts
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/** @license MIT
|
|
2
|
+
* Copyright (c) 2026 vobsjs
|
|
3
|
+
* @vobs/payment
|
|
4
|
+
*/
|
|
5
|
+
/**
|
|
6
|
+
* Lakala aggregator payment driver (licensed aggregator; main-scan / JSAPI / miniapp).
|
|
7
|
+
*
|
|
8
|
+
* Docs: o.lakala.com (aggregator main-scan /api/v3/labs/trans/preorder).
|
|
9
|
+
*
|
|
10
|
+
* Scene mapping (trans_type + account_type):
|
|
11
|
+
* - `pc` → trans_type=41 NATIVE (main scan), account_type from extras.accountType
|
|
12
|
+
* (WECHAT/ALIPAY/UQRCODEPAY, default WECHAT) → `{kind:'qr'}`
|
|
13
|
+
* - `h5` + openid → trans_type=51 JSAPI (WeChat official account, acc_busi_fields.openid) → `{kind:'jsapi'}`
|
|
14
|
+
* - `miniapp` → trans_type=71 WeChat miniapp (acc_busi_fields.openid) → `{kind:'jsapi'}`
|
|
15
|
+
* - `h5` no openid → **unsupported** (main scan has no WAP form; aggregator cashier product API awaits
|
|
16
|
+
* official confirmation, P2 extension)
|
|
17
|
+
*
|
|
18
|
+
* Security:
|
|
19
|
+
* - Request/callback signing: merchant private key RSA-SHA256 (params minus sign, sorted, → base64); responses/callbacks verified with the Lakala public key
|
|
20
|
+
* - Callbacks use the channel self-verification path; research confirms the callback expects a `success` text within 5s or Lakala retries
|
|
21
|
+
*
|
|
22
|
+
* ⚠️ Integration status: request fields come from the official docs (2026-03); **response structure / signature details /
|
|
23
|
+
* callback field names follow the latest Lakala docs and must be calibrated during sandbox integration
|
|
24
|
+
* (test.wsmsd.cn)** — this implementation follows industry convention with lenient parsing.
|
|
25
|
+
*/
|
|
26
|
+
import type { PaymentDriver } from './types.js';
|
|
27
|
+
import type { PaymentStore } from './store.js';
|
|
28
|
+
export interface LakalaOptions {
|
|
29
|
+
/** Merchant number assigned by Lakala. */
|
|
30
|
+
readonly merchantNo: string;
|
|
31
|
+
/** Business terminal number assigned by Lakala. */
|
|
32
|
+
readonly termNo: string;
|
|
33
|
+
/** Merchant private key (PEM, used for request signing). */
|
|
34
|
+
readonly privateKeyPem: string;
|
|
35
|
+
/** Lakala public key (PEM, used to verify callbacks). */
|
|
36
|
+
readonly lakalaPublicKeyPem: string;
|
|
37
|
+
/** Gateway URL. Production s2.lakala.com, test test.wsmsd.cn (path includes /api/v3). */
|
|
38
|
+
readonly baseUrl?: string;
|
|
39
|
+
readonly fetchImpl?: typeof fetch;
|
|
40
|
+
}
|
|
41
|
+
export declare function createLakalaDriver(_store: PaymentStore, options: LakalaOptions): PaymentDriver;
|
package/dist/lakala.js
ADDED
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
/** @license MIT
|
|
2
|
+
* Copyright (c) 2026 vobsjs
|
|
3
|
+
* @vobs/payment
|
|
4
|
+
*/
|
|
5
|
+
/**
|
|
6
|
+
* Lakala aggregator payment driver (licensed aggregator; main-scan / JSAPI / miniapp).
|
|
7
|
+
*
|
|
8
|
+
* Docs: o.lakala.com (aggregator main-scan /api/v3/labs/trans/preorder).
|
|
9
|
+
*
|
|
10
|
+
* Scene mapping (trans_type + account_type):
|
|
11
|
+
* - `pc` → trans_type=41 NATIVE (main scan), account_type from extras.accountType
|
|
12
|
+
* (WECHAT/ALIPAY/UQRCODEPAY, default WECHAT) → `{kind:'qr'}`
|
|
13
|
+
* - `h5` + openid → trans_type=51 JSAPI (WeChat official account, acc_busi_fields.openid) → `{kind:'jsapi'}`
|
|
14
|
+
* - `miniapp` → trans_type=71 WeChat miniapp (acc_busi_fields.openid) → `{kind:'jsapi'}`
|
|
15
|
+
* - `h5` no openid → **unsupported** (main scan has no WAP form; aggregator cashier product API awaits
|
|
16
|
+
* official confirmation, P2 extension)
|
|
17
|
+
*
|
|
18
|
+
* Security:
|
|
19
|
+
* - Request/callback signing: merchant private key RSA-SHA256 (params minus sign, sorted, → base64); responses/callbacks verified with the Lakala public key
|
|
20
|
+
* - Callbacks use the channel self-verification path; research confirms the callback expects a `success` text within 5s or Lakala retries
|
|
21
|
+
*
|
|
22
|
+
* ⚠️ Integration status: request fields come from the official docs (2026-03); **response structure / signature details /
|
|
23
|
+
* callback field names follow the latest Lakala docs and must be calibrated during sandbox integration
|
|
24
|
+
* (test.wsmsd.cn)** — this implementation follows industry convention with lenient parsing.
|
|
25
|
+
*/
|
|
26
|
+
import { createPrivateKey, createPublicKey, sign, verify } from 'node:crypto';
|
|
27
|
+
const DEFAULT_GATEWAY = 'https://s2.lakala.com/api/v3';
|
|
28
|
+
/** Unknown value → string (objects JSON-serialized to avoid [object Object]). */
|
|
29
|
+
function toStr(value) {
|
|
30
|
+
return typeof value === 'object' && value !== null ? JSON.stringify(value) : String(value);
|
|
31
|
+
}
|
|
32
|
+
/** Scene → trans_type (Lakala access-mode code). */
|
|
33
|
+
function transTypeOf(scene) {
|
|
34
|
+
if (scene === 'pc')
|
|
35
|
+
return '41';
|
|
36
|
+
if (scene === 'h5')
|
|
37
|
+
return '51';
|
|
38
|
+
if (scene === 'miniapp')
|
|
39
|
+
return '71';
|
|
40
|
+
return undefined;
|
|
41
|
+
}
|
|
42
|
+
export function createLakalaDriver(_store, options) {
|
|
43
|
+
const baseUrl = options.baseUrl ?? DEFAULT_GATEWAY;
|
|
44
|
+
const fetchImpl = options.fetchImpl ?? ((...args) => fetch(...args));
|
|
45
|
+
const merchantKey = createPrivateKey(options.privateKeyPem);
|
|
46
|
+
const lakalaPublicKey = createPublicKey(options.lakalaPublicKeyPem);
|
|
47
|
+
/** RSA sign: params (excluding sign/empty) sorted → SHA256withRSA → base64. */
|
|
48
|
+
function rsaSign(params) {
|
|
49
|
+
const str = Object.keys(params)
|
|
50
|
+
.filter((k) => k !== 'sign' && params[k] !== undefined && params[k] !== null && params[k] !== '')
|
|
51
|
+
.sort()
|
|
52
|
+
.map((k) => `${k}=${params[k]}`)
|
|
53
|
+
.join('&');
|
|
54
|
+
return sign('RSA-SHA256', Buffer.from(str), merchantKey).toString('base64');
|
|
55
|
+
}
|
|
56
|
+
/** RSA verify (Lakala public key). */
|
|
57
|
+
function rsaVerify(params) {
|
|
58
|
+
const signature = params['sign'];
|
|
59
|
+
if (!signature)
|
|
60
|
+
return false;
|
|
61
|
+
const str = Object.keys(params)
|
|
62
|
+
.filter((k) => k !== 'sign' && params[k] !== undefined && params[k] !== null && params[k] !== '')
|
|
63
|
+
.sort()
|
|
64
|
+
.map((k) => `${k}=${params[k]}`)
|
|
65
|
+
.join('&');
|
|
66
|
+
try {
|
|
67
|
+
return verify('RSA-SHA256', Buffer.from(str), lakalaPublicKey, Buffer.from(signature, 'base64'));
|
|
68
|
+
}
|
|
69
|
+
catch {
|
|
70
|
+
return false;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
/** Location info (required for risk control). */
|
|
74
|
+
function locationInfoOf(input) {
|
|
75
|
+
const extras = (input.extras ?? {});
|
|
76
|
+
const clientIp = typeof extras['clientIp'] === 'string' && extras['clientIp']
|
|
77
|
+
? extras['clientIp']
|
|
78
|
+
: '127.0.0.1';
|
|
79
|
+
return { request_ip: clientIp };
|
|
80
|
+
}
|
|
81
|
+
/** Account-side business info (JSAPI/miniapp scenes pass openid/userId per channel). */
|
|
82
|
+
function accBusiFieldsOf(input, accountType) {
|
|
83
|
+
const extras = (input.extras ?? {});
|
|
84
|
+
const openid = extras['openid'];
|
|
85
|
+
if (typeof openid === 'string' && openid) {
|
|
86
|
+
return accountType === 'WECHAT' ? { openid } : { user_id: openid };
|
|
87
|
+
}
|
|
88
|
+
const userId = extras['userId'];
|
|
89
|
+
if (typeof userId === 'string' && userId) {
|
|
90
|
+
return { user_id: userId };
|
|
91
|
+
}
|
|
92
|
+
return undefined;
|
|
93
|
+
}
|
|
94
|
+
/** Wallet type: extras.accountType explicit (WECHAT/ALIPAY/UQRCODEPAY), default WECHAT. */
|
|
95
|
+
function accountTypeOf(input) {
|
|
96
|
+
const explicit = input.extras?.['accountType'];
|
|
97
|
+
return typeof explicit === 'string' && explicit ? explicit : 'WECHAT';
|
|
98
|
+
}
|
|
99
|
+
async function createOrder(input) {
|
|
100
|
+
const transType = transTypeOf(input.scene);
|
|
101
|
+
if (!transType) {
|
|
102
|
+
throw new Error(`unsupported_scene:${input.scene}`);
|
|
103
|
+
}
|
|
104
|
+
// JSAPI (51) / miniapp (71) require openid/userId
|
|
105
|
+
if (input.scene === 'h5' || input.scene === 'miniapp') {
|
|
106
|
+
const extras = (input.extras ?? {});
|
|
107
|
+
if (typeof extras['openid'] !== 'string' && typeof extras['userId'] !== 'string') {
|
|
108
|
+
throw new Error(`unsupported_scene:${input.scene} (JSAPI/小程序需 openid)`);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
const accountType = accountTypeOf(input);
|
|
112
|
+
const params = {
|
|
113
|
+
merchant_no: options.merchantNo,
|
|
114
|
+
term_no: options.termNo,
|
|
115
|
+
out_trade_no: input.orderId,
|
|
116
|
+
account_type: accountType,
|
|
117
|
+
trans_type: transType,
|
|
118
|
+
total_amount: String(input.amount),
|
|
119
|
+
busi_mode: 'ACQ',
|
|
120
|
+
subject: input.subject,
|
|
121
|
+
notify_url: input.notifyUrl,
|
|
122
|
+
location_info: JSON.stringify(locationInfoOf(input)),
|
|
123
|
+
};
|
|
124
|
+
const accFields = accBusiFieldsOf(input, accountType);
|
|
125
|
+
if (accFields) {
|
|
126
|
+
params['acc_busi_fields'] = JSON.stringify(accFields);
|
|
127
|
+
}
|
|
128
|
+
params['sign'] = rsaSign(params);
|
|
129
|
+
const res = await fetchImpl(`${baseUrl}/labs/trans/preorder`, {
|
|
130
|
+
method: 'POST',
|
|
131
|
+
headers: { 'Content-Type': 'application/json' },
|
|
132
|
+
body: JSON.stringify(params),
|
|
133
|
+
});
|
|
134
|
+
const body = (await res.json());
|
|
135
|
+
if (!res.ok || (body['code'] !== undefined && body['code'] !== '0000' && body['code'] !== 0)) {
|
|
136
|
+
throw new Error(`lakala_error:${toStr(body['code'] ?? res.status)}:${toStr(body['msg'] ?? body['message'] ?? '')}`);
|
|
137
|
+
}
|
|
138
|
+
// ⚠️ response field names follow the latest Lakala docs (sandbox-calibrated); parse leniently
|
|
139
|
+
const data = (body['data'] ?? body);
|
|
140
|
+
if (transType === '41') {
|
|
141
|
+
// main-scan: returns QR code content
|
|
142
|
+
const qrCode = data['code_url'] ??
|
|
143
|
+
data['codeUrl'] ??
|
|
144
|
+
data['qr_code'] ??
|
|
145
|
+
data['prepayCode'] ??
|
|
146
|
+
data['code'];
|
|
147
|
+
if (typeof qrCode === 'string' && qrCode) {
|
|
148
|
+
return {
|
|
149
|
+
kind: 'qr',
|
|
150
|
+
qrUrl: qrCode,
|
|
151
|
+
expiresAt: new Date(Date.now() + 5 * 60 * 1000).toISOString(),
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
else {
|
|
156
|
+
// JSAPI/miniapp: returns pay params (JSON string)
|
|
157
|
+
const payParam = data['pay_param'] ?? data['payParams'] ?? data['pay_params'] ?? data['pay_param_json'];
|
|
158
|
+
if (typeof payParam === 'string' && payParam) {
|
|
159
|
+
return { kind: 'jsapi', payParams: JSON.parse(payParam) };
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
throw new Error(`lakala_unsupported_response:${JSON.stringify(body)}`);
|
|
163
|
+
}
|
|
164
|
+
function verifyCallback(payload, _headers) {
|
|
165
|
+
// ⚠️ callback field names follow the latest official docs (sandbox-calibrated). JSON notification + sign-field verification.
|
|
166
|
+
let obj;
|
|
167
|
+
try {
|
|
168
|
+
obj =
|
|
169
|
+
typeof payload === 'string'
|
|
170
|
+
? JSON.parse(payload)
|
|
171
|
+
: payload;
|
|
172
|
+
}
|
|
173
|
+
catch {
|
|
174
|
+
return { ok: false, reason: 'bad_payload' };
|
|
175
|
+
}
|
|
176
|
+
const params = Object.fromEntries(Object.entries(obj).map(([k, v]) => [k, String(v)]));
|
|
177
|
+
if (!rsaVerify(params)) {
|
|
178
|
+
return { ok: false, reason: 'bad_signature' };
|
|
179
|
+
}
|
|
180
|
+
// status field candidates (pay_status / trade_status / result_code); success values 'SUCCESS'/'00'/'0000'/2
|
|
181
|
+
const status = String(params['pay_status'] ??
|
|
182
|
+
params['trade_status'] ??
|
|
183
|
+
params['result_code'] ??
|
|
184
|
+
params['order_status'] ??
|
|
185
|
+
'');
|
|
186
|
+
const success = status === 'SUCCESS' ||
|
|
187
|
+
status === '00' ||
|
|
188
|
+
status === '0000' ||
|
|
189
|
+
status === '2' ||
|
|
190
|
+
status === 'TRADE_SUCCESS';
|
|
191
|
+
if (!success) {
|
|
192
|
+
return { ok: false, reason: `status:${status}` };
|
|
193
|
+
}
|
|
194
|
+
const orderId = params['out_trade_no'] ?? params['outTradeNo'];
|
|
195
|
+
const amount = params['total_amount'] ?? params['amount'];
|
|
196
|
+
if (!orderId || amount === undefined) {
|
|
197
|
+
return { ok: false, reason: 'bad_payload' };
|
|
198
|
+
}
|
|
199
|
+
return {
|
|
200
|
+
ok: true,
|
|
201
|
+
orderId,
|
|
202
|
+
paidAmount: Number(amount),
|
|
203
|
+
...(params['pay_order_no'] ? { channelTxnId: params['pay_order_no'] } : {}),
|
|
204
|
+
};
|
|
205
|
+
}
|
|
206
|
+
async function queryOrder(orderId) {
|
|
207
|
+
try {
|
|
208
|
+
const params = {
|
|
209
|
+
merchant_no: options.merchantNo,
|
|
210
|
+
term_no: options.termNo,
|
|
211
|
+
out_trade_no: orderId,
|
|
212
|
+
};
|
|
213
|
+
params['sign'] = rsaSign(params);
|
|
214
|
+
const res = await fetchImpl(`${baseUrl}/trade/query`, {
|
|
215
|
+
method: 'POST',
|
|
216
|
+
headers: { 'Content-Type': 'application/json' },
|
|
217
|
+
body: JSON.stringify(params),
|
|
218
|
+
});
|
|
219
|
+
const body = (await res.json());
|
|
220
|
+
const data = (body['data'] ?? body);
|
|
221
|
+
const status = toStr(data['pay_status'] ?? data['trade_status'] ?? data['order_status'] ?? '');
|
|
222
|
+
if (status === 'SUCCESS' || status === '00' || status === 'TRADE_SUCCESS') {
|
|
223
|
+
return { state: 'paid' };
|
|
224
|
+
}
|
|
225
|
+
if (status === 'CLOSED' || status === 'CLOSE') {
|
|
226
|
+
return { state: 'closed' };
|
|
227
|
+
}
|
|
228
|
+
return { state: 'created' };
|
|
229
|
+
}
|
|
230
|
+
catch {
|
|
231
|
+
return { state: 'unknown', raw: { error: 'query_failed' } };
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
return {
|
|
235
|
+
name: 'lakala',
|
|
236
|
+
scenes: ['pc', 'h5', 'miniapp'],
|
|
237
|
+
createOrder,
|
|
238
|
+
verifyCallback,
|
|
239
|
+
queryOrder,
|
|
240
|
+
successResponseText: 'success',
|
|
241
|
+
};
|
|
242
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/** @license MIT
|
|
2
|
+
* Copyright (c) 2026 vobsjs
|
|
3
|
+
* @vobs/payment
|
|
4
|
+
*/
|
|
5
|
+
/**
|
|
6
|
+
* PaymentManager — payment core service layer (framework-agnostic, pure logic).
|
|
7
|
+
*
|
|
8
|
+
* Security baseline coverage (design doc §3/§5):
|
|
9
|
+
* - Server-side pricing (amount comes from the business's getOrderAmount; the frontend amount is only a consistency check)
|
|
10
|
+
* - MAX_AMOUNT per-order limit (over-limit rejected, avoids channel errors/risk control)
|
|
11
|
+
* - Order idempotency (idempotencyKey) + duplicate-order guard for the same business order (duplicateOrderStrategy)
|
|
12
|
+
* - Callback verification + time window + nonce anti-replay + callback idempotency (persist before responding)
|
|
13
|
+
* - Conflict freeze / paid-after-close freeze / large-amount manual review (AUDIT_THRESHOLD + confirm)
|
|
14
|
+
* - Operation audit (order/callback-book/freeze/confirm all logged)
|
|
15
|
+
* - onVerified business hook (must be idempotent) / onAlert alert hook
|
|
16
|
+
*/
|
|
17
|
+
import type { AuditEntry, CallbackResult, CreateOrderRequest, CreateOrderResult, GetOrderAmount, OrderState, OrderStatus, PaymentAlert, PaymentConfig, PaymentDriver, PaymentScene, VerifiedResult } from './types.js';
|
|
18
|
+
import { type PaymentStore } from './store.js';
|
|
19
|
+
export type OrderErrorCode = 'order_not_found' | 'amount_mismatch' | 'amount_exceeds_limit' | 'order_conflict' | 'channel_unavailable' | 'bad_request';
|
|
20
|
+
export type OrderError = {
|
|
21
|
+
readonly error: OrderErrorCode;
|
|
22
|
+
readonly message?: string;
|
|
23
|
+
readonly expected?: number;
|
|
24
|
+
readonly max?: number;
|
|
25
|
+
};
|
|
26
|
+
export type CreateOrderOutcome = CreateOrderResult | OrderError;
|
|
27
|
+
export interface PaymentManagerOptions {
|
|
28
|
+
readonly config?: PaymentConfig;
|
|
29
|
+
/** Defaults to an in-memory store. */
|
|
30
|
+
readonly store?: PaymentStore;
|
|
31
|
+
/** Callback signature key (HMAC-SHA256). Inject via KMS/env in production. */
|
|
32
|
+
readonly apiKey?: string;
|
|
33
|
+
/** Server-pricing hook (business: orderId → amount in fen; undefined = order not found). */
|
|
34
|
+
readonly getOrderAmount?: GetOrderAmount;
|
|
35
|
+
/** Business action after booking (deliver/activate) — must be idempotent; on failure the business must retry or compensate manually. */
|
|
36
|
+
readonly onVerified?: (result: VerifiedResult) => void | Promise<void>;
|
|
37
|
+
/** Alerts (frozen/conflict/large-amount/signature-failure). */
|
|
38
|
+
readonly onAlert?: (alert: PaymentAlert) => void;
|
|
39
|
+
}
|
|
40
|
+
export interface PaymentManager {
|
|
41
|
+
registerDriver(driver: PaymentDriver): void;
|
|
42
|
+
available(): readonly {
|
|
43
|
+
readonly name: string;
|
|
44
|
+
readonly scenes: readonly PaymentScene[];
|
|
45
|
+
}[];
|
|
46
|
+
createOrder(req: CreateOrderRequest): Promise<CreateOrderOutcome>;
|
|
47
|
+
getStatus(orderId: string): OrderStatus | undefined;
|
|
48
|
+
close(orderId: string): OrderStatus | undefined;
|
|
49
|
+
confirm(orderId: string): OrderStatus | undefined;
|
|
50
|
+
/** Active reconciliation (compensates lost callbacks): query channel status → sync locally (channel paid & local created → migrate via state machine, incl. large-amount audit). */
|
|
51
|
+
syncOrderStatus(orderId: string): Promise<OrderStatus | undefined>;
|
|
52
|
+
/** Expiry scan: created & past ttlMs → closed; returns closed orders. Call from a scheduled task. */
|
|
53
|
+
scanExpiredOrders(ttlMs: number): readonly {
|
|
54
|
+
readonly orderId: string;
|
|
55
|
+
readonly state: OrderState;
|
|
56
|
+
}[];
|
|
57
|
+
handleCallback(driverName: string, ctx: {
|
|
58
|
+
readonly headers: Readonly<Record<string, string | undefined>>;
|
|
59
|
+
readonly body: string;
|
|
60
|
+
}): Promise<CallbackResult>;
|
|
61
|
+
auditLog(): readonly AuditEntry[];
|
|
62
|
+
}
|
|
63
|
+
export declare function createPaymentManager(options?: PaymentManagerOptions): PaymentManager;
|