@h-ai/payment 0.1.0-alpha.66 → 0.1.0-alpha.68
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 +139 -51
- package/README.ja.md +140 -52
- package/README.md +154 -53
- 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 +646 -170
- package/dist/index.js.map +1 -1
- package/dist/payment-types-D02t-Pc9.d.ts +500 -0
- package/package.json +5 -3
- package/dist/chunk-4IUARFWH.js +0 -67
- package/dist/chunk-4IUARFWH.js.map +0 -1
- package/dist/payment-types-Cf3kF6Tn.d.ts +0 -211
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,21 +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().
|
|
30
|
+
secretKey: z.string().regex(/^(?:sk|rk)_(?:test|live)_\S+$/, paymentM("payment_stripeKeyInvalid")),
|
|
26
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(),
|
|
27
36
|
webhookToleranceSeconds: z.number().int().positive().default(300)
|
|
28
|
-
});
|
|
37
|
+
}).strict();
|
|
29
38
|
var PaymentConfigSchema = z.object({
|
|
30
39
|
wechat: WechatPayConfigSchema.optional(),
|
|
31
40
|
alipay: AlipayConfigSchema.optional(),
|
|
@@ -112,7 +121,7 @@ async function handleNotify(providerName, request) {
|
|
|
112
121
|
action: "payment_notify",
|
|
113
122
|
resource: "payment",
|
|
114
123
|
resourceId: notifyResult.data.orderNo,
|
|
115
|
-
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 }
|
|
116
125
|
});
|
|
117
126
|
}
|
|
118
127
|
return notifyResult;
|
|
@@ -177,22 +186,77 @@ function signAlipayParams(params, privateKey, signType = "RSA2") {
|
|
|
177
186
|
}
|
|
178
187
|
function verifyAlipayNotify(params, alipayPublicKey) {
|
|
179
188
|
const sign = params.sign ?? "";
|
|
180
|
-
const signType = params.sign_type
|
|
189
|
+
const signType = params.sign_type;
|
|
190
|
+
if (signType !== "RSA" && signType !== "RSA2")
|
|
191
|
+
return false;
|
|
181
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("&");
|
|
182
193
|
const algorithm = signType === "RSA2" ? "RSA-SHA256" : "RSA-SHA1";
|
|
183
194
|
const verify = createVerify(algorithm);
|
|
184
195
|
verify.update(sorted);
|
|
185
196
|
return verify.verify(alipayPublicKey, sign, "base64");
|
|
186
197
|
}
|
|
198
|
+
|
|
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
|
+
});
|
|
187
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),
|
|
188
252
|
out_trade_no: z.string().min(1),
|
|
189
253
|
trade_no: z.string().min(1),
|
|
190
|
-
trade_status: z.
|
|
191
|
-
total_amount:
|
|
192
|
-
gmt_payment:
|
|
193
|
-
}).catchall(z.string());
|
|
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);
|
|
194
258
|
|
|
195
|
-
// src/providers/alipay/
|
|
259
|
+
// src/providers/alipay/payment-provider-alipay.ts
|
|
196
260
|
var ALIPAY_GATEWAY = "https://openapi.alipay.com/gateway.do";
|
|
197
261
|
var ALIPAY_SANDBOX_GATEWAY = "https://openapi-sandbox.dl.alipaydev.com/gateway.do";
|
|
198
262
|
function formatFenAmount(amount) {
|
|
@@ -203,15 +267,22 @@ function formatFenAmount(amount) {
|
|
|
203
267
|
function parseAlipayAmount(value) {
|
|
204
268
|
const trimmedValue = value.trim();
|
|
205
269
|
if (!/^\d+(?:\.\d{1,2})?$/.test(trimmedValue)) {
|
|
206
|
-
throw new Error(
|
|
270
|
+
throw new Error("Invalid Alipay amount");
|
|
207
271
|
}
|
|
208
272
|
const [yuanPart, centsPart = ""] = trimmedValue.split(".");
|
|
209
|
-
|
|
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;
|
|
210
278
|
}
|
|
211
279
|
function parseAlipayNotifyParams(body) {
|
|
212
|
-
const
|
|
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()));
|
|
213
284
|
if (!parseResult.success) {
|
|
214
|
-
throw
|
|
285
|
+
throw new Error("Invalid Alipay notification parameters");
|
|
215
286
|
}
|
|
216
287
|
return parseResult.data;
|
|
217
288
|
}
|
|
@@ -222,59 +293,78 @@ function createAlipayProvider(config) {
|
|
|
222
293
|
return {
|
|
223
294
|
app_id: config.appId,
|
|
224
295
|
method,
|
|
296
|
+
format: "JSON",
|
|
225
297
|
charset: "utf-8",
|
|
226
298
|
sign_type: signType,
|
|
227
|
-
timestamp: (/* @__PURE__ */ new Date())
|
|
299
|
+
timestamp: formatAlipayTime(/* @__PURE__ */ new Date()),
|
|
228
300
|
version: "1.0",
|
|
229
301
|
notify_url: notifyUrl
|
|
230
302
|
};
|
|
231
303
|
}
|
|
232
|
-
function
|
|
233
|
-
const map = {
|
|
234
|
-
jsapi: { method: "alipay.trade.create", productCode: "JSAPI_PAY" },
|
|
235
|
-
h5: { method: "alipay.trade.wap.pay", productCode: "QUICK_WAP_WAY" },
|
|
236
|
-
app: { method: "alipay.trade.app.pay", productCode: "QUICK_MSECURITY_PAY" },
|
|
237
|
-
native: { method: "alipay.trade.precreate", productCode: "FACE_TO_FACE_PAYMENT" }
|
|
238
|
-
};
|
|
239
|
-
return map[tradeType] ?? map.h5;
|
|
240
|
-
}
|
|
241
|
-
async function alipayRequest(params) {
|
|
304
|
+
async function alipayRequest(params, schema) {
|
|
242
305
|
const sign = signAlipayParams(params, config.privateKey, signType);
|
|
243
306
|
const allParams = { ...params, sign };
|
|
244
|
-
const
|
|
245
|
-
const timedResponse = await fetchWithTimeout(`${gateway}?${queryString}`, {
|
|
307
|
+
const timedResponse = await fetchWithTimeout(gateway, {
|
|
246
308
|
method: "POST",
|
|
247
|
-
headers: { "Content-Type": "application/x-www-form-urlencoded" }
|
|
309
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
310
|
+
body: new URLSearchParams(allParams).toString()
|
|
248
311
|
});
|
|
249
312
|
if (!timedResponse.ok) {
|
|
250
313
|
throw new Error(`Alipay API failed: ${timedResponse.status}`);
|
|
251
314
|
}
|
|
252
|
-
|
|
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;
|
|
253
341
|
}
|
|
254
342
|
return {
|
|
255
343
|
name: "alipay",
|
|
256
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"));
|
|
257
349
|
try {
|
|
258
|
-
const
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
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
|
+
})
|
|
269
360
|
};
|
|
270
|
-
const response = await alipayRequest(params);
|
|
271
|
-
const key = `${method.replace(/\./g, "_")}_response`;
|
|
272
|
-
const data = response[key];
|
|
273
361
|
return ok({
|
|
274
362
|
provider: "alipay",
|
|
275
|
-
tradeType:
|
|
276
|
-
clientParams:
|
|
277
|
-
|
|
363
|
+
tradeType: "h5",
|
|
364
|
+
clientParams: {
|
|
365
|
+
gateway,
|
|
366
|
+
fields: { ...fields, sign: signAlipayParams(fields, config.privateKey, signType) }
|
|
367
|
+
}
|
|
278
368
|
});
|
|
279
369
|
} catch (cause) {
|
|
280
370
|
return err(
|
|
@@ -287,13 +377,21 @@ function createAlipayProvider(config) {
|
|
|
287
377
|
async handleNotify(request) {
|
|
288
378
|
try {
|
|
289
379
|
const params = parseAlipayNotifyParams(request.body);
|
|
290
|
-
const valid = verifyAlipayNotify(params, config.alipayPublicKey);
|
|
380
|
+
const valid = params.sign_type === signType && verifyAlipayNotify(params, config.alipayPublicKey);
|
|
291
381
|
if (!valid) {
|
|
292
382
|
return err(
|
|
293
383
|
HaiPaymentError.NOTIFY_VERIFY_FAILED,
|
|
294
384
|
paymentM("payment_notifyVerifyFailed")
|
|
295
385
|
);
|
|
296
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
|
+
}
|
|
297
395
|
const statusMap = {
|
|
298
396
|
TRADE_SUCCESS: "paid",
|
|
299
397
|
TRADE_CLOSED: "closed",
|
|
@@ -303,10 +401,11 @@ function createAlipayProvider(config) {
|
|
|
303
401
|
return ok({
|
|
304
402
|
orderNo: params.out_trade_no,
|
|
305
403
|
transactionId: params.trade_no,
|
|
404
|
+
eventId: params.notify_id,
|
|
306
405
|
amount: parseAlipayAmount(params.total_amount),
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
406
|
+
currency: "CNY",
|
|
407
|
+
status: statusMap[params.trade_status],
|
|
408
|
+
paidAt: ["TRADE_SUCCESS", "TRADE_FINISHED"].includes(params.trade_status) ? paidAt : void 0,
|
|
310
409
|
raw: params
|
|
311
410
|
});
|
|
312
411
|
} catch (cause) {
|
|
@@ -324,8 +423,10 @@ function createAlipayProvider(config) {
|
|
|
324
423
|
...buildCommonParams(method, ""),
|
|
325
424
|
biz_content: JSON.stringify({ out_trade_no: orderNo })
|
|
326
425
|
};
|
|
327
|
-
const
|
|
328
|
-
|
|
426
|
+
const data = await alipayRequest(params, AlipayQueryResponseSchema);
|
|
427
|
+
if (data.out_trade_no !== orderNo) {
|
|
428
|
+
throw new Error("Alipay response order mismatch");
|
|
429
|
+
}
|
|
329
430
|
const statusMap = {
|
|
330
431
|
TRADE_SUCCESS: "paid",
|
|
331
432
|
TRADE_CLOSED: "closed",
|
|
@@ -334,10 +435,11 @@ function createAlipayProvider(config) {
|
|
|
334
435
|
};
|
|
335
436
|
return ok({
|
|
336
437
|
orderNo,
|
|
337
|
-
transactionId: data
|
|
338
|
-
status: statusMap[data
|
|
339
|
-
amount: parseAlipayAmount(data
|
|
340
|
-
|
|
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
|
|
341
443
|
});
|
|
342
444
|
} catch (cause) {
|
|
343
445
|
return err(
|
|
@@ -359,12 +461,15 @@ function createAlipayProvider(config) {
|
|
|
359
461
|
refund_reason: input.reason
|
|
360
462
|
})
|
|
361
463
|
};
|
|
362
|
-
const
|
|
363
|
-
|
|
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);
|
|
364
469
|
return ok({
|
|
365
470
|
refundNo: input.refundNo,
|
|
366
|
-
refundId: data
|
|
367
|
-
status: data
|
|
471
|
+
refundId: data.trade_no,
|
|
472
|
+
status: data.fund_change === "Y" ? "success" : "processing"
|
|
368
473
|
});
|
|
369
474
|
} catch (cause) {
|
|
370
475
|
return err(
|
|
@@ -381,7 +486,10 @@ function createAlipayProvider(config) {
|
|
|
381
486
|
...buildCommonParams(method, ""),
|
|
382
487
|
biz_content: JSON.stringify({ out_trade_no: orderNo })
|
|
383
488
|
};
|
|
384
|
-
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
|
+
}
|
|
385
493
|
return ok(void 0);
|
|
386
494
|
} catch (cause) {
|
|
387
495
|
return err(
|
|
@@ -393,16 +501,237 @@ function createAlipayProvider(config) {
|
|
|
393
501
|
}
|
|
394
502
|
};
|
|
395
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
|
|
396
665
|
var STRIPE_API_BASE = "https://api.stripe.com/v1";
|
|
397
|
-
|
|
398
|
-
|
|
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) {
|
|
399
726
|
const response = await fetchWithTimeout(`${STRIPE_API_BASE}${path}`, {
|
|
400
727
|
method,
|
|
401
728
|
headers: {
|
|
402
729
|
"Authorization": `Bearer ${config.secretKey}`,
|
|
403
|
-
"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 } : {}
|
|
404
733
|
},
|
|
405
|
-
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
|
|
406
735
|
});
|
|
407
736
|
if (!response.ok) {
|
|
408
737
|
const errorText = await response.text();
|
|
@@ -410,19 +739,68 @@ function createStripeProvider(config) {
|
|
|
410
739
|
}
|
|
411
740
|
return response.json();
|
|
412
741
|
}
|
|
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
|
+
}
|
|
413
788
|
const TIMESTAMP_TOLERANCE = config.webhookToleranceSeconds ?? 300;
|
|
414
789
|
function verifyWebhookSignature(payload, signature) {
|
|
415
|
-
const parts = signature.split(",");
|
|
416
|
-
const
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
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) {
|
|
420
798
|
return false;
|
|
421
799
|
}
|
|
422
800
|
const signedPayload = `${timestamp}.${payload}`;
|
|
423
801
|
const expected = createHmac("sha256", config.webhookSecret).update(signedPayload).digest("hex");
|
|
424
802
|
try {
|
|
425
|
-
return core.string.constantTimeEqual(v1, expected);
|
|
803
|
+
return signatures.some((v1) => /^[a-f0-9]{64}$/.test(v1) && core.string.constantTimeEqual(v1, expected));
|
|
426
804
|
} catch {
|
|
427
805
|
return false;
|
|
428
806
|
}
|
|
@@ -430,27 +808,48 @@ function createStripeProvider(config) {
|
|
|
430
808
|
return {
|
|
431
809
|
name: "stripe",
|
|
432
810
|
async createOrder(input) {
|
|
811
|
+
if (input.tradeType !== "h5")
|
|
812
|
+
return err(HaiPaymentError.UNSUPPORTED_TRADE_TYPE, paymentM("payment_unsupportedTradeType"));
|
|
433
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");
|
|
434
817
|
const params = {
|
|
435
818
|
"mode": "payment",
|
|
819
|
+
"ui_mode": "hosted_page",
|
|
820
|
+
"success_url": config.successUrl,
|
|
436
821
|
"line_items[0][price_data][currency]": input.currency?.toLowerCase() ?? "usd",
|
|
437
822
|
"line_items[0][price_data][product_data][name]": input.description,
|
|
438
823
|
"line_items[0][price_data][unit_amount]": input.amount.toString(),
|
|
439
824
|
"line_items[0][quantity]": "1"
|
|
440
825
|
};
|
|
826
|
+
if (config.cancelUrl)
|
|
827
|
+
params.cancel_url = config.cancelUrl;
|
|
441
828
|
params["metadata[orderNo]"] = input.orderNo;
|
|
442
829
|
if (input.metadata) {
|
|
443
830
|
for (const [k, v] of Object.entries(input.metadata)) {
|
|
444
831
|
params[`metadata[${k}]`] = v;
|
|
445
832
|
}
|
|
446
833
|
}
|
|
447
|
-
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
|
+
});
|
|
448
847
|
return ok({
|
|
449
848
|
provider: "stripe",
|
|
450
849
|
tradeType: input.tradeType,
|
|
451
850
|
clientParams: {
|
|
452
|
-
sessionId: session.id,
|
|
453
|
-
checkoutUrl: session.url
|
|
851
|
+
sessionId: mapping.session.id,
|
|
852
|
+
checkoutUrl: mapping.session.url
|
|
454
853
|
}
|
|
455
854
|
});
|
|
456
855
|
} catch (cause) {
|
|
@@ -463,7 +862,7 @@ function createStripeProvider(config) {
|
|
|
463
862
|
},
|
|
464
863
|
async handleNotify(request) {
|
|
465
864
|
try {
|
|
466
|
-
const signature = request.headers
|
|
865
|
+
const signature = getNotifyHeader(request.headers, "stripe-signature") ?? "";
|
|
467
866
|
const valid = verifyWebhookSignature(request.body, signature);
|
|
468
867
|
if (!valid) {
|
|
469
868
|
return err(
|
|
@@ -471,41 +870,37 @@ function createStripeProvider(config) {
|
|
|
471
870
|
paymentM("payment_notifyVerifyFailed")
|
|
472
871
|
);
|
|
473
872
|
}
|
|
474
|
-
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
|
+
}
|
|
475
880
|
const obj = event.data.object;
|
|
476
|
-
const
|
|
477
|
-
const statusMap = {
|
|
478
|
-
"checkout.session.completed": "paid",
|
|
479
|
-
"payment_intent.payment_failed": "failed"
|
|
480
|
-
};
|
|
881
|
+
const status = event.type === "checkout.session.expired" ? "closed" : event.type === "checkout.session.async_payment_failed" ? "failed" : obj.payment_status === "unpaid" ? "pending" : "paid";
|
|
481
882
|
return ok({
|
|
482
|
-
orderNo,
|
|
883
|
+
orderNo: obj.metadata.orderNo,
|
|
483
884
|
transactionId: obj.id,
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
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,
|
|
488
891
|
raw: event
|
|
489
892
|
});
|
|
490
|
-
} catch
|
|
893
|
+
} catch {
|
|
491
894
|
return err(
|
|
492
895
|
HaiPaymentError.NOTIFY_PARSE_FAILED,
|
|
493
896
|
paymentM("payment_notifyParseFailed"),
|
|
494
|
-
|
|
897
|
+
new Error("Invalid Stripe payment notification")
|
|
495
898
|
);
|
|
496
899
|
}
|
|
497
900
|
},
|
|
498
901
|
async queryOrder(orderNo) {
|
|
499
902
|
try {
|
|
500
|
-
const
|
|
501
|
-
const session = sessions.data[0];
|
|
502
|
-
if (!session) {
|
|
503
|
-
return ok({
|
|
504
|
-
orderNo,
|
|
505
|
-
status: "pending",
|
|
506
|
-
amount: 0
|
|
507
|
-
});
|
|
508
|
-
}
|
|
903
|
+
const session = await retrieveOrder(orderNo);
|
|
509
904
|
const statusMap = {
|
|
510
905
|
paid: "paid",
|
|
511
906
|
unpaid: "pending",
|
|
@@ -514,7 +909,7 @@ function createStripeProvider(config) {
|
|
|
514
909
|
return ok({
|
|
515
910
|
orderNo,
|
|
516
911
|
transactionId: session.id,
|
|
517
|
-
status: statusMap[session.payment_status]
|
|
912
|
+
status: session.status === "expired" ? "closed" : statusMap[session.payment_status],
|
|
518
913
|
amount: session.amount_total
|
|
519
914
|
});
|
|
520
915
|
} catch (cause) {
|
|
@@ -527,23 +922,34 @@ function createStripeProvider(config) {
|
|
|
527
922
|
},
|
|
528
923
|
async refund(input) {
|
|
529
924
|
try {
|
|
530
|
-
|
|
531
|
-
const
|
|
532
|
-
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") {
|
|
533
928
|
return err(
|
|
534
929
|
HaiPaymentError.REFUND_FAILED,
|
|
535
930
|
paymentM("payment_refundFailed")
|
|
536
931
|
);
|
|
537
932
|
}
|
|
538
|
-
const
|
|
539
|
-
payment_intent:
|
|
540
|
-
amount: input.amount.toString(),
|
|
541
|
-
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)
|
|
542
948
|
});
|
|
543
949
|
return ok({
|
|
544
950
|
refundNo: input.refundNo,
|
|
545
951
|
refundId: refund2.id,
|
|
546
|
-
status: refund2.status === "succeeded" ? "success" : "processing"
|
|
952
|
+
status: refund2.status === "succeeded" ? "success" : refund2.status === "failed" || refund2.status === "canceled" ? "failed" : "processing"
|
|
547
953
|
});
|
|
548
954
|
} catch (cause) {
|
|
549
955
|
return err(
|
|
@@ -553,8 +959,31 @@ function createStripeProvider(config) {
|
|
|
553
959
|
);
|
|
554
960
|
}
|
|
555
961
|
},
|
|
556
|
-
async closeOrder(
|
|
557
|
-
|
|
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
|
+
}
|
|
558
987
|
}
|
|
559
988
|
};
|
|
560
989
|
}
|
|
@@ -585,14 +1014,14 @@ prepay_id=${prepayId}
|
|
|
585
1014
|
sign.update(message);
|
|
586
1015
|
return sign.sign(privateKey, "base64");
|
|
587
1016
|
}
|
|
588
|
-
function verifyNotifySignature(timestamp, nonce, body, signature,
|
|
1017
|
+
function verifyNotifySignature(timestamp, nonce, body, signature, platformKey) {
|
|
589
1018
|
const message = `${timestamp}
|
|
590
1019
|
${nonce}
|
|
591
1020
|
${body}
|
|
592
1021
|
`;
|
|
593
1022
|
const verify = createVerify("RSA-SHA256");
|
|
594
1023
|
verify.update(message);
|
|
595
|
-
return verify.verify(
|
|
1024
|
+
return verify.verify(platformKey, signature, "base64");
|
|
596
1025
|
}
|
|
597
1026
|
function decryptResource(ciphertext, nonce, associatedData, apiV3Key) {
|
|
598
1027
|
const buf = Buffer.from(ciphertext, "base64");
|
|
@@ -604,10 +1033,60 @@ function decryptResource(ciphertext, nonce, associatedData, apiV3Key) {
|
|
|
604
1033
|
const decrypted = Buffer.concat([decipher.update(data), decipher.final()]);
|
|
605
1034
|
return decrypted.toString("utf-8");
|
|
606
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();
|
|
607
1061
|
|
|
608
|
-
// src/providers/wechat/
|
|
1062
|
+
// src/providers/wechat/payment-provider-wechat.ts
|
|
609
1063
|
var WECHAT_API_BASE = "https://api.mch.weixin.qq.com";
|
|
610
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
|
+
}
|
|
611
1090
|
async function wechatRequest(method, path, body) {
|
|
612
1091
|
const timestamp = getTimestamp();
|
|
613
1092
|
const nonce = generateNonce();
|
|
@@ -629,18 +1108,14 @@ function createWechatPayProvider(config) {
|
|
|
629
1108
|
}
|
|
630
1109
|
return response.json();
|
|
631
1110
|
}
|
|
632
|
-
function getOrderPath(tradeType) {
|
|
633
|
-
const map = {
|
|
634
|
-
jsapi: "/v3/pay/transactions/jsapi",
|
|
635
|
-
h5: "/v3/pay/transactions/h5",
|
|
636
|
-
app: "/v3/pay/transactions/app",
|
|
637
|
-
native: "/v3/pay/transactions/native"
|
|
638
|
-
};
|
|
639
|
-
return map[tradeType] ?? "/v3/pay/transactions/jsapi";
|
|
640
|
-
}
|
|
641
1111
|
return {
|
|
642
1112
|
name: "wechat",
|
|
643
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
|
+
}
|
|
644
1119
|
try {
|
|
645
1120
|
const requestBody = {
|
|
646
1121
|
appid: config.appId,
|
|
@@ -650,35 +1125,26 @@ function createWechatPayProvider(config) {
|
|
|
650
1125
|
notify_url: input.notifyUrl,
|
|
651
1126
|
amount: {
|
|
652
1127
|
total: input.amount,
|
|
653
|
-
currency:
|
|
654
|
-
}
|
|
1128
|
+
currency: "CNY"
|
|
1129
|
+
},
|
|
1130
|
+
payer: { openid: input.userId }
|
|
655
1131
|
};
|
|
656
|
-
if (input.tradeType === "jsapi" && input.userId) {
|
|
657
|
-
requestBody.payer = { openid: input.userId };
|
|
658
|
-
}
|
|
659
1132
|
if (input.metadata) {
|
|
660
1133
|
requestBody.attach = JSON.stringify(input.metadata);
|
|
661
1134
|
}
|
|
662
|
-
const
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
paySign
|
|
676
|
-
};
|
|
677
|
-
} else if (input.tradeType === "h5") {
|
|
678
|
-
clientParams = { h5Url: response.h5_url };
|
|
679
|
-
} else if (input.tradeType === "native") {
|
|
680
|
-
clientParams = { codeUrl: response.code_url };
|
|
681
|
-
}
|
|
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
|
+
};
|
|
682
1148
|
return ok({
|
|
683
1149
|
provider: "wechat",
|
|
684
1150
|
tradeType: input.tradeType,
|
|
@@ -695,44 +1161,44 @@ function createWechatPayProvider(config) {
|
|
|
695
1161
|
},
|
|
696
1162
|
async handleNotify(request) {
|
|
697
1163
|
try {
|
|
698
|
-
|
|
699
|
-
const nonce = request.headers["wechatpay-nonce"] ?? "";
|
|
700
|
-
const signature = request.headers["wechatpay-signature"] ?? "";
|
|
701
|
-
if (!config.platformCert) {
|
|
702
|
-
return err(
|
|
703
|
-
HaiPaymentError.NOTIFY_VERIFY_FAILED,
|
|
704
|
-
paymentM("payment_notifyVerifyFailed")
|
|
705
|
-
);
|
|
706
|
-
}
|
|
707
|
-
const valid = verifyNotifySignature(timestamp, nonce, request.body, signature, config.platformCert);
|
|
708
|
-
if (!valid) {
|
|
1164
|
+
if (!verifyNotification(request)) {
|
|
709
1165
|
return err(
|
|
710
1166
|
HaiPaymentError.NOTIFY_VERIFY_FAILED,
|
|
711
1167
|
paymentM("payment_notifyVerifyFailed")
|
|
712
1168
|
);
|
|
713
1169
|
}
|
|
714
|
-
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;
|
|
715
1174
|
const decrypted = decryptResource(
|
|
716
1175
|
body.resource.ciphertext,
|
|
717
1176
|
body.resource.nonce,
|
|
718
|
-
body.resource.associated_data,
|
|
1177
|
+
body.resource.associated_data ?? "",
|
|
719
1178
|
config.apiV3Key
|
|
720
1179
|
);
|
|
721
|
-
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
|
+
}
|
|
722
1187
|
return ok({
|
|
723
1188
|
orderNo: resource.out_trade_no,
|
|
724
1189
|
transactionId: resource.transaction_id,
|
|
1190
|
+
eventId: body.id,
|
|
725
1191
|
amount: resource.amount.total,
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
1192
|
+
currency: resource.amount.currency,
|
|
1193
|
+
status: "paid",
|
|
1194
|
+
paidAt: new Date(resource.success_time),
|
|
729
1195
|
raw: resource
|
|
730
1196
|
});
|
|
731
|
-
} catch
|
|
1197
|
+
} catch {
|
|
732
1198
|
return err(
|
|
733
1199
|
HaiPaymentError.NOTIFY_PARSE_FAILED,
|
|
734
1200
|
paymentM("payment_notifyParseFailed"),
|
|
735
|
-
|
|
1201
|
+
new Error("Invalid Wechat payment notification")
|
|
736
1202
|
);
|
|
737
1203
|
}
|
|
738
1204
|
},
|
|
@@ -822,6 +1288,7 @@ var payment = {
|
|
|
822
1288
|
* 初始化支付模块
|
|
823
1289
|
*
|
|
824
1290
|
* 根据提供的配置自动注册对应的 Provider。
|
|
1291
|
+
* Stripe 要求共享 reldb 已初始化;仓库就绪后才发布 Provider,关闭时不代替宿主关闭 reldb。
|
|
825
1292
|
*
|
|
826
1293
|
* @param config - 支付配置
|
|
827
1294
|
*/
|
|
@@ -850,6 +1317,16 @@ var payment = {
|
|
|
850
1317
|
);
|
|
851
1318
|
}
|
|
852
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
|
+
}
|
|
853
1330
|
clearProviders();
|
|
854
1331
|
if (parsed.wechat) {
|
|
855
1332
|
registerProvider(createWechatPayProvider(parsed.wechat));
|
|
@@ -857,9 +1334,8 @@ var payment = {
|
|
|
857
1334
|
if (parsed.alipay) {
|
|
858
1335
|
registerProvider(createAlipayProvider(parsed.alipay));
|
|
859
1336
|
}
|
|
860
|
-
if (
|
|
861
|
-
registerProvider(
|
|
862
|
-
}
|
|
1337
|
+
if (stripeProvider)
|
|
1338
|
+
registerProvider(stripeProvider);
|
|
863
1339
|
currentConfig = parsed;
|
|
864
1340
|
logger2.info("Payment module initialized", {
|
|
865
1341
|
providers: [
|