@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/dist/index.js CHANGED
@@ -1,9 +1,10 @@
1
- import { paymentM, HaiPaymentError } from './chunk-4IUARFWH.js';
2
- export { HaiPaymentError } from './chunk-4IUARFWH.js';
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 { createVerify, createDecipheriv, randomBytes, createSign, createHmac } from 'crypto';
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
- platformCert: z.string().optional(),
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().min(1, paymentM("payment_configFieldRequired")),
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 ?? "RSA2";
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-provider.ts
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()).toISOString().replace("T", " ").slice(0, 19),
299
+ timestamp: formatAlipayTime(/* @__PURE__ */ new Date()),
156
300
  version: "1.0",
157
301
  notify_url: notifyUrl
158
302
  };
159
303
  }
160
- function getTradeConfig(tradeType) {
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 queryString = Object.entries(allParams).map(([k, v]) => `${k}=${encodeURIComponent(v)}`).join("&");
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 (!response.ok) {
178
- throw new Error(`Alipay API failed: ${response.status}`);
312
+ if (!timedResponse.ok) {
313
+ throw new Error(`Alipay API failed: ${timedResponse.status}`);
179
314
  }
180
- return response.json();
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 { method, productCode } = getTradeConfig(input.tradeType);
187
- const bizContent = JSON.stringify({
188
- out_trade_no: input.orderNo,
189
- total_amount: (input.amount / 100).toFixed(2),
190
- // 分 → 元
191
- subject: input.description,
192
- product_code: productCode,
193
- passback_params: input.metadata ? JSON.stringify(input.metadata) : void 0
194
- });
195
- const params = {
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: input.tradeType,
205
- clientParams: data ?? {},
206
- prepayId: data?.trade_no
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 pairs = request.body.split("&");
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
- amount: Math.round(Number.parseFloat(params.total_amount) * 100),
241
- // 元 → 分
242
- status: statusMap[params.trade_status] ?? "pending",
243
- paidAt: params.gmt_payment ? new Date(params.gmt_payment) : void 0,
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 response = await alipayRequest(params);
262
- const data = response.alipay_trade_query_response;
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?.trade_no,
272
- status: statusMap[data?.trade_status] ?? "pending",
273
- amount: Math.round(Number.parseFloat(data?.total_amount ?? "0") * 100),
274
- paidAt: data?.send_pay_date ? new Date(data.send_pay_date) : void 0
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 / 100).toFixed(2),
460
+ refund_amount: formatFenAmount(input.amount),
293
461
  refund_reason: input.reason
294
462
  })
295
463
  };
296
- const response = await alipayRequest(params);
297
- const data = response.alipay_trade_refund_response;
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?.trade_no ?? "",
301
- status: data?.fund_change === "Y" ? "success" : "processing"
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
- function createStripeProvider(config) {
332
- async function stripeRequest(method, path, body) {
333
- const response = await fetch(`${STRIPE_API_BASE}${path}`, {
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
- const TIMESTAMP_TOLERANCE = 300;
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 timestamp = parts.find((p) => p.startsWith("t="))?.slice(2) ?? "";
351
- const v1 = parts.find((p) => p.startsWith("v1="))?.slice(3) ?? "";
352
- const ts = Number.parseInt(timestamp, 10);
353
- if (Number.isNaN(ts) || Math.abs(Math.floor(Date.now() / 1e3) - ts) > TIMESTAMP_TOLERANCE) {
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 session = await stripeRequest("POST", "/checkout/sessions", params);
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["stripe-signature"] ?? "";
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 event = JSON.parse(request.body);
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 orderNo = obj.metadata?.orderNo ?? "";
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
- amount: obj.amount_total ?? 0,
419
- status: statusMap[event.type] ?? "pending",
420
- paidAt: /* @__PURE__ */ new Date(),
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 (cause) {
893
+ } catch {
424
894
  return err(
425
895
  HaiPaymentError.NOTIFY_PARSE_FAILED,
426
896
  paymentM("payment_notifyParseFailed"),
427
- cause
897
+ new Error("Invalid Stripe payment notification")
428
898
  );
429
899
  }
430
900
  },
431
901
  async queryOrder(orderNo) {
432
902
  try {
433
- const sessions = await stripeRequest("GET", `/checkout/sessions?limit=1&metadata[orderNo]=${encodeURIComponent(orderNo)}`);
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] ?? "pending",
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
- const sessions = await stripeRequest("GET", `/checkout/sessions?limit=1&metadata[orderNo]=${encodeURIComponent(input.orderNo)}`);
464
- const paymentIntent = sessions.data[0]?.payment_intent;
465
- if (!paymentIntent) {
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 refund2 = await stripeRequest("POST", "/refunds", {
472
- payment_intent: paymentIntent,
473
- amount: input.amount.toString(),
474
- reason: input.reason ?? "requested_by_customer"
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(_orderNo) {
490
- return ok(void 0);
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, platformCert) {
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(platformCert, signature, "base64");
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/wechat-pay-provider.ts
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 fetch(`${WECHAT_API_BASE}${path}`, {
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: input.currency ?? "CNY"
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 path = getOrderPath(input.tradeType);
596
- const response = await wechatRequest("POST", path, requestBody);
597
- let clientParams = {};
598
- if (input.tradeType === "jsapi") {
599
- const timestamp = getTimestamp();
600
- const nonce = generateNonce();
601
- const paySign = signJsapi(config.appId, timestamp, nonce, response.prepay_id, config.privateKey);
602
- clientParams = {
603
- appId: config.appId,
604
- timeStamp: timestamp,
605
- nonceStr: nonce,
606
- package: `prepay_id=${response.prepay_id}`,
607
- signType: "RSA",
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
- const timestamp = request.headers["wechatpay-timestamp"] ?? "";
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 body = JSON.parse(request.body);
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 resource = JSON.parse(decrypted);
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
- status: resource.trade_state === "SUCCESS" ? "paid" : "failed",
660
- paidAt: resource.success_time ? new Date(resource.success_time) : void 0,
1192
+ currency: resource.amount.currency,
1193
+ status: "paid",
1194
+ paidAt: new Date(resource.success_time),
661
1195
  raw: resource
662
1196
  });
663
- } catch (cause) {
1197
+ } catch {
664
1198
  return err(
665
1199
  HaiPaymentError.NOTIFY_PARSE_FAILED,
666
1200
  paymentM("payment_notifyParseFailed"),
667
- cause
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 (parsed.stripe) {
793
- registerProvider(createStripeProvider(parsed.stripe));
794
- }
1337
+ if (stripeProvider)
1338
+ registerProvider(stripeProvider);
795
1339
  currentConfig = parsed;
796
1340
  logger2.info("Payment module initialized", {
797
1341
  providers: [