@h-ai/payment 0.1.0-alpha9 → 1.0.0-beta.10
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.en.md +244 -0
- package/README.ja.md +244 -0
- package/README.md +172 -52
- package/dist/chunk-YQD4KPT5.js +103 -0
- package/dist/chunk-YQD4KPT5.js.map +1 -0
- package/dist/client/index.d.ts +26 -25
- package/dist/client/index.js +61 -60
- package/dist/client/index.js.map +1 -1
- package/dist/index.d.ts +17 -19
- package/dist/index.js +719 -175
- package/dist/index.js.map +1 -1
- package/dist/payment-types-D02t-Pc9.d.ts +500 -0
- package/package.json +17 -15
- package/dist/api/index.d.ts +0 -211
- package/dist/api/index.js +0 -96
- package/dist/api/index.js.map +0 -1
- package/dist/chunk-4IUARFWH.js +0 -67
- package/dist/chunk-4IUARFWH.js.map +0 -1
- package/dist/payment-types-D1GvLKHg.d.ts +0 -209
package/dist/index.js
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
|
-
import { paymentM, HaiPaymentError } from './chunk-
|
|
2
|
-
export { HaiPaymentError } from './chunk-
|
|
1
|
+
import { paymentM, HaiPaymentError } from './chunk-YQD4KPT5.js';
|
|
2
|
+
export { HaiPaymentError } from './chunk-YQD4KPT5.js';
|
|
3
3
|
import { z } from 'zod';
|
|
4
4
|
import { core, err, ok } from '@h-ai/core';
|
|
5
|
+
import { reldb } from '@h-ai/reldb';
|
|
5
6
|
import { audit } from '@h-ai/audit';
|
|
6
|
-
import {
|
|
7
|
+
import { createHash, createHmac, X509Certificate, createDecipheriv, randomBytes, createSign, createVerify } from 'crypto';
|
|
7
8
|
import { Buffer } from 'buffer';
|
|
8
9
|
|
|
9
10
|
var WechatPayConfigSchema = z.object({
|
|
@@ -11,20 +12,29 @@ var WechatPayConfigSchema = z.object({
|
|
|
11
12
|
apiV3Key: z.string().min(1, paymentM("payment_configFieldRequired")),
|
|
12
13
|
serialNo: z.string().min(1, paymentM("payment_configFieldRequired")),
|
|
13
14
|
privateKey: z.string().min(1, paymentM("payment_configFieldRequired")),
|
|
14
|
-
|
|
15
|
+
/** 精确按 Wechatpay-Serial 选择平台证书/公钥,轮换期间同时配置新旧标识。 */
|
|
16
|
+
platformKeys: z.record(z.string().min(1), z.string().min(1)).refine((keys) => Object.keys(keys).length > 0, paymentM("payment_configFieldRequired")),
|
|
15
17
|
appId: z.string().min(1, paymentM("payment_configFieldRequired"))
|
|
16
18
|
});
|
|
17
19
|
var AlipayConfigSchema = z.object({
|
|
18
20
|
appId: z.string().min(1, paymentM("payment_configFieldRequired")),
|
|
21
|
+
sellerId: z.string().min(1, paymentM("payment_configFieldRequired")),
|
|
19
22
|
privateKey: z.string().min(1, paymentM("payment_configFieldRequired")),
|
|
20
23
|
alipayPublicKey: z.string().min(1, paymentM("payment_configFieldRequired")),
|
|
24
|
+
/** 手机网站支付完成后返回的可信 HTTPS 地址;仅用于页面导航,不能证明付款。 */
|
|
25
|
+
returnUrl: z.url({ protocol: /^https$/ }),
|
|
21
26
|
signType: z.enum(["RSA2", "RSA"]).default("RSA2"),
|
|
22
27
|
sandbox: z.boolean().default(false)
|
|
23
28
|
});
|
|
24
29
|
var StripeConfigSchema = z.object({
|
|
25
|
-
secretKey: z.string().
|
|
26
|
-
webhookSecret: z.string().min(1, paymentM("payment_configFieldRequired"))
|
|
27
|
-
}
|
|
30
|
+
secretKey: z.string().regex(/^(?:sk|rk)_(?:test|live)_\S+$/, paymentM("payment_stripeKeyInvalid")),
|
|
31
|
+
webhookSecret: z.string().min(1, paymentM("payment_configFieldRequired")),
|
|
32
|
+
/** 托管 Checkout 完成后的可信跳转地址,可含 {CHECKOUT_SESSION_ID}。 */
|
|
33
|
+
successUrl: z.url({ protocol: /^https?$/ }),
|
|
34
|
+
/** 托管 Checkout 取消按钮的可信跳转地址;省略时不显示返回按钮。 */
|
|
35
|
+
cancelUrl: z.url({ protocol: /^https?$/ }).optional(),
|
|
36
|
+
webhookToleranceSeconds: z.number().int().positive().default(300)
|
|
37
|
+
}).strict();
|
|
28
38
|
var PaymentConfigSchema = z.object({
|
|
29
39
|
wechat: WechatPayConfigSchema.optional(),
|
|
30
40
|
alipay: AlipayConfigSchema.optional(),
|
|
@@ -38,6 +48,35 @@ async function auditLog(input) {
|
|
|
38
48
|
logger.warn("Failed to write payment audit log", { action: input.action, error: result.error.message });
|
|
39
49
|
}
|
|
40
50
|
}
|
|
51
|
+
function isValidAmount(amount) {
|
|
52
|
+
return Number.isSafeInteger(amount) && amount > 0;
|
|
53
|
+
}
|
|
54
|
+
function validateCreateOrderInput(input) {
|
|
55
|
+
if (!isValidAmount(input.amount)) {
|
|
56
|
+
return err(
|
|
57
|
+
HaiPaymentError.INVALID_AMOUNT,
|
|
58
|
+
paymentM("payment_invalidAmount")
|
|
59
|
+
);
|
|
60
|
+
}
|
|
61
|
+
return ok(void 0);
|
|
62
|
+
}
|
|
63
|
+
function validateRefundInput(input) {
|
|
64
|
+
if (!isValidAmount(input.amount)) {
|
|
65
|
+
return err(
|
|
66
|
+
HaiPaymentError.INVALID_AMOUNT,
|
|
67
|
+
paymentM("payment_invalidAmount")
|
|
68
|
+
);
|
|
69
|
+
}
|
|
70
|
+
if (input.totalAmount !== void 0) {
|
|
71
|
+
if (!isValidAmount(input.totalAmount) || input.totalAmount < input.amount) {
|
|
72
|
+
return err(
|
|
73
|
+
HaiPaymentError.INVALID_AMOUNT,
|
|
74
|
+
paymentM("payment_invalidAmount")
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
return ok(void 0);
|
|
79
|
+
}
|
|
41
80
|
function registerProvider(provider) {
|
|
42
81
|
providers.set(provider.name, provider);
|
|
43
82
|
}
|
|
@@ -58,6 +97,9 @@ async function createOrder(providerName, input) {
|
|
|
58
97
|
const result = requireProvider(providerName);
|
|
59
98
|
if (!result.success)
|
|
60
99
|
return result;
|
|
100
|
+
const validateResult = validateCreateOrderInput(input);
|
|
101
|
+
if (!validateResult.success)
|
|
102
|
+
return validateResult;
|
|
61
103
|
const orderResult = await result.data.createOrder(input);
|
|
62
104
|
if (orderResult.success) {
|
|
63
105
|
await auditLog({
|
|
@@ -79,7 +121,7 @@ async function handleNotify(providerName, request) {
|
|
|
79
121
|
action: "payment_notify",
|
|
80
122
|
resource: "payment",
|
|
81
123
|
resourceId: notifyResult.data.orderNo,
|
|
82
|
-
details: { provider: providerName, transactionId: notifyResult.data.transactionId, status: notifyResult.data.status, amount: notifyResult.data.amount }
|
|
124
|
+
details: { provider: providerName, transactionId: notifyResult.data.transactionId, eventId: notifyResult.data.eventId, status: notifyResult.data.status, amount: notifyResult.data.amount, currency: notifyResult.data.currency }
|
|
83
125
|
});
|
|
84
126
|
}
|
|
85
127
|
return notifyResult;
|
|
@@ -94,6 +136,9 @@ async function refund(providerName, input) {
|
|
|
94
136
|
const result = requireProvider(providerName);
|
|
95
137
|
if (!result.success)
|
|
96
138
|
return result;
|
|
139
|
+
const validateResult = validateRefundInput(input);
|
|
140
|
+
if (!validateResult.success)
|
|
141
|
+
return validateResult;
|
|
97
142
|
const refundResult = await result.data.refund(input);
|
|
98
143
|
if (refundResult.success) {
|
|
99
144
|
await auditLog({
|
|
@@ -123,6 +168,15 @@ async function closeOrder(providerName, orderNo) {
|
|
|
123
168
|
function clearProviders() {
|
|
124
169
|
providers.clear();
|
|
125
170
|
}
|
|
171
|
+
|
|
172
|
+
// src/providers/payment-provider-http.ts
|
|
173
|
+
var DEFAULT_PROVIDER_TIMEOUT_MS = 15e3;
|
|
174
|
+
async function fetchWithTimeout(input, init, timeoutMs = DEFAULT_PROVIDER_TIMEOUT_MS) {
|
|
175
|
+
return fetch(input, {
|
|
176
|
+
...init,
|
|
177
|
+
signal: init.signal ? AbortSignal.any([init.signal, AbortSignal.timeout(timeoutMs)]) : AbortSignal.timeout(timeoutMs)
|
|
178
|
+
});
|
|
179
|
+
}
|
|
126
180
|
function signAlipayParams(params, privateKey, signType = "RSA2") {
|
|
127
181
|
const sorted = Object.keys(params).filter((k) => params[k] !== void 0 && params[k] !== "").sort().map((k) => `${k}=${params[k]}`).join("&");
|
|
128
182
|
const algorithm = signType === "RSA2" ? "RSA-SHA256" : "RSA-SHA1";
|
|
@@ -132,7 +186,9 @@ function signAlipayParams(params, privateKey, signType = "RSA2") {
|
|
|
132
186
|
}
|
|
133
187
|
function verifyAlipayNotify(params, alipayPublicKey) {
|
|
134
188
|
const sign = params.sign ?? "";
|
|
135
|
-
const signType = params.sign_type
|
|
189
|
+
const signType = params.sign_type;
|
|
190
|
+
if (signType !== "RSA" && signType !== "RSA2")
|
|
191
|
+
return false;
|
|
136
192
|
const sorted = Object.keys(params).filter((k) => k !== "sign" && k !== "sign_type" && params[k] !== void 0 && params[k] !== "").sort().map((k) => `${k}=${params[k]}`).join("&");
|
|
137
193
|
const algorithm = signType === "RSA2" ? "RSA-SHA256" : "RSA-SHA1";
|
|
138
194
|
const verify = createVerify(algorithm);
|
|
@@ -140,9 +196,96 @@ function verifyAlipayNotify(params, alipayPublicKey) {
|
|
|
140
196
|
return verify.verify(alipayPublicKey, sign, "base64");
|
|
141
197
|
}
|
|
142
198
|
|
|
143
|
-
// src/providers/alipay/alipay-
|
|
199
|
+
// src/providers/alipay/payment-alipay-time.ts
|
|
200
|
+
var beijingFormatter = new Intl.DateTimeFormat("en-CA", {
|
|
201
|
+
timeZone: "Asia/Shanghai",
|
|
202
|
+
year: "numeric",
|
|
203
|
+
month: "2-digit",
|
|
204
|
+
day: "2-digit",
|
|
205
|
+
hour: "2-digit",
|
|
206
|
+
minute: "2-digit",
|
|
207
|
+
second: "2-digit",
|
|
208
|
+
hourCycle: "h23"
|
|
209
|
+
});
|
|
210
|
+
function formatAlipayTime(date) {
|
|
211
|
+
const parts = Object.fromEntries(beijingFormatter.formatToParts(date).map((part) => [part.type, part.value]));
|
|
212
|
+
return `${parts.year}-${parts.month}-${parts.day} ${parts.hour}:${parts.minute}:${parts.second}`;
|
|
213
|
+
}
|
|
214
|
+
function parseAlipayTime(value) {
|
|
215
|
+
if (!/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/.test(value))
|
|
216
|
+
return void 0;
|
|
217
|
+
const date = /* @__PURE__ */ new Date(`${value.replace(" ", "T")}+08:00`);
|
|
218
|
+
return Number.isFinite(date.getTime()) && formatAlipayTime(date) === value ? date : void 0;
|
|
219
|
+
}
|
|
220
|
+
var AlipayTimeSchema = z.string().refine((value) => parseAlipayTime(value) !== void 0);
|
|
221
|
+
var AlipayResponseRootSchema = z.record(z.string(), z.unknown());
|
|
222
|
+
var AlipayResponseSchema = z.object({
|
|
223
|
+
code: z.string().regex(/^\d{5}$/),
|
|
224
|
+
sub_code: z.string().optional()
|
|
225
|
+
});
|
|
226
|
+
var AlipayAmountSchema = z.string().regex(/^\d+(?:\.\d{1,2})?$/);
|
|
227
|
+
var AlipayQueryResponseSchema = z.object({
|
|
228
|
+
out_trade_no: z.string().min(1),
|
|
229
|
+
trade_no: z.string().min(1),
|
|
230
|
+
trade_status: z.enum(["WAIT_BUYER_PAY", "TRADE_CLOSED", "TRADE_SUCCESS", "TRADE_FINISHED"]),
|
|
231
|
+
total_amount: AlipayAmountSchema,
|
|
232
|
+
send_pay_date: AlipayTimeSchema.optional()
|
|
233
|
+
});
|
|
234
|
+
var AlipayRefundResponseSchema = z.object({
|
|
235
|
+
out_trade_no: z.string().min(1),
|
|
236
|
+
trade_no: z.string().min(1),
|
|
237
|
+
refund_fee: AlipayAmountSchema,
|
|
238
|
+
fund_change: z.enum(["Y", "N"]).optional()
|
|
239
|
+
});
|
|
240
|
+
var AlipayCloseResponseSchema = z.object({
|
|
241
|
+
out_trade_no: z.string().min(1),
|
|
242
|
+
trade_no: z.string().min(1)
|
|
243
|
+
});
|
|
244
|
+
var AlipayNotifyParamsSchema = z.object({
|
|
245
|
+
app_id: z.string().min(1),
|
|
246
|
+
seller_id: z.string().min(1),
|
|
247
|
+
notify_id: z.string().min(1),
|
|
248
|
+
notify_type: z.literal("trade_status_sync"),
|
|
249
|
+
notify_time: AlipayTimeSchema,
|
|
250
|
+
sign_type: z.enum(["RSA", "RSA2"]),
|
|
251
|
+
sign: z.string().min(1),
|
|
252
|
+
out_trade_no: z.string().min(1),
|
|
253
|
+
trade_no: z.string().min(1),
|
|
254
|
+
trade_status: z.enum(["WAIT_BUYER_PAY", "TRADE_CLOSED", "TRADE_SUCCESS", "TRADE_FINISHED"]),
|
|
255
|
+
total_amount: AlipayAmountSchema,
|
|
256
|
+
gmt_payment: AlipayTimeSchema.optional()
|
|
257
|
+
}).catchall(z.string()).refine((params) => !["TRADE_SUCCESS", "TRADE_FINISHED"].includes(params.trade_status) || !!params.gmt_payment);
|
|
258
|
+
|
|
259
|
+
// src/providers/alipay/payment-provider-alipay.ts
|
|
144
260
|
var ALIPAY_GATEWAY = "https://openapi.alipay.com/gateway.do";
|
|
145
261
|
var ALIPAY_SANDBOX_GATEWAY = "https://openapi-sandbox.dl.alipaydev.com/gateway.do";
|
|
262
|
+
function formatFenAmount(amount) {
|
|
263
|
+
const yuan = Math.floor(amount / 100);
|
|
264
|
+
const cents = String(amount % 100).padStart(2, "0");
|
|
265
|
+
return `${yuan}.${cents}`;
|
|
266
|
+
}
|
|
267
|
+
function parseAlipayAmount(value) {
|
|
268
|
+
const trimmedValue = value.trim();
|
|
269
|
+
if (!/^\d+(?:\.\d{1,2})?$/.test(trimmedValue)) {
|
|
270
|
+
throw new Error("Invalid Alipay amount");
|
|
271
|
+
}
|
|
272
|
+
const [yuanPart, centsPart = ""] = trimmedValue.split(".");
|
|
273
|
+
const amount = Number.parseInt(yuanPart, 10) * 100 + Number.parseInt(centsPart.padEnd(2, "0"), 10);
|
|
274
|
+
if (!Number.isSafeInteger(amount)) {
|
|
275
|
+
throw new TypeError("Invalid Alipay amount");
|
|
276
|
+
}
|
|
277
|
+
return amount;
|
|
278
|
+
}
|
|
279
|
+
function parseAlipayNotifyParams(body) {
|
|
280
|
+
const params = new URLSearchParams(body);
|
|
281
|
+
if (new Set(params.keys()).size !== [...params].length)
|
|
282
|
+
throw new Error("Ambiguous Alipay notification parameters");
|
|
283
|
+
const parseResult = AlipayNotifyParamsSchema.safeParse(Object.fromEntries(params.entries()));
|
|
284
|
+
if (!parseResult.success) {
|
|
285
|
+
throw new Error("Invalid Alipay notification parameters");
|
|
286
|
+
}
|
|
287
|
+
return parseResult.data;
|
|
288
|
+
}
|
|
146
289
|
function createAlipayProvider(config) {
|
|
147
290
|
const gateway = config.sandbox ? ALIPAY_SANDBOX_GATEWAY : ALIPAY_GATEWAY;
|
|
148
291
|
const signType = config.signType ?? "RSA2";
|
|
@@ -150,60 +293,78 @@ function createAlipayProvider(config) {
|
|
|
150
293
|
return {
|
|
151
294
|
app_id: config.appId,
|
|
152
295
|
method,
|
|
296
|
+
format: "JSON",
|
|
153
297
|
charset: "utf-8",
|
|
154
298
|
sign_type: signType,
|
|
155
|
-
timestamp: (/* @__PURE__ */ new Date())
|
|
299
|
+
timestamp: formatAlipayTime(/* @__PURE__ */ new Date()),
|
|
156
300
|
version: "1.0",
|
|
157
301
|
notify_url: notifyUrl
|
|
158
302
|
};
|
|
159
303
|
}
|
|
160
|
-
function
|
|
161
|
-
const map = {
|
|
162
|
-
jsapi: { method: "alipay.trade.create", productCode: "JSAPI_PAY" },
|
|
163
|
-
h5: { method: "alipay.trade.wap.pay", productCode: "QUICK_WAP_WAY" },
|
|
164
|
-
app: { method: "alipay.trade.app.pay", productCode: "QUICK_MSECURITY_PAY" },
|
|
165
|
-
native: { method: "alipay.trade.precreate", productCode: "FACE_TO_FACE_PAYMENT" }
|
|
166
|
-
};
|
|
167
|
-
return map[tradeType] ?? map.h5;
|
|
168
|
-
}
|
|
169
|
-
async function alipayRequest(params) {
|
|
304
|
+
async function alipayRequest(params, schema) {
|
|
170
305
|
const sign = signAlipayParams(params, config.privateKey, signType);
|
|
171
306
|
const allParams = { ...params, sign };
|
|
172
|
-
const
|
|
173
|
-
const response = await fetch(`${gateway}?${queryString}`, {
|
|
307
|
+
const timedResponse = await fetchWithTimeout(gateway, {
|
|
174
308
|
method: "POST",
|
|
175
|
-
headers: { "Content-Type": "application/x-www-form-urlencoded" }
|
|
309
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
310
|
+
body: new URLSearchParams(allParams).toString()
|
|
176
311
|
});
|
|
177
|
-
if (!
|
|
178
|
-
throw new Error(`Alipay API failed: ${
|
|
312
|
+
if (!timedResponse.ok) {
|
|
313
|
+
throw new Error(`Alipay API failed: ${timedResponse.status}`);
|
|
179
314
|
}
|
|
180
|
-
|
|
315
|
+
let body;
|
|
316
|
+
try {
|
|
317
|
+
body = await timedResponse.json();
|
|
318
|
+
} catch {
|
|
319
|
+
throw new Error("Invalid Alipay JSON response");
|
|
320
|
+
}
|
|
321
|
+
const root = AlipayResponseRootSchema.safeParse(body);
|
|
322
|
+
const key = `${params.method.replace(/\./g, "_")}_response`;
|
|
323
|
+
if (!root.success || "error_response" in root.data) {
|
|
324
|
+
throw new Error("Invalid Alipay response envelope");
|
|
325
|
+
}
|
|
326
|
+
const response = root.data[key];
|
|
327
|
+
const common = AlipayResponseSchema.safeParse(response);
|
|
328
|
+
if (!common.success) {
|
|
329
|
+
throw new Error("Invalid Alipay response code");
|
|
330
|
+
}
|
|
331
|
+
if (common.data.code !== "10000") {
|
|
332
|
+
const subCode = common.data.sub_code;
|
|
333
|
+
const suffix = subCode && /^[\w.-]{1,80}$/.test(subCode) ? `, sub_code=${subCode}` : "";
|
|
334
|
+
throw new Error(`Alipay business request failed (code=${common.data.code}${suffix})`);
|
|
335
|
+
}
|
|
336
|
+
const parsed = schema.safeParse(response);
|
|
337
|
+
if (!parsed.success) {
|
|
338
|
+
throw new Error("Invalid Alipay business response");
|
|
339
|
+
}
|
|
340
|
+
return parsed.data;
|
|
181
341
|
}
|
|
182
342
|
return {
|
|
183
343
|
name: "alipay",
|
|
184
344
|
async createOrder(input) {
|
|
345
|
+
if (input.tradeType !== "h5")
|
|
346
|
+
return err(HaiPaymentError.UNSUPPORTED_TRADE_TYPE, paymentM("payment_unsupportedTradeType"));
|
|
347
|
+
if (input.currency !== void 0 && input.currency !== "CNY")
|
|
348
|
+
return err(HaiPaymentError.INVALID_INPUT, paymentM("payment_invalidCreateOrderInput"));
|
|
185
349
|
try {
|
|
186
|
-
const
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
...buildCommonParams(method, input.notifyUrl),
|
|
197
|
-
biz_content: bizContent
|
|
350
|
+
const fields = {
|
|
351
|
+
...buildCommonParams("alipay.trade.wap.pay", input.notifyUrl),
|
|
352
|
+
return_url: config.returnUrl,
|
|
353
|
+
biz_content: JSON.stringify({
|
|
354
|
+
out_trade_no: input.orderNo,
|
|
355
|
+
total_amount: formatFenAmount(input.amount),
|
|
356
|
+
subject: input.description,
|
|
357
|
+
product_code: "QUICK_WAP_WAY",
|
|
358
|
+
passback_params: input.metadata ? encodeURIComponent(JSON.stringify(input.metadata)) : void 0
|
|
359
|
+
})
|
|
198
360
|
};
|
|
199
|
-
const response = await alipayRequest(params);
|
|
200
|
-
const key = `${method.replace(/\./g, "_")}_response`;
|
|
201
|
-
const data = response[key];
|
|
202
361
|
return ok({
|
|
203
362
|
provider: "alipay",
|
|
204
|
-
tradeType:
|
|
205
|
-
clientParams:
|
|
206
|
-
|
|
363
|
+
tradeType: "h5",
|
|
364
|
+
clientParams: {
|
|
365
|
+
gateway,
|
|
366
|
+
fields: { ...fields, sign: signAlipayParams(fields, config.privateKey, signType) }
|
|
367
|
+
}
|
|
207
368
|
});
|
|
208
369
|
} catch (cause) {
|
|
209
370
|
return err(
|
|
@@ -215,19 +376,22 @@ function createAlipayProvider(config) {
|
|
|
215
376
|
},
|
|
216
377
|
async handleNotify(request) {
|
|
217
378
|
try {
|
|
218
|
-
const params =
|
|
219
|
-
const
|
|
220
|
-
for (const pair of pairs) {
|
|
221
|
-
const [key, ...rest] = pair.split("=");
|
|
222
|
-
params[decodeURIComponent(key)] = decodeURIComponent(rest.join("="));
|
|
223
|
-
}
|
|
224
|
-
const valid = verifyAlipayNotify(params, config.alipayPublicKey);
|
|
379
|
+
const params = parseAlipayNotifyParams(request.body);
|
|
380
|
+
const valid = params.sign_type === signType && verifyAlipayNotify(params, config.alipayPublicKey);
|
|
225
381
|
if (!valid) {
|
|
226
382
|
return err(
|
|
227
383
|
HaiPaymentError.NOTIFY_VERIFY_FAILED,
|
|
228
384
|
paymentM("payment_notifyVerifyFailed")
|
|
229
385
|
);
|
|
230
386
|
}
|
|
387
|
+
if (params.app_id !== config.appId || params.seller_id !== config.sellerId) {
|
|
388
|
+
return err(HaiPaymentError.NOTIFY_VERIFY_FAILED, paymentM("payment_notifyVerifyFailed"));
|
|
389
|
+
}
|
|
390
|
+
const notifiedAt = parseAlipayTime(params.notify_time);
|
|
391
|
+
const paidAt = params.gmt_payment ? parseAlipayTime(params.gmt_payment) : void 0;
|
|
392
|
+
if (notifiedAt.getTime() > Date.now() + 3e5 || paidAt && paidAt.getTime() > notifiedAt.getTime() + 3e5) {
|
|
393
|
+
throw new Error("Invalid Alipay notification time");
|
|
394
|
+
}
|
|
231
395
|
const statusMap = {
|
|
232
396
|
TRADE_SUCCESS: "paid",
|
|
233
397
|
TRADE_CLOSED: "closed",
|
|
@@ -237,10 +401,11 @@ function createAlipayProvider(config) {
|
|
|
237
401
|
return ok({
|
|
238
402
|
orderNo: params.out_trade_no,
|
|
239
403
|
transactionId: params.trade_no,
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
404
|
+
eventId: params.notify_id,
|
|
405
|
+
amount: parseAlipayAmount(params.total_amount),
|
|
406
|
+
currency: "CNY",
|
|
407
|
+
status: statusMap[params.trade_status],
|
|
408
|
+
paidAt: ["TRADE_SUCCESS", "TRADE_FINISHED"].includes(params.trade_status) ? paidAt : void 0,
|
|
244
409
|
raw: params
|
|
245
410
|
});
|
|
246
411
|
} catch (cause) {
|
|
@@ -258,8 +423,10 @@ function createAlipayProvider(config) {
|
|
|
258
423
|
...buildCommonParams(method, ""),
|
|
259
424
|
biz_content: JSON.stringify({ out_trade_no: orderNo })
|
|
260
425
|
};
|
|
261
|
-
const
|
|
262
|
-
|
|
426
|
+
const data = await alipayRequest(params, AlipayQueryResponseSchema);
|
|
427
|
+
if (data.out_trade_no !== orderNo) {
|
|
428
|
+
throw new Error("Alipay response order mismatch");
|
|
429
|
+
}
|
|
263
430
|
const statusMap = {
|
|
264
431
|
TRADE_SUCCESS: "paid",
|
|
265
432
|
TRADE_CLOSED: "closed",
|
|
@@ -268,10 +435,11 @@ function createAlipayProvider(config) {
|
|
|
268
435
|
};
|
|
269
436
|
return ok({
|
|
270
437
|
orderNo,
|
|
271
|
-
transactionId: data
|
|
272
|
-
status: statusMap[data
|
|
273
|
-
amount:
|
|
274
|
-
|
|
438
|
+
transactionId: data.trade_no,
|
|
439
|
+
status: statusMap[data.trade_status],
|
|
440
|
+
amount: parseAlipayAmount(data.total_amount),
|
|
441
|
+
// 支付宝时间为北京时间,不能依赖节点的本地时区解释。
|
|
442
|
+
paidAt: data.send_pay_date ? parseAlipayTime(data.send_pay_date) : void 0
|
|
275
443
|
});
|
|
276
444
|
} catch (cause) {
|
|
277
445
|
return err(
|
|
@@ -289,16 +457,19 @@ function createAlipayProvider(config) {
|
|
|
289
457
|
biz_content: JSON.stringify({
|
|
290
458
|
out_trade_no: input.orderNo,
|
|
291
459
|
out_request_no: input.refundNo,
|
|
292
|
-
refund_amount: (input.amount
|
|
460
|
+
refund_amount: formatFenAmount(input.amount),
|
|
293
461
|
refund_reason: input.reason
|
|
294
462
|
})
|
|
295
463
|
};
|
|
296
|
-
const
|
|
297
|
-
|
|
464
|
+
const data = await alipayRequest(params, AlipayRefundResponseSchema);
|
|
465
|
+
if (data.out_trade_no !== input.orderNo) {
|
|
466
|
+
throw new Error("Alipay response order mismatch");
|
|
467
|
+
}
|
|
468
|
+
parseAlipayAmount(data.refund_fee);
|
|
298
469
|
return ok({
|
|
299
470
|
refundNo: input.refundNo,
|
|
300
|
-
refundId: data
|
|
301
|
-
status: data
|
|
471
|
+
refundId: data.trade_no,
|
|
472
|
+
status: data.fund_change === "Y" ? "success" : "processing"
|
|
302
473
|
});
|
|
303
474
|
} catch (cause) {
|
|
304
475
|
return err(
|
|
@@ -315,7 +486,10 @@ function createAlipayProvider(config) {
|
|
|
315
486
|
...buildCommonParams(method, ""),
|
|
316
487
|
biz_content: JSON.stringify({ out_trade_no: orderNo })
|
|
317
488
|
};
|
|
318
|
-
await alipayRequest(params);
|
|
489
|
+
const data = await alipayRequest(params, AlipayCloseResponseSchema);
|
|
490
|
+
if (data.out_trade_no !== orderNo) {
|
|
491
|
+
throw new Error("Alipay response order mismatch");
|
|
492
|
+
}
|
|
319
493
|
return ok(void 0);
|
|
320
494
|
} catch (cause) {
|
|
321
495
|
return err(
|
|
@@ -327,16 +501,237 @@ function createAlipayProvider(config) {
|
|
|
327
501
|
}
|
|
328
502
|
};
|
|
329
503
|
}
|
|
504
|
+
|
|
505
|
+
// src/providers/payment-provider-notify.ts
|
|
506
|
+
function getNotifyHeader(headers, name) {
|
|
507
|
+
const values = Object.entries(headers).filter(([key]) => key.toLowerCase() === name.toLowerCase());
|
|
508
|
+
return values.length === 1 ? values[0][1] : void 0;
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
// src/providers/stripe/payment-stripe-repository-operation.ts
|
|
512
|
+
var STRIPE_OPERATION_TABLE = "hai_payment_stripe_operations";
|
|
513
|
+
var SAFE_RETRY_SECONDS = 23 * 60 * 60;
|
|
514
|
+
var StripeOperationRepository = class {
|
|
515
|
+
constructor(db) {
|
|
516
|
+
this.db = db;
|
|
517
|
+
}
|
|
518
|
+
db;
|
|
519
|
+
/** 建表成功后才能发布 Provider;失败由 payment.init 返回。 */
|
|
520
|
+
async initialize() {
|
|
521
|
+
return this.db.ddl.createTable(STRIPE_OPERATION_TABLE, {
|
|
522
|
+
id: { type: "TEXT", primaryKey: true },
|
|
523
|
+
request_hash: { type: "TEXT", notNull: true },
|
|
524
|
+
result_json: { type: "JSON" },
|
|
525
|
+
payment_intent: { type: "TEXT" },
|
|
526
|
+
created_at: { type: "BIGINT", notNull: true }
|
|
527
|
+
}, true);
|
|
528
|
+
}
|
|
529
|
+
/** 数据库时间避免不同应用节点的系统时钟影响安全重试窗口。 */
|
|
530
|
+
nowSql() {
|
|
531
|
+
switch (this.db.config?.type) {
|
|
532
|
+
case "sqlite":
|
|
533
|
+
return "CAST(strftime('%s', 'now') AS INTEGER)";
|
|
534
|
+
case "postgresql":
|
|
535
|
+
return "CAST(EXTRACT(EPOCH FROM CURRENT_TIMESTAMP) AS BIGINT)";
|
|
536
|
+
case "mysql":
|
|
537
|
+
return "UNIX_TIMESTAMP()";
|
|
538
|
+
default:
|
|
539
|
+
throw new Error("Stripe operation repository requires an initialized database");
|
|
540
|
+
}
|
|
541
|
+
}
|
|
542
|
+
/**
|
|
543
|
+
* 读取持久操作记录,以共享数据库时间计算安全重试窗口,不根据渠道 metadata 列表猜订单。
|
|
544
|
+
* @param id - 按账户、模式、操作类型和业务标识生成的稳定摘要 ID。
|
|
545
|
+
* @returns 已保留的记录;无记录时为 null,result 为 null 仅代表结果未知。
|
|
546
|
+
* @throws 查询失败或持久结果 JSON 非法时抛错,由最近的 Provider 边界转为 HaiResult。
|
|
547
|
+
* @example 模块内部上下文
|
|
548
|
+
* ```ts
|
|
549
|
+
* async function lookup(repository: StripeOperationRepository, operationId: string) {
|
|
550
|
+
* const record = await repository.load(operationId)
|
|
551
|
+
* if (record?.result === null && !record.canRetry) {
|
|
552
|
+
* throw new Error('Reconcile the channel outcome before retrying')
|
|
553
|
+
* }
|
|
554
|
+
* return record
|
|
555
|
+
* }
|
|
556
|
+
* ```
|
|
557
|
+
*/
|
|
558
|
+
async load(id) {
|
|
559
|
+
const result = await this.db.sql.get(
|
|
560
|
+
`SELECT request_hash, result_json, payment_intent,
|
|
561
|
+
CASE WHEN created_at > (${this.nowSql()} - ${SAFE_RETRY_SECONDS}) THEN 1 ELSE 0 END AS can_retry
|
|
562
|
+
FROM ${STRIPE_OPERATION_TABLE} WHERE id = ?`,
|
|
563
|
+
[id]
|
|
564
|
+
);
|
|
565
|
+
if (!result.success)
|
|
566
|
+
throw new Error("Failed to load Stripe operation", { cause: result.error });
|
|
567
|
+
if (!result.data)
|
|
568
|
+
return null;
|
|
569
|
+
const row = result.data;
|
|
570
|
+
return {
|
|
571
|
+
requestHash: row.request_hash,
|
|
572
|
+
result: typeof row.result_json === "string" ? JSON.parse(row.result_json) : row.result_json,
|
|
573
|
+
paymentIntent: row.payment_intent,
|
|
574
|
+
canRetry: row.can_retry === true || row.can_retry === 1 || row.can_retry === "1"
|
|
575
|
+
};
|
|
576
|
+
}
|
|
577
|
+
/**
|
|
578
|
+
* 通过主键插入原子预留;并发节点只接受相同请求摘要,不覆盖已有参数或删除未知操作。
|
|
579
|
+
* @param id - 所有节点复用的稳定操作 ID,也是渠道幂等键。
|
|
580
|
+
* @param requestHash - 路径和排序后表单参数的规范摘要。
|
|
581
|
+
* @returns 数据库中唯一的操作记录;已有 result 必须复用而非重新请求渠道。
|
|
582
|
+
* @throws 预留/读取失败或同标识复用不同参数时抛错,由 Provider 转为操作失败。
|
|
583
|
+
* @example 模块内部上下文
|
|
584
|
+
* ```ts
|
|
585
|
+
* async function reserveOnce(repository: StripeOperationRepository, id: string, requestHash: string) {
|
|
586
|
+
* const record = await repository.reserve(id, requestHash)
|
|
587
|
+
* // result=null 可能是渠道执行后回执丢失;重试仍须原 id 且 canRetry=true。
|
|
588
|
+
* if (record.result === null && !record.canRetry) throw new Error('Channel reconciliation required')
|
|
589
|
+
* return record
|
|
590
|
+
* }
|
|
591
|
+
* ```
|
|
592
|
+
*/
|
|
593
|
+
async reserve(id, requestHash) {
|
|
594
|
+
let record = await this.load(id);
|
|
595
|
+
if (!record) {
|
|
596
|
+
const inserted = await this.db.sql.execute(
|
|
597
|
+
`INSERT INTO ${STRIPE_OPERATION_TABLE} (id, request_hash, created_at) VALUES (?, ?, ${this.nowSql()})`,
|
|
598
|
+
[id, requestHash]
|
|
599
|
+
);
|
|
600
|
+
record = await this.load(id);
|
|
601
|
+
if (!record)
|
|
602
|
+
throw new Error("Failed to reserve Stripe operation", { cause: inserted.success ? void 0 : inserted.error });
|
|
603
|
+
}
|
|
604
|
+
if (record.requestHash !== requestHash)
|
|
605
|
+
throw new Error("Stripe operation identifier was reused with different parameters");
|
|
606
|
+
return record;
|
|
607
|
+
}
|
|
608
|
+
/**
|
|
609
|
+
* 以请求摘要和未完成状态作 CAS 发布渠道结果,再读取已持久化的唯一结果。
|
|
610
|
+
* @param id - 已成功预留的稳定操作 ID。
|
|
611
|
+
* @param requestHash - 与预留完全一致的规范请求摘要。
|
|
612
|
+
* @param result - 已经由渠道结果 Schema 校验、允许持久化的最小结果。
|
|
613
|
+
* @param paymentIntent - 已核对身份的 PaymentIntent;创建时尚未知可省略。
|
|
614
|
+
* @returns 数据库确认的结果,可能由并发完成者先写入;调用者须再按结果 Schema 解析。
|
|
615
|
+
* @throws 更新、重读或持久状态核对失败时抛错;不将未知状态伪装为成功。
|
|
616
|
+
* @example 模块内部上下文
|
|
617
|
+
* ```ts
|
|
618
|
+
* async function persistConfirmed(repository: StripeOperationRepository, id: string, digest: string, confirmedMapping: unknown) {
|
|
619
|
+
* // 调用前渠道响应已校验;digest 来自首次预留,不能以新参数覆盖旧操作。
|
|
620
|
+
* return repository.complete(id, digest, confirmedMapping)
|
|
621
|
+
* }
|
|
622
|
+
* ```
|
|
623
|
+
*/
|
|
624
|
+
async complete(id, requestHash, result, paymentIntent) {
|
|
625
|
+
const updated = await this.db.sql.execute(
|
|
626
|
+
`UPDATE ${STRIPE_OPERATION_TABLE} SET result_json = ?, payment_intent = ?
|
|
627
|
+
WHERE id = ? AND request_hash = ? AND result_json IS NULL`,
|
|
628
|
+
[JSON.stringify(result), paymentIntent ?? null, id, requestHash]
|
|
629
|
+
);
|
|
630
|
+
if (!updated.success)
|
|
631
|
+
throw new Error("Failed to persist Stripe operation result", { cause: updated.error });
|
|
632
|
+
const persisted = await this.load(id);
|
|
633
|
+
if (!persisted || persisted.requestHash !== requestHash || persisted.result === null)
|
|
634
|
+
throw new Error("Stripe operation result was not persisted");
|
|
635
|
+
return persisted.result;
|
|
636
|
+
}
|
|
637
|
+
/**
|
|
638
|
+
* 只在订单映射已完成时首次补全或幂等确认同一 PaymentIntent,禁止迟到写覆盖渠道身份。
|
|
639
|
+
* @param id - 已确认 Checkout Session 映射的订单操作 ID。
|
|
640
|
+
* @param paymentIntent - 从该 Session 读取且通过订单/金额/币种核对的渠道标识。
|
|
641
|
+
* @throws 写入失败、订单未完成或已绑定不同 PaymentIntent 时抛错,由 Provider 转为失败。
|
|
642
|
+
* @example 模块内部上下文
|
|
643
|
+
* ```ts
|
|
644
|
+
* async function bindVerifiedIntent(repository: StripeOperationRepository, orderOperationId: string, verifiedIntentId: string) {
|
|
645
|
+
* await repository.rememberPaymentIntent(orderOperationId, verifiedIntentId)
|
|
646
|
+
* // 仅确认渠道身份映射,不代表宿主业务已履约。
|
|
647
|
+
* }
|
|
648
|
+
* ```
|
|
649
|
+
*/
|
|
650
|
+
async rememberPaymentIntent(id, paymentIntent) {
|
|
651
|
+
const result = await this.db.sql.execute(
|
|
652
|
+
`UPDATE ${STRIPE_OPERATION_TABLE} SET payment_intent = ?
|
|
653
|
+
WHERE id = ? AND result_json IS NOT NULL AND (payment_intent IS NULL OR payment_intent = ?)`,
|
|
654
|
+
[paymentIntent, id, paymentIntent]
|
|
655
|
+
);
|
|
656
|
+
if (!result.success)
|
|
657
|
+
throw new Error("Failed to persist Stripe PaymentIntent", { cause: result.error });
|
|
658
|
+
const record = await this.load(id);
|
|
659
|
+
if (!record || record.paymentIntent !== paymentIntent)
|
|
660
|
+
throw new Error("Stripe PaymentIntent conflicts with the persisted mapping");
|
|
661
|
+
}
|
|
662
|
+
};
|
|
663
|
+
|
|
664
|
+
// src/providers/stripe/payment-provider-stripe.ts
|
|
330
665
|
var STRIPE_API_BASE = "https://api.stripe.com/v1";
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
666
|
+
var STRIPE_API_VERSION = "2026-09-30.endive";
|
|
667
|
+
var StripeCreatedSessionSchema = z.object({
|
|
668
|
+
id: z.string().min(1),
|
|
669
|
+
url: z.url(),
|
|
670
|
+
payment_intent: z.string().min(1).nullable().optional()
|
|
671
|
+
});
|
|
672
|
+
var StripeOrderMappingSchema = z.object({
|
|
673
|
+
orderNo: z.string().min(1),
|
|
674
|
+
amount: z.number().int().positive(),
|
|
675
|
+
currency: z.string().regex(/^[A-Z]{3}$/),
|
|
676
|
+
session: StripeCreatedSessionSchema
|
|
677
|
+
});
|
|
678
|
+
var StripeRetrievedSessionSchema = z.object({
|
|
679
|
+
id: z.string().min(1),
|
|
680
|
+
metadata: z.object({ orderNo: z.string().min(1) }),
|
|
681
|
+
amount_total: z.number().int().nonnegative(),
|
|
682
|
+
currency: z.string().regex(/^[a-z]{3}$/i),
|
|
683
|
+
payment_status: z.enum(["paid", "unpaid", "no_payment_required"]),
|
|
684
|
+
payment_intent: z.string().min(1).nullable(),
|
|
685
|
+
status: z.enum(["open", "complete", "expired"])
|
|
686
|
+
});
|
|
687
|
+
var StripeRefundSchema = z.object({
|
|
688
|
+
id: z.string().min(1),
|
|
689
|
+
status: z.enum(["pending", "requires_action", "succeeded", "failed", "canceled"])
|
|
690
|
+
});
|
|
691
|
+
var StripeCheckoutEventSchema = z.object({
|
|
692
|
+
id: z.string().min(1),
|
|
693
|
+
livemode: z.boolean(),
|
|
694
|
+
// 本 Provider 只支持当前账户,不能消费 Connect/组织上下文的其它商户事件。
|
|
695
|
+
account: z.never().optional(),
|
|
696
|
+
context: z.never().optional(),
|
|
697
|
+
created: z.number().int().nonnegative().max(864e10),
|
|
698
|
+
type: z.enum([
|
|
699
|
+
"checkout.session.completed",
|
|
700
|
+
"checkout.session.async_payment_succeeded",
|
|
701
|
+
"checkout.session.async_payment_failed",
|
|
702
|
+
"checkout.session.expired"
|
|
703
|
+
]),
|
|
704
|
+
data: z.object({
|
|
705
|
+
object: z.object({
|
|
706
|
+
object: z.literal("checkout.session"),
|
|
707
|
+
id: z.string().min(1),
|
|
708
|
+
mode: z.literal("payment"),
|
|
709
|
+
metadata: z.object({ orderNo: z.string().min(1).regex(/\S/) }).passthrough(),
|
|
710
|
+
amount_total: z.number().int().nonnegative(),
|
|
711
|
+
currency: z.string().regex(/^[a-z]{3}$/i),
|
|
712
|
+
payment_status: z.enum(["paid", "unpaid", "no_payment_required"])
|
|
713
|
+
}).passthrough()
|
|
714
|
+
}).passthrough()
|
|
715
|
+
}).passthrough().refine((event) => {
|
|
716
|
+
const paid = event.data.object.payment_status !== "unpaid";
|
|
717
|
+
if (event.type === "checkout.session.async_payment_succeeded")
|
|
718
|
+
return paid;
|
|
719
|
+
if (event.type === "checkout.session.async_payment_failed" || event.type === "checkout.session.expired")
|
|
720
|
+
return !paid;
|
|
721
|
+
return true;
|
|
722
|
+
}, { message: "Checkout event type contradicts payment status" });
|
|
723
|
+
function createStripeProvider(config, repository = new StripeOperationRepository(reldb)) {
|
|
724
|
+
let accountScopePromise;
|
|
725
|
+
async function stripeRequest(method, path, options) {
|
|
726
|
+
const response = await fetchWithTimeout(`${STRIPE_API_BASE}${path}`, {
|
|
334
727
|
method,
|
|
335
728
|
headers: {
|
|
336
729
|
"Authorization": `Bearer ${config.secretKey}`,
|
|
337
|
-
"Content-Type": "application/x-www-form-urlencoded"
|
|
730
|
+
"Content-Type": "application/x-www-form-urlencoded",
|
|
731
|
+
"Stripe-Version": STRIPE_API_VERSION,
|
|
732
|
+
...options?.idempotencyKey ? { "Idempotency-Key": options.idempotencyKey } : {}
|
|
338
733
|
},
|
|
339
|
-
body: body ? Object.entries(body).map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(v)}`).join("&") : void 0
|
|
734
|
+
body: options?.body ? Object.entries(options.body).sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0).map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(v)}`).join("&") : void 0
|
|
340
735
|
});
|
|
341
736
|
if (!response.ok) {
|
|
342
737
|
const errorText = await response.text();
|
|
@@ -344,19 +739,68 @@ function createStripeProvider(config) {
|
|
|
344
739
|
}
|
|
345
740
|
return response.json();
|
|
346
741
|
}
|
|
347
|
-
|
|
742
|
+
async function accountScope() {
|
|
743
|
+
if (!accountScopePromise) {
|
|
744
|
+
accountScopePromise = (async () => {
|
|
745
|
+
const mode = /^(?:sk|rk)_(test|live)_/.exec(config.secretKey)?.[1];
|
|
746
|
+
if (!mode)
|
|
747
|
+
throw new Error("Stripe requires a test or live secret/restricted API key");
|
|
748
|
+
const account = z.object({ id: z.string().regex(/^acct_/).min(6) }).parse(await stripeRequest("GET", "/account"));
|
|
749
|
+
return `${account.id}:${mode}`;
|
|
750
|
+
})().catch((error) => {
|
|
751
|
+
accountScopePromise = void 0;
|
|
752
|
+
throw error;
|
|
753
|
+
});
|
|
754
|
+
}
|
|
755
|
+
return accountScopePromise;
|
|
756
|
+
}
|
|
757
|
+
async function operationId(kind, businessId) {
|
|
758
|
+
const digest = createHash("sha256").update(JSON.stringify([await accountScope(), kind, businessId])).digest("hex");
|
|
759
|
+
return `hai-payment-${kind}-${digest}`;
|
|
760
|
+
}
|
|
761
|
+
async function executeOperation(options) {
|
|
762
|
+
const requestHash = createHash("sha256").update(JSON.stringify([
|
|
763
|
+
options.path,
|
|
764
|
+
Object.entries(options.body).sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0)
|
|
765
|
+
])).digest("hex");
|
|
766
|
+
const reserved = await repository.reserve(options.id, requestHash);
|
|
767
|
+
if (reserved.result !== null)
|
|
768
|
+
return options.resultSchema.parse(reserved.result);
|
|
769
|
+
if (!reserved.canRetry)
|
|
770
|
+
throw new Error("Stripe operation outcome is unknown beyond the safe retry window; reconcile before retrying");
|
|
771
|
+
const result = options.execute(await stripeRequest("POST", options.path, { body: options.body, idempotencyKey: options.id }));
|
|
772
|
+
return options.resultSchema.parse(await repository.complete(options.id, requestHash, result, options.paymentIntent?.(result)));
|
|
773
|
+
}
|
|
774
|
+
async function retrieveOrder(orderNo) {
|
|
775
|
+
const id = await operationId("order", orderNo);
|
|
776
|
+
const operation = await repository.load(id);
|
|
777
|
+
if (!operation || operation.result === null)
|
|
778
|
+
throw new Error("Stripe order has no confirmed persisted Session mapping");
|
|
779
|
+
const mapping = StripeOrderMappingSchema.parse(operation.result);
|
|
780
|
+
const session = StripeRetrievedSessionSchema.parse(await stripeRequest("GET", `/checkout/sessions/${encodeURIComponent(mapping.session.id)}`));
|
|
781
|
+
if (session.id !== mapping.session.id || session.metadata.orderNo !== orderNo || mapping.orderNo !== orderNo || session.amount_total !== mapping.amount || session.currency.toUpperCase() !== mapping.currency) {
|
|
782
|
+
throw new Error("Stripe Session does not match the persisted order");
|
|
783
|
+
}
|
|
784
|
+
if (session.payment_intent)
|
|
785
|
+
await repository.rememberPaymentIntent(id, session.payment_intent);
|
|
786
|
+
return session;
|
|
787
|
+
}
|
|
788
|
+
const TIMESTAMP_TOLERANCE = config.webhookToleranceSeconds ?? 300;
|
|
348
789
|
function verifyWebhookSignature(payload, signature) {
|
|
349
|
-
const parts = signature.split(",");
|
|
350
|
-
const
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
790
|
+
const parts = signature.split(",").map((part) => part.trim());
|
|
791
|
+
const timestamps = parts.filter((p) => p.startsWith("t="));
|
|
792
|
+
if (timestamps.length !== 1)
|
|
793
|
+
return false;
|
|
794
|
+
const timestamp = timestamps[0].slice(2);
|
|
795
|
+
const signatures = parts.filter((p) => p.startsWith("v1=")).map((p) => p.slice(3));
|
|
796
|
+
const ts = Number(timestamp);
|
|
797
|
+
if (!/^(?:0|[1-9]\d{0,11})$/.test(timestamp) || !Number.isSafeInteger(ts) || Math.abs(Math.floor(Date.now() / 1e3) - ts) > TIMESTAMP_TOLERANCE) {
|
|
354
798
|
return false;
|
|
355
799
|
}
|
|
356
800
|
const signedPayload = `${timestamp}.${payload}`;
|
|
357
801
|
const expected = createHmac("sha256", config.webhookSecret).update(signedPayload).digest("hex");
|
|
358
802
|
try {
|
|
359
|
-
return core.string.constantTimeEqual(v1, expected);
|
|
803
|
+
return signatures.some((v1) => /^[a-f0-9]{64}$/.test(v1) && core.string.constantTimeEqual(v1, expected));
|
|
360
804
|
} catch {
|
|
361
805
|
return false;
|
|
362
806
|
}
|
|
@@ -364,27 +808,48 @@ function createStripeProvider(config) {
|
|
|
364
808
|
return {
|
|
365
809
|
name: "stripe",
|
|
366
810
|
async createOrder(input) {
|
|
811
|
+
if (input.tradeType !== "h5")
|
|
812
|
+
return err(HaiPaymentError.UNSUPPORTED_TRADE_TYPE, paymentM("payment_unsupportedTradeType"));
|
|
367
813
|
try {
|
|
814
|
+
z.string().min(1).regex(/\S/).parse(input.orderNo);
|
|
815
|
+
if (input.metadata && Object.hasOwn(input.metadata, "orderNo"))
|
|
816
|
+
throw new Error("Stripe metadata orderNo is reserved");
|
|
368
817
|
const params = {
|
|
369
818
|
"mode": "payment",
|
|
819
|
+
"ui_mode": "hosted_page",
|
|
820
|
+
"success_url": config.successUrl,
|
|
370
821
|
"line_items[0][price_data][currency]": input.currency?.toLowerCase() ?? "usd",
|
|
371
822
|
"line_items[0][price_data][product_data][name]": input.description,
|
|
372
823
|
"line_items[0][price_data][unit_amount]": input.amount.toString(),
|
|
373
824
|
"line_items[0][quantity]": "1"
|
|
374
825
|
};
|
|
826
|
+
if (config.cancelUrl)
|
|
827
|
+
params.cancel_url = config.cancelUrl;
|
|
375
828
|
params["metadata[orderNo]"] = input.orderNo;
|
|
376
829
|
if (input.metadata) {
|
|
377
830
|
for (const [k, v] of Object.entries(input.metadata)) {
|
|
378
831
|
params[`metadata[${k}]`] = v;
|
|
379
832
|
}
|
|
380
833
|
}
|
|
381
|
-
const
|
|
834
|
+
const mapping = await executeOperation({
|
|
835
|
+
id: await operationId("order", input.orderNo),
|
|
836
|
+
path: "/checkout/sessions",
|
|
837
|
+
body: params,
|
|
838
|
+
resultSchema: StripeOrderMappingSchema,
|
|
839
|
+
execute: (raw) => ({
|
|
840
|
+
orderNo: input.orderNo,
|
|
841
|
+
amount: input.amount,
|
|
842
|
+
currency: input.currency?.toUpperCase() ?? "USD",
|
|
843
|
+
session: StripeCreatedSessionSchema.parse(raw)
|
|
844
|
+
}),
|
|
845
|
+
paymentIntent: (result) => result.session.payment_intent
|
|
846
|
+
});
|
|
382
847
|
return ok({
|
|
383
848
|
provider: "stripe",
|
|
384
849
|
tradeType: input.tradeType,
|
|
385
850
|
clientParams: {
|
|
386
|
-
sessionId: session.id,
|
|
387
|
-
checkoutUrl: session.url
|
|
851
|
+
sessionId: mapping.session.id,
|
|
852
|
+
checkoutUrl: mapping.session.url
|
|
388
853
|
}
|
|
389
854
|
});
|
|
390
855
|
} catch (cause) {
|
|
@@ -397,7 +862,7 @@ function createStripeProvider(config) {
|
|
|
397
862
|
},
|
|
398
863
|
async handleNotify(request) {
|
|
399
864
|
try {
|
|
400
|
-
const signature = request.headers
|
|
865
|
+
const signature = getNotifyHeader(request.headers, "stripe-signature") ?? "";
|
|
401
866
|
const valid = verifyWebhookSignature(request.body, signature);
|
|
402
867
|
if (!valid) {
|
|
403
868
|
return err(
|
|
@@ -405,40 +870,37 @@ function createStripeProvider(config) {
|
|
|
405
870
|
paymentM("payment_notifyVerifyFailed")
|
|
406
871
|
);
|
|
407
872
|
}
|
|
408
|
-
const
|
|
873
|
+
const parsed = StripeCheckoutEventSchema.safeParse(JSON.parse(request.body));
|
|
874
|
+
if (!parsed.success)
|
|
875
|
+
throw new Error("Invalid Stripe payment event");
|
|
876
|
+
const event = parsed.data;
|
|
877
|
+
if (event.livemode !== /^(?:sk|rk)_live_/.test(config.secretKey) || event.created > Math.floor(Date.now() / 1e3) + TIMESTAMP_TOLERANCE) {
|
|
878
|
+
return err(HaiPaymentError.NOTIFY_VERIFY_FAILED, paymentM("payment_notifyVerifyFailed"));
|
|
879
|
+
}
|
|
409
880
|
const obj = event.data.object;
|
|
410
|
-
const
|
|
411
|
-
const statusMap = {
|
|
412
|
-
"checkout.session.completed": "paid",
|
|
413
|
-
"payment_intent.payment_failed": "failed"
|
|
414
|
-
};
|
|
881
|
+
const status = event.type === "checkout.session.expired" ? "closed" : event.type === "checkout.session.async_payment_failed" ? "failed" : obj.payment_status === "unpaid" ? "pending" : "paid";
|
|
415
882
|
return ok({
|
|
416
|
-
orderNo,
|
|
883
|
+
orderNo: obj.metadata.orderNo,
|
|
417
884
|
transactionId: obj.id,
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
885
|
+
eventId: event.id,
|
|
886
|
+
amount: obj.amount_total,
|
|
887
|
+
currency: obj.currency.toUpperCase(),
|
|
888
|
+
status,
|
|
889
|
+
// 使用支付成功事件的时间,使重复通知不会改变 paidAt;pending/failed/closed 不宣称已支付。
|
|
890
|
+
paidAt: status === "paid" ? new Date(event.created * 1e3) : void 0,
|
|
421
891
|
raw: event
|
|
422
892
|
});
|
|
423
|
-
} catch
|
|
893
|
+
} catch {
|
|
424
894
|
return err(
|
|
425
895
|
HaiPaymentError.NOTIFY_PARSE_FAILED,
|
|
426
896
|
paymentM("payment_notifyParseFailed"),
|
|
427
|
-
|
|
897
|
+
new Error("Invalid Stripe payment notification")
|
|
428
898
|
);
|
|
429
899
|
}
|
|
430
900
|
},
|
|
431
901
|
async queryOrder(orderNo) {
|
|
432
902
|
try {
|
|
433
|
-
const
|
|
434
|
-
const session = sessions.data[0];
|
|
435
|
-
if (!session) {
|
|
436
|
-
return ok({
|
|
437
|
-
orderNo,
|
|
438
|
-
status: "pending",
|
|
439
|
-
amount: 0
|
|
440
|
-
});
|
|
441
|
-
}
|
|
903
|
+
const session = await retrieveOrder(orderNo);
|
|
442
904
|
const statusMap = {
|
|
443
905
|
paid: "paid",
|
|
444
906
|
unpaid: "pending",
|
|
@@ -447,7 +909,7 @@ function createStripeProvider(config) {
|
|
|
447
909
|
return ok({
|
|
448
910
|
orderNo,
|
|
449
911
|
transactionId: session.id,
|
|
450
|
-
status: statusMap[session.payment_status]
|
|
912
|
+
status: session.status === "expired" ? "closed" : statusMap[session.payment_status],
|
|
451
913
|
amount: session.amount_total
|
|
452
914
|
});
|
|
453
915
|
} catch (cause) {
|
|
@@ -460,23 +922,34 @@ function createStripeProvider(config) {
|
|
|
460
922
|
},
|
|
461
923
|
async refund(input) {
|
|
462
924
|
try {
|
|
463
|
-
|
|
464
|
-
const
|
|
465
|
-
if (!
|
|
925
|
+
z.string().min(1).regex(/\S/).parse(input.refundNo);
|
|
926
|
+
const session = await retrieveOrder(input.orderNo);
|
|
927
|
+
if (!session.payment_intent || session.payment_status !== "paid") {
|
|
466
928
|
return err(
|
|
467
929
|
HaiPaymentError.REFUND_FAILED,
|
|
468
930
|
paymentM("payment_refundFailed")
|
|
469
931
|
);
|
|
470
932
|
}
|
|
471
|
-
const
|
|
472
|
-
payment_intent:
|
|
473
|
-
amount: input.amount.toString(),
|
|
474
|
-
reason:
|
|
933
|
+
const body = {
|
|
934
|
+
"payment_intent": session.payment_intent,
|
|
935
|
+
"amount": input.amount.toString(),
|
|
936
|
+
"reason": "requested_by_customer",
|
|
937
|
+
"metadata[orderNo]": input.orderNo,
|
|
938
|
+
"metadata[refundNo]": input.refundNo
|
|
939
|
+
};
|
|
940
|
+
if (input.reason)
|
|
941
|
+
body["metadata[reason]"] = input.reason;
|
|
942
|
+
const refund2 = await executeOperation({
|
|
943
|
+
id: await operationId("refund", input.refundNo),
|
|
944
|
+
path: "/refunds",
|
|
945
|
+
body,
|
|
946
|
+
resultSchema: StripeRefundSchema,
|
|
947
|
+
execute: (raw) => StripeRefundSchema.parse(raw)
|
|
475
948
|
});
|
|
476
949
|
return ok({
|
|
477
950
|
refundNo: input.refundNo,
|
|
478
951
|
refundId: refund2.id,
|
|
479
|
-
status: refund2.status === "succeeded" ? "success" : "processing"
|
|
952
|
+
status: refund2.status === "succeeded" ? "success" : refund2.status === "failed" || refund2.status === "canceled" ? "failed" : "processing"
|
|
480
953
|
});
|
|
481
954
|
} catch (cause) {
|
|
482
955
|
return err(
|
|
@@ -486,8 +959,31 @@ function createStripeProvider(config) {
|
|
|
486
959
|
);
|
|
487
960
|
}
|
|
488
961
|
},
|
|
489
|
-
async closeOrder(
|
|
490
|
-
|
|
962
|
+
async closeOrder(orderNo) {
|
|
963
|
+
try {
|
|
964
|
+
const session = await retrieveOrder(orderNo);
|
|
965
|
+
if (session.payment_status !== "unpaid" || session.status === "complete")
|
|
966
|
+
throw new Error("A completed or paid Stripe Session cannot be closed");
|
|
967
|
+
if (session.status === "expired")
|
|
968
|
+
return ok(void 0);
|
|
969
|
+
try {
|
|
970
|
+
const expired = z.object({ id: z.string(), status: z.literal("expired") }).parse(
|
|
971
|
+
await stripeRequest("POST", `/checkout/sessions/${encodeURIComponent(session.id)}/expire`, {
|
|
972
|
+
body: {},
|
|
973
|
+
idempotencyKey: await operationId("close", orderNo)
|
|
974
|
+
})
|
|
975
|
+
);
|
|
976
|
+
if (expired.id !== session.id)
|
|
977
|
+
throw new Error("Stripe expiration response does not match the requested Session");
|
|
978
|
+
} catch (cause) {
|
|
979
|
+
const confirmed = await retrieveOrder(orderNo);
|
|
980
|
+
if (confirmed.status !== "expired" || confirmed.payment_status !== "unpaid")
|
|
981
|
+
throw cause;
|
|
982
|
+
}
|
|
983
|
+
return ok(void 0);
|
|
984
|
+
} catch (cause) {
|
|
985
|
+
return err(HaiPaymentError.CLOSE_ORDER_FAILED, paymentM("payment_closeOrderFailed"), cause);
|
|
986
|
+
}
|
|
491
987
|
}
|
|
492
988
|
};
|
|
493
989
|
}
|
|
@@ -518,14 +1014,14 @@ prepay_id=${prepayId}
|
|
|
518
1014
|
sign.update(message);
|
|
519
1015
|
return sign.sign(privateKey, "base64");
|
|
520
1016
|
}
|
|
521
|
-
function verifyNotifySignature(timestamp, nonce, body, signature,
|
|
1017
|
+
function verifyNotifySignature(timestamp, nonce, body, signature, platformKey) {
|
|
522
1018
|
const message = `${timestamp}
|
|
523
1019
|
${nonce}
|
|
524
1020
|
${body}
|
|
525
1021
|
`;
|
|
526
1022
|
const verify = createVerify("RSA-SHA256");
|
|
527
1023
|
verify.update(message);
|
|
528
|
-
return verify.verify(
|
|
1024
|
+
return verify.verify(platformKey, signature, "base64");
|
|
529
1025
|
}
|
|
530
1026
|
function decryptResource(ciphertext, nonce, associatedData, apiV3Key) {
|
|
531
1027
|
const buf = Buffer.from(ciphertext, "base64");
|
|
@@ -537,17 +1033,67 @@ function decryptResource(ciphertext, nonce, associatedData, apiV3Key) {
|
|
|
537
1033
|
const decrypted = Buffer.concat([decipher.update(data), decipher.final()]);
|
|
538
1034
|
return decrypted.toString("utf-8");
|
|
539
1035
|
}
|
|
1036
|
+
var WechatNotifyEnvelopeSchema = z.object({
|
|
1037
|
+
id: z.string().min(1),
|
|
1038
|
+
create_time: z.iso.datetime({ offset: true }),
|
|
1039
|
+
event_type: z.literal("TRANSACTION.SUCCESS"),
|
|
1040
|
+
resource_type: z.literal("encrypt-resource"),
|
|
1041
|
+
resource: z.object({
|
|
1042
|
+
algorithm: z.literal("AEAD_AES_256_GCM"),
|
|
1043
|
+
original_type: z.literal("transaction"),
|
|
1044
|
+
ciphertext: z.string().min(1),
|
|
1045
|
+
nonce: z.string().min(1),
|
|
1046
|
+
associated_data: z.string().optional()
|
|
1047
|
+
})
|
|
1048
|
+
});
|
|
1049
|
+
var WechatNotifyResourceSchema = z.object({
|
|
1050
|
+
appid: z.string().min(1),
|
|
1051
|
+
mchid: z.string().min(1),
|
|
1052
|
+
out_trade_no: z.string().min(1),
|
|
1053
|
+
transaction_id: z.string().min(1),
|
|
1054
|
+
trade_state: z.literal("SUCCESS"),
|
|
1055
|
+
amount: z.object({
|
|
1056
|
+
total: z.number().int().positive().max(Number.MAX_SAFE_INTEGER),
|
|
1057
|
+
currency: z.string().regex(/^[A-Z]{3}$/)
|
|
1058
|
+
}).passthrough(),
|
|
1059
|
+
success_time: z.iso.datetime({ offset: true })
|
|
1060
|
+
}).passthrough();
|
|
540
1061
|
|
|
541
|
-
// src/providers/wechat/
|
|
1062
|
+
// src/providers/wechat/payment-provider-wechat.ts
|
|
542
1063
|
var WECHAT_API_BASE = "https://api.mch.weixin.qq.com";
|
|
543
1064
|
function createWechatPayProvider(config) {
|
|
1065
|
+
function verifyNotification(request) {
|
|
1066
|
+
try {
|
|
1067
|
+
const timestamp = getNotifyHeader(request.headers, "wechatpay-timestamp") ?? "";
|
|
1068
|
+
const nonce = getNotifyHeader(request.headers, "wechatpay-nonce") ?? "";
|
|
1069
|
+
const signature = getNotifyHeader(request.headers, "wechatpay-signature") ?? "";
|
|
1070
|
+
const serial = getNotifyHeader(request.headers, "wechatpay-serial") ?? "";
|
|
1071
|
+
const signatureType = getNotifyHeader(request.headers, "wechatpay-signature-type");
|
|
1072
|
+
const now = Date.now();
|
|
1073
|
+
if (!/^(?:0|[1-9]\d{0,11})$/.test(timestamp) || !Number.isSafeInteger(Number(timestamp)) || Math.abs(Math.floor(now / 1e3) - Number(timestamp)) > 300 || !nonce || !signature || !serial || signatureType !== void 0 && signatureType !== "WECHATPAY2-SHA256-RSA2048") {
|
|
1074
|
+
return false;
|
|
1075
|
+
}
|
|
1076
|
+
const key = Object.hasOwn(config.platformKeys, serial) ? config.platformKeys[serial] : void 0;
|
|
1077
|
+
if (!key)
|
|
1078
|
+
return false;
|
|
1079
|
+
if (key.includes("-----BEGIN CERTIFICATE-----")) {
|
|
1080
|
+
const certificate = new X509Certificate(key);
|
|
1081
|
+
if (certificate.serialNumber.toUpperCase() !== serial.toUpperCase() || now < Date.parse(certificate.validFrom) || now > Date.parse(certificate.validTo)) {
|
|
1082
|
+
return false;
|
|
1083
|
+
}
|
|
1084
|
+
}
|
|
1085
|
+
return verifyNotifySignature(timestamp, nonce, request.body, signature, key);
|
|
1086
|
+
} catch {
|
|
1087
|
+
return false;
|
|
1088
|
+
}
|
|
1089
|
+
}
|
|
544
1090
|
async function wechatRequest(method, path, body) {
|
|
545
1091
|
const timestamp = getTimestamp();
|
|
546
1092
|
const nonce = generateNonce();
|
|
547
1093
|
const bodyStr = body ? JSON.stringify(body) : "";
|
|
548
1094
|
const signature = signRequest(method, path, timestamp, nonce, bodyStr, config.privateKey);
|
|
549
1095
|
const authorization = `WECHATPAY2-SHA256-RSA2048 mchid="${config.mchId}",nonce_str="${nonce}",timestamp="${timestamp}",serial_no="${config.serialNo}",signature="${signature}"`;
|
|
550
|
-
const response = await
|
|
1096
|
+
const response = await fetchWithTimeout(`${WECHAT_API_BASE}${path}`, {
|
|
551
1097
|
method,
|
|
552
1098
|
headers: {
|
|
553
1099
|
"Content-Type": "application/json",
|
|
@@ -562,18 +1108,14 @@ function createWechatPayProvider(config) {
|
|
|
562
1108
|
}
|
|
563
1109
|
return response.json();
|
|
564
1110
|
}
|
|
565
|
-
function getOrderPath(tradeType) {
|
|
566
|
-
const map = {
|
|
567
|
-
jsapi: "/v3/pay/transactions/jsapi",
|
|
568
|
-
h5: "/v3/pay/transactions/h5",
|
|
569
|
-
app: "/v3/pay/transactions/app",
|
|
570
|
-
native: "/v3/pay/transactions/native"
|
|
571
|
-
};
|
|
572
|
-
return map[tradeType] ?? "/v3/pay/transactions/jsapi";
|
|
573
|
-
}
|
|
574
1111
|
return {
|
|
575
1112
|
name: "wechat",
|
|
576
1113
|
async createOrder(input) {
|
|
1114
|
+
if (input.tradeType !== "jsapi")
|
|
1115
|
+
return err(HaiPaymentError.UNSUPPORTED_TRADE_TYPE, paymentM("payment_unsupportedTradeType"));
|
|
1116
|
+
if (typeof input.userId !== "string" || !input.userId.trim() || input.userId.length > 128 || input.currency !== void 0 && input.currency !== "CNY") {
|
|
1117
|
+
return err(HaiPaymentError.INVALID_INPUT, paymentM("payment_invalidCreateOrderInput"));
|
|
1118
|
+
}
|
|
577
1119
|
try {
|
|
578
1120
|
const requestBody = {
|
|
579
1121
|
appid: config.appId,
|
|
@@ -583,35 +1125,26 @@ function createWechatPayProvider(config) {
|
|
|
583
1125
|
notify_url: input.notifyUrl,
|
|
584
1126
|
amount: {
|
|
585
1127
|
total: input.amount,
|
|
586
|
-
currency:
|
|
587
|
-
}
|
|
1128
|
+
currency: "CNY"
|
|
1129
|
+
},
|
|
1130
|
+
payer: { openid: input.userId }
|
|
588
1131
|
};
|
|
589
|
-
if (input.tradeType === "jsapi" && input.userId) {
|
|
590
|
-
requestBody.payer = { openid: input.userId };
|
|
591
|
-
}
|
|
592
1132
|
if (input.metadata) {
|
|
593
1133
|
requestBody.attach = JSON.stringify(input.metadata);
|
|
594
1134
|
}
|
|
595
|
-
const
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
paySign
|
|
609
|
-
};
|
|
610
|
-
} else if (input.tradeType === "h5") {
|
|
611
|
-
clientParams = { h5Url: response.h5_url };
|
|
612
|
-
} else if (input.tradeType === "native") {
|
|
613
|
-
clientParams = { codeUrl: response.code_url };
|
|
614
|
-
}
|
|
1135
|
+
const response = await wechatRequest("POST", "/v3/pay/transactions/jsapi", requestBody);
|
|
1136
|
+
if (typeof response.prepay_id !== "string" || !response.prepay_id.trim())
|
|
1137
|
+
throw new Error("Invalid Wechat JSAPI prepay response");
|
|
1138
|
+
const timestamp = getTimestamp();
|
|
1139
|
+
const nonce = generateNonce();
|
|
1140
|
+
const clientParams = {
|
|
1141
|
+
appId: config.appId,
|
|
1142
|
+
timeStamp: timestamp,
|
|
1143
|
+
nonceStr: nonce,
|
|
1144
|
+
package: `prepay_id=${response.prepay_id}`,
|
|
1145
|
+
signType: "RSA",
|
|
1146
|
+
paySign: signJsapi(config.appId, timestamp, nonce, response.prepay_id, config.privateKey)
|
|
1147
|
+
};
|
|
615
1148
|
return ok({
|
|
616
1149
|
provider: "wechat",
|
|
617
1150
|
tradeType: input.tradeType,
|
|
@@ -628,43 +1161,44 @@ function createWechatPayProvider(config) {
|
|
|
628
1161
|
},
|
|
629
1162
|
async handleNotify(request) {
|
|
630
1163
|
try {
|
|
631
|
-
|
|
632
|
-
const nonce = request.headers["wechatpay-nonce"] ?? "";
|
|
633
|
-
const signature = request.headers["wechatpay-signature"] ?? "";
|
|
634
|
-
if (!config.platformCert) {
|
|
635
|
-
return err(
|
|
636
|
-
HaiPaymentError.NOTIFY_VERIFY_FAILED,
|
|
637
|
-
paymentM("payment_notifyVerifyFailed")
|
|
638
|
-
);
|
|
639
|
-
}
|
|
640
|
-
const valid = verifyNotifySignature(timestamp, nonce, request.body, signature, config.platformCert);
|
|
641
|
-
if (!valid) {
|
|
1164
|
+
if (!verifyNotification(request)) {
|
|
642
1165
|
return err(
|
|
643
1166
|
HaiPaymentError.NOTIFY_VERIFY_FAILED,
|
|
644
1167
|
paymentM("payment_notifyVerifyFailed")
|
|
645
1168
|
);
|
|
646
1169
|
}
|
|
647
|
-
const
|
|
1170
|
+
const parsedBody = WechatNotifyEnvelopeSchema.safeParse(JSON.parse(request.body));
|
|
1171
|
+
if (!parsedBody.success)
|
|
1172
|
+
throw new Error("Invalid Wechat payment notification");
|
|
1173
|
+
const body = parsedBody.data;
|
|
648
1174
|
const decrypted = decryptResource(
|
|
649
1175
|
body.resource.ciphertext,
|
|
650
1176
|
body.resource.nonce,
|
|
651
|
-
body.resource.associated_data,
|
|
1177
|
+
body.resource.associated_data ?? "",
|
|
652
1178
|
config.apiV3Key
|
|
653
1179
|
);
|
|
654
|
-
const
|
|
1180
|
+
const parsedResource = WechatNotifyResourceSchema.safeParse(JSON.parse(decrypted));
|
|
1181
|
+
if (!parsedResource.success)
|
|
1182
|
+
throw new Error("Invalid Wechat payment notification resource");
|
|
1183
|
+
const resource = parsedResource.data;
|
|
1184
|
+
if (resource.appid !== config.appId || resource.mchid !== config.mchId) {
|
|
1185
|
+
return err(HaiPaymentError.NOTIFY_VERIFY_FAILED, paymentM("payment_notifyVerifyFailed"));
|
|
1186
|
+
}
|
|
655
1187
|
return ok({
|
|
656
1188
|
orderNo: resource.out_trade_no,
|
|
657
1189
|
transactionId: resource.transaction_id,
|
|
1190
|
+
eventId: body.id,
|
|
658
1191
|
amount: resource.amount.total,
|
|
659
|
-
|
|
660
|
-
|
|
1192
|
+
currency: resource.amount.currency,
|
|
1193
|
+
status: "paid",
|
|
1194
|
+
paidAt: new Date(resource.success_time),
|
|
661
1195
|
raw: resource
|
|
662
1196
|
});
|
|
663
|
-
} catch
|
|
1197
|
+
} catch {
|
|
664
1198
|
return err(
|
|
665
1199
|
HaiPaymentError.NOTIFY_PARSE_FAILED,
|
|
666
1200
|
paymentM("payment_notifyParseFailed"),
|
|
667
|
-
|
|
1201
|
+
new Error("Invalid Wechat payment notification")
|
|
668
1202
|
);
|
|
669
1203
|
}
|
|
670
1204
|
},
|
|
@@ -754,6 +1288,7 @@ var payment = {
|
|
|
754
1288
|
* 初始化支付模块
|
|
755
1289
|
*
|
|
756
1290
|
* 根据提供的配置自动注册对应的 Provider。
|
|
1291
|
+
* Stripe 要求共享 reldb 已初始化;仓库就绪后才发布 Provider,关闭时不代替宿主关闭 reldb。
|
|
757
1292
|
*
|
|
758
1293
|
* @param config - 支付配置
|
|
759
1294
|
*/
|
|
@@ -782,6 +1317,16 @@ var payment = {
|
|
|
782
1317
|
);
|
|
783
1318
|
}
|
|
784
1319
|
const parsed = parseResult.data;
|
|
1320
|
+
let stripeProvider;
|
|
1321
|
+
if (parsed.stripe) {
|
|
1322
|
+
if (!reldb.isInitialized)
|
|
1323
|
+
return err(HaiPaymentError.CONFIG_ERROR, paymentM("payment_stripeDatabaseRequired"));
|
|
1324
|
+
const repository = new StripeOperationRepository(reldb);
|
|
1325
|
+
const ready = await repository.initialize();
|
|
1326
|
+
if (!ready.success)
|
|
1327
|
+
return err(HaiPaymentError.CONFIG_ERROR, paymentM("payment_stripeStoreInitFailed"), ready.error);
|
|
1328
|
+
stripeProvider = createStripeProvider(parsed.stripe, repository);
|
|
1329
|
+
}
|
|
785
1330
|
clearProviders();
|
|
786
1331
|
if (parsed.wechat) {
|
|
787
1332
|
registerProvider(createWechatPayProvider(parsed.wechat));
|
|
@@ -789,9 +1334,8 @@ var payment = {
|
|
|
789
1334
|
if (parsed.alipay) {
|
|
790
1335
|
registerProvider(createAlipayProvider(parsed.alipay));
|
|
791
1336
|
}
|
|
792
|
-
if (
|
|
793
|
-
registerProvider(
|
|
794
|
-
}
|
|
1337
|
+
if (stripeProvider)
|
|
1338
|
+
registerProvider(stripeProvider);
|
|
795
1339
|
currentConfig = parsed;
|
|
796
1340
|
logger2.info("Payment module initialized", {
|
|
797
1341
|
providers: [
|