@h-ai/payment 0.1.0-alpha8 → 1.0.0-beta.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.en.md +244 -0
- package/README.ja.md +244 -0
- package/README.md +172 -52
- package/dist/chunk-YQD4KPT5.js +103 -0
- package/dist/chunk-YQD4KPT5.js.map +1 -0
- package/dist/client/index.d.ts +26 -25
- package/dist/client/index.js +61 -60
- package/dist/client/index.js.map +1 -1
- package/dist/index.d.ts +17 -19
- package/dist/index.js +719 -175
- package/dist/index.js.map +1 -1
- package/dist/payment-types-D02t-Pc9.d.ts +500 -0
- package/package.json +17 -15
- package/dist/api/index.d.ts +0 -211
- package/dist/api/index.js +0 -96
- package/dist/api/index.js.map +0 -1
- package/dist/chunk-4IUARFWH.js +0 -67
- package/dist/chunk-4IUARFWH.js.map +0 -1
- package/dist/payment-types-D1GvLKHg.d.ts +0 -209
package/README.en.md
ADDED
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
# @h-ai/payment
|
|
2
|
+
|
|
3
|
+
[简体中文](./README.md) | [English](./README.en.md) | [日本語](./README.ja.md)
|
|
4
|
+
|
|
5
|
+
[Framework home](../../README.en.md) · Related modules: [@h-ai/audit](../audit/README.en.md) · [@h-ai/core](../core/README.en.md)
|
|
6
|
+
|
|
7
|
+
## Introduction
|
|
8
|
+
|
|
9
|
+
A unified payment module for WeChat Pay, Alipay, and Stripe, with provider-based server processing and payment initiation across clients.
|
|
10
|
+
|
|
11
|
+
## Core features
|
|
12
|
+
|
|
13
|
+
| Provider | Payment scenarios |
|
|
14
|
+
| ---------- | ---------------------------------------------------------- |
|
|
15
|
+
| WeChat Pay | `jsapi`: WeChat public-account browser |
|
|
16
|
+
| Alipay | `h5`: signed WAP form POST |
|
|
17
|
+
| Stripe | h5: hosted Checkout page redirect (international payments) |
|
|
18
|
+
|
|
19
|
+
Built-in creation accepts only the combinations above. Public `TradeType` is `jsapi | h5`; App, Native, mini-program, and other combinations fail with `UNSUPPORTED_TRADE_TYPE` before contacting the channel. WeChat requires the current public account’s openid in `userId`; WeChat and Alipay accept CNY only. Missing WeChat `prepay_id` is a failure. Alipay `createOrder` creates signed page parameters, without claiming channel acceptance or payment. Its `{ gateway, fields }` payload is submitted as a complete POST form to the fixed production/sandbox gateway. The client never parses HTML or accepts arbitrary actions. Required `alipay.returnUrl` is a trusted HTTPS URL, configurable through `HAI_PAYMENT_ALIPAY_RETURNURL`; returning there is not payment proof. Check both `success` and `data.invoked`. Wait for the WeChat bridge before invoking; even a cancellation callback can follow a successful invocation.
|
|
20
|
+
|
|
21
|
+
Protocol references: [WeChat JSAPI creation](https://pay.wechatpay.cn/doc/v3/merchant/4012791897), [client invocation](https://pay.wechatpay.cn/doc/v3/merchant/4012791857), and the [official Alipay Node SDK pageExecute contract](https://github.com/alipay/alipay-sdk-nodejs-all#pageexecute-示例代码). Targeted checks cover protocol shapes, actual RSA verification, jsdom consumption, and a real Chrome POST intercepted by the test. Merchant credentials, channel sandbox accounts, and phone devices were unavailable; these checks do not establish real payment acceptance.
|
|
22
|
+
|
|
23
|
+
## Getting started
|
|
24
|
+
|
|
25
|
+
### Dependencies
|
|
26
|
+
|
|
27
|
+
- `@h-ai/audit`: optional audit logging; payment operations are recorded automatically when audit is initialized.
|
|
28
|
+
- `@h-ai/reldb`: required when Stripe is enabled. Initialize a shared database first for durable provider mappings and idempotent outcomes. WeChat/Alipay alone do not require it.
|
|
29
|
+
|
|
30
|
+
### Quick start
|
|
31
|
+
|
|
32
|
+
#### Node.js server
|
|
33
|
+
|
|
34
|
+
```typescript
|
|
35
|
+
import type { PaymentConfigInput } from '@h-ai/payment'
|
|
36
|
+
import { payment } from '@h-ai/payment'
|
|
37
|
+
|
|
38
|
+
async function paymentExample(trustedConfig: PaymentConfigInput, trustedOpenid: string) {
|
|
39
|
+
// 常驻服务在启动/停机时分别 init/close;此函数演示完整生命周期。
|
|
40
|
+
// 如启用 Stripe,宿主已先初始化共享 reldb 并检查 success。
|
|
41
|
+
const initialized = await payment.init(trustedConfig)
|
|
42
|
+
if (!initialized.success)
|
|
43
|
+
throw new Error(initialized.error.message)
|
|
44
|
+
try {
|
|
45
|
+
const created = await payment.createOrder('wechat', {
|
|
46
|
+
orderNo: 'ORD001',
|
|
47
|
+
amount: 100,
|
|
48
|
+
description: '测试商品',
|
|
49
|
+
tradeType: 'jsapi',
|
|
50
|
+
userId: trustedOpenid,
|
|
51
|
+
notifyUrl: 'https://api.example.com/payment/notify/wechat',
|
|
52
|
+
})
|
|
53
|
+
if (!created.success)
|
|
54
|
+
throw new Error(created.error.message)
|
|
55
|
+
const query = await payment.queryOrder('wechat', 'ORD001')
|
|
56
|
+
if (!query.success)
|
|
57
|
+
throw new Error(query.error.message)
|
|
58
|
+
return { order: created.data, status: query.data.status }
|
|
59
|
+
}
|
|
60
|
+
finally {
|
|
61
|
+
await payment.close() // 宿主随后才关闭其 reldb。
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
#### Browser client
|
|
67
|
+
|
|
68
|
+
```typescript
|
|
69
|
+
import { invokePayment } from '@h-ai/payment/client'
|
|
70
|
+
|
|
71
|
+
// 使用服务端返回的订单数据调起支付
|
|
72
|
+
const invoked = await invokePayment(orderData)
|
|
73
|
+
if (!invoked.success)
|
|
74
|
+
throw new Error(invoked.error.message)
|
|
75
|
+
if (!invoked.data.invoked)
|
|
76
|
+
throw new Error(invoked.data.message)
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
### HTTP API contracts
|
|
80
|
+
|
|
81
|
+
Payment HTTP APIs are defined by `apiContract.payment` in `@h-ai/api-contract`. After composing them into the application contract, call them through the typed client:
|
|
82
|
+
|
|
83
|
+
```typescript
|
|
84
|
+
const order = await api.payment.orders.create({
|
|
85
|
+
provider: 'wechat',
|
|
86
|
+
amount: 100,
|
|
87
|
+
description: '商品',
|
|
88
|
+
tradeType: 'jsapi',
|
|
89
|
+
userId: 'trusted-public-account-openid',
|
|
90
|
+
})
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
### Configuration
|
|
94
|
+
|
|
95
|
+
```typescript
|
|
96
|
+
import type { PaymentConfigInput } from '@h-ai/payment'
|
|
97
|
+
import { PaymentConfigSchema } from '@h-ai/payment'
|
|
98
|
+
|
|
99
|
+
// 仅为配置字段示意;真实密钥、证书和商户身份由私有部署注入。
|
|
100
|
+
const input: PaymentConfigInput = {
|
|
101
|
+
wechat: {
|
|
102
|
+
mchId: '商户号',
|
|
103
|
+
apiV3Key: 'API v3 密钥',
|
|
104
|
+
serialNo: '证书序列号',
|
|
105
|
+
privateKey: '私钥内容',
|
|
106
|
+
appId: '应用 ID',
|
|
107
|
+
platformKeys: { PUB_KEY_ID_123: '平台公钥 PEM', CERT_SERIAL: '平台证书 PEM' },
|
|
108
|
+
},
|
|
109
|
+
alipay: {
|
|
110
|
+
appId: '应用 ID',
|
|
111
|
+
sellerId: '卖家支付宝用户 ID',
|
|
112
|
+
privateKey: '私钥',
|
|
113
|
+
alipayPublicKey: '支付宝公钥',
|
|
114
|
+
returnUrl: 'https://app.example/payment/return', // 必填 HTTPS
|
|
115
|
+
signType: 'RSA2', // 可选,默认 RSA2
|
|
116
|
+
sandbox: false, // 可选,默认 false
|
|
117
|
+
},
|
|
118
|
+
stripe: {
|
|
119
|
+
secretKey: 'sk_test_xxx',
|
|
120
|
+
webhookSecret: 'whsec_xxx',
|
|
121
|
+
successUrl: 'https://app.example/payment/success?session_id={CHECKOUT_SESSION_ID}', // Required
|
|
122
|
+
cancelUrl: 'https://app.example/payment/cancel', // Optional
|
|
123
|
+
webhookToleranceSeconds: 300, // 可选,默认 300 秒
|
|
124
|
+
},
|
|
125
|
+
}
|
|
126
|
+
const parsed = PaymentConfigSchema.safeParse(input)
|
|
127
|
+
if (!parsed.success)
|
|
128
|
+
throw new Error('Payment configuration is invalid')
|
|
129
|
+
// 使用 parsed.data;启用 Stripe 前须先初始化共享 reldb。
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
Configure only the providers you need; unconfigured providers are not registered.
|
|
133
|
+
|
|
134
|
+
Amounts use minor currency units (cents) and must be positive integers. `amount` / `totalAmount` are validated before calling a provider, so invalid amounts never reach the payment platform.
|
|
135
|
+
|
|
136
|
+
All built-in provider HTTP requests have a default 15-second timeout to avoid long hangs on network failures.
|
|
137
|
+
|
|
138
|
+
### Notification identity, clocks, and business idempotency
|
|
139
|
+
|
|
140
|
+
WeChat requires `platformKeys`, mapping trusted certificate serials or public-key IDs to their PEM values. `Wechatpay-Serial` selects exactly one entry; unknown identifiers fail. Certificates are checked against their serial and validity period. Deploy both old and new entries on every node during rotation. No key is downloaded from notification data. Signed timestamps must be strict integers within 300 seconds of the synchronized node clock. Only `TRANSACTION.SUCCESS` transaction notifications are accepted, and decrypted `appid/mchid` must match configuration.
|
|
141
|
+
|
|
142
|
+
Alipay requires `sellerId`, verifies `app_id/seller_id` after signature verification, accepts only the configured RSA2/RSA algorithm, and rejects duplicate form parameters, unknown states, and invalid calendar dates. Requests explicitly use Asia/Shanghai time and send signed parameters in the POST form body, excluding order data from URLs. Notification/payment dates use Beijing time. Valid retries may arrive on later days; their original dates are not a five-minute signature window. Stripe validates strict signing timestamps, supports multiple v1 signatures during rotation, and checks test/live mode. Connect/organization-context events are unsupported; configure the current account's matching webhook secret.
|
|
143
|
+
|
|
144
|
+
All built-in and custom providers must return `eventId` and uppercase `currency`. IDs are WeChat id, Alipay notify_id, and Stripe event.id. Providers verify and parse; the application must verify its order/provider/amount/currency, persist deduplication within a merchant namespace, and prevent state regression. Never reuse deleted order numbers. The [runnable consumer example](examples/payment-notify-consumer.ts) commits an order CAS, unique event, and fulfillment outbox atomically. Only acknowledge after commit. External fulfillment needs an idempotent outbox consumer with a stable key; a database transaction cannot promise exactly-once external effects. Two real Node processes sharing SQLite verify one outbox entry across duplicates and restart; production nodes should share PostgreSQL/MySQL.
|
|
145
|
+
|
|
146
|
+
References: [WeChat certificate and timestamp rules](https://pay.wechatpay.cn/doc/v3/merchant/4013053420), [public-key rotation](https://pay.wechatpay.cn/doc/v3/merchant/4012154180), [Alipay notification validation and retries](https://developer.alibaba.com/docs/doc.htm?articleId=103296&docType=1&treeId=346).
|
|
147
|
+
|
|
148
|
+
### Alipay business responses and refund states
|
|
149
|
+
|
|
150
|
+
Queries, refunds, and closing validate the operation-specific response envelope, string `code=10000`, required fields, and returned order number. HTTP 200, a missing envelope, or a success code without business fields is insufficient. Missing trades, permission/balance errors, and other business failures return the matching `QUERY_ORDER_FAILED` / `REFUND_FAILED` / `CLOSE_ORDER_FAILED`. Error causes retain bounded provider error identifiers, excluding raw responses and `msg/sub_msg`. Queries reject unknown states and invalid amounts instead of inventing pending orders or zero amounts.
|
|
151
|
+
|
|
152
|
+
For refunds, check `data.status` even when `success` is true. Only `fund_change=Y` means `success`; `N` or an absent value returns `processing` and requires confirmation through Alipay refund queries (this module has no refund-query API yet). `refund_fee` is cumulative and must not be compared with this individual refund amount. Alipay `refundId` identifies the original trade; use `refundNo` for each refund and preserve its parameters when retrying. Update business orders only after checking `closeOrder` success and the refund status.
|
|
153
|
+
|
|
154
|
+
Reference: [Alipay refund success rules](https://aipay.alipay.com/docs/subscribe/api-list/other-basic-interfaces/alipay-trade-refund.html).
|
|
155
|
+
|
|
156
|
+
### Stripe hosted Checkout and closing orders
|
|
157
|
+
|
|
158
|
+
Only `mode=payment`, `ui_mode=hosted_page`, and `tradeType=h5` are implemented. Requests pin `Stripe-Version: 2026-09-30.endive`. Embedded/elements, JSAPI, App, Native, and mini-program modes are rejected by the provider, HTTP schema, and browser entry. Server-side `successUrl` is required; `cancelUrl` is optional. Both accept HTTP(S); the success URL may contain Stripe's literal `{CHECKOUT_SESSION_ID}` placeholder. Clients cannot override these values, and retries for the same order cannot change the creation parameters.
|
|
159
|
+
|
|
160
|
+
`closeOrder('stripe', orderNo)` retrieves the persisted Session: open/unpaid calls its expire endpoint, an already expired Session confirms idempotent success, and complete/paid fails. Concurrent closing or response loss triggers a fresh read; success requires confirmed expiration. Navigating to a success/cancel page proves neither payment nor closure.
|
|
161
|
+
|
|
162
|
+
References: [Create Checkout Session](https://docs.stripe.com/api/checkout/sessions/create), [Expire Session](https://docs.stripe.com/api/checkout/sessions/expire).
|
|
163
|
+
|
|
164
|
+
### Stripe webhook states
|
|
165
|
+
|
|
166
|
+
The Stripe provider handles payment-mode `checkout.session.completed`, `checkout.session.async_payment_succeeded`, `checkout.session.async_payment_failed`, and `checkout.session.expired` events. Subscribe the endpoint to these events only. Other resource events or incomplete Sessions return `NOTIFY_PARSE_FAILED` instead of producing a pending order.
|
|
167
|
+
|
|
168
|
+
A completed Session with `payment_status=unpaid` returns `pending`; only `paid` / `no_payment_required` returns `paid`. Asynchronous failure returns `failed`, and expiration returns `closed`. Only paid results contain `paidAt`, derived from the successful payment event's `created` timestamp so repeated delivery keeps the same value.
|
|
169
|
+
|
|
170
|
+
Results include `eventId` and uppercase `currency`; `transactionId` is the Checkout Session ID. The application must verify the order, amount, and currency in a shared database, persist deduplication, and prevent out-of-order events from reverting paid/refunded orders. Signature verification and parsing do not replace that business transaction. Check `status === 'paid'` before fulfillment, even when `success` is true.
|
|
171
|
+
|
|
172
|
+
References: [Stripe event types](https://docs.stripe.com/api/events/types), [Checkout Session fields](https://docs.stripe.com/api/checkout/sessions/object).
|
|
173
|
+
|
|
174
|
+
### Stripe persistence and idempotency
|
|
175
|
+
|
|
176
|
+
Before enabling Stripe, initialize reldb and check its success, then initialize payment. Close payment before reldb. Initialization waits for `hai_payment_stripe_operations` to be ready and does not publish a provider on failure. Multiple nodes must use the same PostgreSQL/MySQL database. SQLite is for development or explicitly shared files on one host, not separate local files across hosts.
|
|
177
|
+
|
|
178
|
+
The first operation needing an order mapping reads `/v1/account`. The stable account identity and test/live key mode isolate records and preserve mappings after key rotation. Keys must start with `sk_test_`, `sk_live_`, `rk_test_`, or `rk_live_`; restricted keys also need account read permission.
|
|
179
|
+
|
|
180
|
+
Order creation uses a stable `Idempotency-Key` derived from the account, mode, and orderNo. A unique database record binds the normalized request digest to its Session ID; callers cannot override metadata.orderNo. Queries/refunds retrieve that specific Session, verify order number, amount, and currency, and persist its PaymentIntent. Unknown orders fail. Refunds persist their result by refundNo and reject changed parameters. Free-text reason is preserved in refund metadata; the Stripe reason is requested_by_customer.
|
|
181
|
+
|
|
182
|
+
Response loss or persistence failure retains the reservation and cannot return success. Unknown outcomes can reuse the same key only within a 23-hour window measured by the database. Afterward, reconcile with Stripe and repair the record before retrying; do not delete it and blindly resend. Confirmed results are retained without a cache TTL. A refund result describes its acceptance; processing does not mean funds have arrived. See [Stripe idempotency retention](https://docs.stripe.com/api/idempotent_requests).
|
|
183
|
+
|
|
184
|
+
### Audit logging
|
|
185
|
+
|
|
186
|
+
Successful key payment operations automatically write audit logs through `@h-ai/audit`, without extra configuration. Audit failures produce warn logs and do not affect payment operations.
|
|
187
|
+
|
|
188
|
+
| Operation | `action` | Recorded data |
|
|
189
|
+
| ---------------- | ---------------- | --------------------------------------- |
|
|
190
|
+
| Create order | `create_order` | provider、amount、tradeType |
|
|
191
|
+
| Payment callback | `payment_notify` | provider、transactionId、status、amount |
|
|
192
|
+
| Refund | `refund` | provider、refundNo、amount |
|
|
193
|
+
| Close order | `close_order` | provider |
|
|
194
|
+
|
|
195
|
+
> Order queries (`queryOrder`) are read-only and do not write audit logs.
|
|
196
|
+
|
|
197
|
+
### Error handling
|
|
198
|
+
|
|
199
|
+
```typescript
|
|
200
|
+
const result = await payment.createOrder('wechat', orderInput)
|
|
201
|
+
if (!result.success) {
|
|
202
|
+
switch (result.error.code) {
|
|
203
|
+
case HaiPaymentError.INVALID_AMOUNT.code:
|
|
204
|
+
// 金额不是正整数,或 refund.totalAmount < amount
|
|
205
|
+
break
|
|
206
|
+
case HaiPaymentError.PROVIDER_NOT_FOUND.code:
|
|
207
|
+
// Provider 未注册
|
|
208
|
+
break
|
|
209
|
+
case HaiPaymentError.CREATE_ORDER_FAILED.code:
|
|
210
|
+
// 创建订单失败
|
|
211
|
+
break
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
```
|
|
215
|
+
|
|
216
|
+
Common error codes:
|
|
217
|
+
|
|
218
|
+
| Error code | code | Description |
|
|
219
|
+
| ---------------------------------------- | ----------------- | -------------------------------------- |
|
|
220
|
+
| `HaiPaymentError.CREATE_ORDER_FAILED` | `hai:payment:001` | Order creation failed |
|
|
221
|
+
| `HaiPaymentError.NOT_INITIALIZED` | `hai:payment:010` | Module not initialized |
|
|
222
|
+
| `HaiPaymentError.PROVIDER_NOT_FOUND` | `hai:payment:030` | Provider not registered |
|
|
223
|
+
| `HaiPaymentError.INVALID_AMOUNT` | `hai:payment:040` | Invalid amount |
|
|
224
|
+
| `HaiPaymentError.UNSUPPORTED_TRADE_TYPE` | `hai:payment:041` | Payment scenario is not implemented |
|
|
225
|
+
| `HaiPaymentError.NOTIFY_VERIFY_FAILED` | `hai:payment:050` | Callback signature verification failed |
|
|
226
|
+
| `HaiPaymentError.NOTIFY_PARSE_FAILED` | `hai:payment:051` | Callback parsing failed |
|
|
227
|
+
| `HaiPaymentError.CONFIG_ERROR` | `hai:payment:070` | Invalid configuration |
|
|
228
|
+
|
|
229
|
+
## Development and testing
|
|
230
|
+
|
|
231
|
+
```bash
|
|
232
|
+
pnpm --filter @h-ai/payment typecheck
|
|
233
|
+
pnpm --filter @h-ai/payment lint
|
|
234
|
+
pnpm --filter @h-ai/payment build
|
|
235
|
+
pnpm --filter @h-ai/payment test
|
|
236
|
+
node scripts/check-module-jsdoc.mjs --package payment
|
|
237
|
+
pnpm --filter @h-ai/payment exec vitest run tests/payment-provider-alipay.test.ts tests/payment-provider-wechat.test.ts tests/payment-provider-stripe.test.ts
|
|
238
|
+
```
|
|
239
|
+
|
|
240
|
+
Declaration examples use the actual `@h-ai/payment` / `@h-ai/payment/client` entries and distinguish configuration input from parsed output, and `HaiResult.success` from confirmed payment or refund status. Signing, protocol parsing, and durable operation helpers are marked as internal context and are not application deep-import APIs. Run `node scripts/check-module-jsdoc.mjs --package payment` to check coverage and example types. It does not execute real payments, network calls, device bridges, or fulfillment; verify those integrations separately. See the [module JSDoc guide](../../.agents/instructions/module-conventions.md#jsdoc-与示例验证).
|
|
241
|
+
|
|
242
|
+
## License
|
|
243
|
+
|
|
244
|
+
Apache-2.0
|
package/README.ja.md
ADDED
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
# @h-ai/payment
|
|
2
|
+
|
|
3
|
+
[简体中文](./README.md) | [English](./README.en.md) | [日本語](./README.ja.md)
|
|
4
|
+
|
|
5
|
+
[フレームワークのホーム](../../README.ja.md) · 関連モジュール:[@h-ai/audit](../audit/README.ja.md) · [@h-ai/core](../core/README.ja.md)
|
|
6
|
+
|
|
7
|
+
## 概要
|
|
8
|
+
|
|
9
|
+
WeChat Pay、Alipay、Stripe に対応する統一決済モジュールです。Provider によるサーバー側の決済処理と、複数プラットフォームからの決済開始を提供します。
|
|
10
|
+
|
|
11
|
+
## 主な機能
|
|
12
|
+
|
|
13
|
+
| Provider | 決済方式 |
|
|
14
|
+
| ---------- | ------------------------------------------------ |
|
|
15
|
+
| WeChat Pay | `jsapi`:WeChat 公式アカウント内ブラウザー |
|
|
16
|
+
| Alipay | `h5`:署名済み WAP フォーム POST |
|
|
17
|
+
| Stripe | h5:ホスト型 Checkout ページへの遷移(国際決済) |
|
|
18
|
+
|
|
19
|
+
組み込み作成処理は上記の組み合わせに限定されます。公開 `TradeType` は `jsapi | h5` で、App・Native・ミニプログラムなどは通信前に `UNSUPPORTED_TRADE_TYPE` を返します。WeChat の `userId` は現在の公式アカウントの openid が必須で、WeChat/Alipay は CNY のみ受け付けます。有効な `prepay_id` の欠落もエラーです。Alipay の `createOrder` は署名済みページパラメーターを生成する処理で、チャネル受付や支払い完了を意味しません。返された `{ gateway, fields }` の全署名フィールドを固定の本番/サンドボックスゲートウェイへ POST します。HTML の解析や任意 action は許可しません。信頼する HTTPS の `alipay.returnUrl` が必須で、`HAI_PAYMENT_ALIPAY_RETURNURL` でも設定できます。戻り先への遷移は支払証明になりません。`success` と `data.invoked` の両方を確認してください。WeChat ブリッジの準備を待ち、キャンセルのクライアント応答を支払いと解釈しないでください。
|
|
20
|
+
|
|
21
|
+
仕様:[WeChat JSAPI 注文](https://pay.wechatpay.cn/doc/v3/merchant/4012791897)、[クライアント呼び出し](https://pay.wechatpay.cn/doc/v3/merchant/4012791857)、[Alipay 公式 Node SDK pageExecute](https://github.com/alipay/alipay-sdk-nodejs-all#pageexecute-示例代码)。定向テストはプロトコル形状、実際の RSA 検証、jsdom 処理、テストで通信を遮断した Chrome の POSTを確認します。実加盟店の認証情報、チャネルのサンドボックスアカウント、実端末を使った決済検証は行っていません。
|
|
22
|
+
|
|
23
|
+
## 使い始める
|
|
24
|
+
|
|
25
|
+
### 依存関係
|
|
26
|
+
|
|
27
|
+
- `@h-ai/audit`:任意の監査ログ。初期化済みの場合、決済操作を自動記録します。
|
|
28
|
+
- `@h-ai/reldb`:Stripe 有効時は必須です。共有データベースを先に初期化し、チャネル識別子と冪等結果を永続化します。WeChat/Alipay のみなら必須ではありません。
|
|
29
|
+
|
|
30
|
+
### クイックスタート
|
|
31
|
+
|
|
32
|
+
#### Node.js サーバー
|
|
33
|
+
|
|
34
|
+
```typescript
|
|
35
|
+
import type { PaymentConfigInput } from '@h-ai/payment'
|
|
36
|
+
import { payment } from '@h-ai/payment'
|
|
37
|
+
|
|
38
|
+
async function paymentExample(trustedConfig: PaymentConfigInput, trustedOpenid: string) {
|
|
39
|
+
// 常驻服务在启动/停机时分别 init/close;此函数演示完整生命周期。
|
|
40
|
+
// 如启用 Stripe,宿主已先初始化共享 reldb 并检查 success。
|
|
41
|
+
const initialized = await payment.init(trustedConfig)
|
|
42
|
+
if (!initialized.success)
|
|
43
|
+
throw new Error(initialized.error.message)
|
|
44
|
+
try {
|
|
45
|
+
const created = await payment.createOrder('wechat', {
|
|
46
|
+
orderNo: 'ORD001',
|
|
47
|
+
amount: 100,
|
|
48
|
+
description: '测试商品',
|
|
49
|
+
tradeType: 'jsapi',
|
|
50
|
+
userId: trustedOpenid,
|
|
51
|
+
notifyUrl: 'https://api.example.com/payment/notify/wechat',
|
|
52
|
+
})
|
|
53
|
+
if (!created.success)
|
|
54
|
+
throw new Error(created.error.message)
|
|
55
|
+
const query = await payment.queryOrder('wechat', 'ORD001')
|
|
56
|
+
if (!query.success)
|
|
57
|
+
throw new Error(query.error.message)
|
|
58
|
+
return { order: created.data, status: query.data.status }
|
|
59
|
+
}
|
|
60
|
+
finally {
|
|
61
|
+
await payment.close() // 宿主随后才关闭其 reldb。
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
#### ブラウザークライアント
|
|
67
|
+
|
|
68
|
+
```typescript
|
|
69
|
+
import { invokePayment } from '@h-ai/payment/client'
|
|
70
|
+
|
|
71
|
+
// 使用服务端返回的订单数据调起支付
|
|
72
|
+
const invoked = await invokePayment(orderData)
|
|
73
|
+
if (!invoked.success)
|
|
74
|
+
throw new Error(invoked.error.message)
|
|
75
|
+
if (!invoked.data.invoked)
|
|
76
|
+
throw new Error(invoked.data.message)
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
### HTTP API 契約
|
|
80
|
+
|
|
81
|
+
決済 HTTP API は `@h-ai/api-contract` の `apiContract.payment` で定義します。アプリケーション contract に組み込むと、型付きクライアントで呼び出せます:
|
|
82
|
+
|
|
83
|
+
```typescript
|
|
84
|
+
const order = await api.payment.orders.create({
|
|
85
|
+
provider: 'wechat',
|
|
86
|
+
amount: 100,
|
|
87
|
+
description: '商品',
|
|
88
|
+
tradeType: 'jsapi',
|
|
89
|
+
userId: 'trusted-public-account-openid',
|
|
90
|
+
})
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
### 設定
|
|
94
|
+
|
|
95
|
+
```typescript
|
|
96
|
+
import type { PaymentConfigInput } from '@h-ai/payment'
|
|
97
|
+
import { PaymentConfigSchema } from '@h-ai/payment'
|
|
98
|
+
|
|
99
|
+
// 仅为配置字段示意;真实密钥、证书和商户身份由私有部署注入。
|
|
100
|
+
const input: PaymentConfigInput = {
|
|
101
|
+
wechat: {
|
|
102
|
+
mchId: '商户号',
|
|
103
|
+
apiV3Key: 'API v3 密钥',
|
|
104
|
+
serialNo: '证书序列号',
|
|
105
|
+
privateKey: '私钥内容',
|
|
106
|
+
appId: '应用 ID',
|
|
107
|
+
platformKeys: { PUB_KEY_ID_123: '平台公钥 PEM', CERT_SERIAL: '平台证书 PEM' },
|
|
108
|
+
},
|
|
109
|
+
alipay: {
|
|
110
|
+
appId: '应用 ID',
|
|
111
|
+
sellerId: '卖家支付宝用户 ID',
|
|
112
|
+
privateKey: '私钥',
|
|
113
|
+
alipayPublicKey: '支付宝公钥',
|
|
114
|
+
returnUrl: 'https://app.example/payment/return', // 必填 HTTPS
|
|
115
|
+
signType: 'RSA2', // 可选,默认 RSA2
|
|
116
|
+
sandbox: false, // 可选,默认 false
|
|
117
|
+
},
|
|
118
|
+
stripe: {
|
|
119
|
+
secretKey: 'sk_test_xxx',
|
|
120
|
+
webhookSecret: 'whsec_xxx',
|
|
121
|
+
successUrl: 'https://app.example/payment/success?session_id={CHECKOUT_SESSION_ID}', // 必須
|
|
122
|
+
cancelUrl: 'https://app.example/payment/cancel', // 任意
|
|
123
|
+
webhookToleranceSeconds: 300, // 可选,默认 300 秒
|
|
124
|
+
},
|
|
125
|
+
}
|
|
126
|
+
const parsed = PaymentConfigSchema.safeParse(input)
|
|
127
|
+
if (!parsed.success)
|
|
128
|
+
throw new Error('Payment configuration is invalid')
|
|
129
|
+
// 使用 parsed.data;启用 Stripe 前须先初始化共享 reldb。
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
必要な Provider のみ設定してください。未設定の Provider は登録されません。
|
|
133
|
+
|
|
134
|
+
金額は最小通貨単位(分・セント)で指定し、正の整数でなければなりません。Provider を呼ぶ前に `amount` / `totalAmount` を検証し、不正な金額を決済サービスに渡さないようにします。
|
|
135
|
+
|
|
136
|
+
すべての組み込み Provider の HTTP リクエストには既定で 15 秒のタイムアウトがあり、ネットワーク障害による長時間の待機を防ぎます。
|
|
137
|
+
|
|
138
|
+
### 通知の識別・時刻・業務上の冪等性
|
|
139
|
+
|
|
140
|
+
WeChat は `platformKeys` が必須です。信頼する証明書シリアル番号または公開鍵 ID と PEM を登録し、`Wechatpay-Serial` で一件を選択します。不明な ID は拒否し、証明書のシリアル番号と有効期間も検証します。鍵の更新中は全ノードに新旧の鍵を設定してください。通知から鍵を自動取得しません。署名時刻は厳密な整数で、同期したノード時刻との差が 300 秒以内である必要があります。`TRANSACTION.SUCCESS` の transaction 通知のみ受け付け、復号後の `appid/mchid` も照合します。
|
|
141
|
+
|
|
142
|
+
Alipay は `sellerId` を必須とし、署名検証後に `app_id/seller_id` と設定済みの RSA2/RSA を照合します。重複パラメータ、不明な状態、不正な暦日は拒否します。リクエスト時刻には Asia/Shanghai を明示し、署名と注文情報は URL ではなく POST form body に送ります。通知・支払日時も北京時間として解釈します。日をまたぐ正規の再送を許可し、元の日時に五分制限は適用しません。Stripe は厳密な署名時刻、更新中の複数 v1 署名、test/live を検証します。Connect・組織コンテキストのイベントは未対応で、現在のアカウント用 Webhook secret が必要です。
|
|
143
|
+
|
|
144
|
+
組み込み・カスタム Provider は必須の `eventId` と大文字 `currency` を返します。ID は WeChat id・Alipay notify_id・Stripe event.id です。業務側は共有 DB でチャネル・注文・金額・通貨を照合し、商戸単位の重複排除と状態の逆戻り防止を行います。注文番号は削除後も再利用しません。[実行可能な消費者例](examples/payment-notify-consumer.ts) は注文 CAS、通知記録、履行 outbox を同一トランザクションで保存し、コミット後にのみ成功応答します。外部配送には固定 ID による冪等処理が必要で、DB だけで外部副作用の厳密な一回実行は保証できません。二つの実 Node プロセスと共有 SQLite で重複・再起動後も outbox が一件であることを検証します。複数ホストの本番では共有 PostgreSQL/MySQL を使用してください。
|
|
145
|
+
|
|
146
|
+
参照:[WeChat 証明書と時刻](https://pay.wechatpay.cn/doc/v3/merchant/4013053420)、[公開鍵更新](https://pay.wechatpay.cn/doc/v3/merchant/4012154180)、[Alipay 通知の照合と再送](https://developer.alibaba.com/docs/doc.htm?articleId=103296&docType=1&treeId=346)。
|
|
147
|
+
|
|
148
|
+
### Alipay の業務レスポンスと返金状態
|
|
149
|
+
|
|
150
|
+
照会・返金・終了は、メソッドごとの応答 envelope、文字列の `code=10000`、必須フィールド、返却注文番号を検証します。HTTP 200、envelope の欠落、成功コードだけでは成功としません。取引不存在・権限不足・残高不足などは対応する `QUERY_ORDER_FAILED` / `REFUND_FAILED` / `CLOSE_ORDER_FAILED` を返します。cause には長さを制限したエラー識別子だけを残し、生レスポンスと `msg/sub_msg` を公開しません。不明な状態や不正な金額を pending・ゼロ金額に置き換えません。
|
|
151
|
+
|
|
152
|
+
返金は `success: true` でも `data.status` を確認してください。`fund_change=Y` のみ `success`、`N` または欠落は `processing` とし、Alipay の返金照会で確認が必要です(本モジュールには返金照会 API はありません)。`refund_fee` は累計返金額のため今回の返金額と直接比較できません。Alipay の `refundId` は元取引番号であり、個々の返金は `refundNo` で識別し、再試行でも番号とパラメータを保持してください。業務注文の更新前に `closeOrder` の成功と返金状態を確認します。
|
|
153
|
+
|
|
154
|
+
参照:[Alipay 返金成功の判定](https://aipay.alipay.com/docs/subscribe/api-list/other-basic-interfaces/alipay-trade-refund.html)。
|
|
155
|
+
|
|
156
|
+
### Stripe ホスト型 Checkout と注文終了
|
|
157
|
+
|
|
158
|
+
`mode=payment`、`ui_mode=hosted_page`、`tradeType=h5` のみ実装し、API は `Stripe-Version: 2026-09-30.endive` に固定します。embedded/elements、JSAPI、App、Native、小程序は Provider・HTTP schema・ブラウザー入口で拒否します。サーバー設定の `successUrl` は必須、`cancelUrl` は任意で、HTTP(S) のみ使用できます。成功 URL には `{CHECKOUT_SESSION_ID}` を含められます。クライアントから変更できず、同じ注文の再試行でも作成パラメータを変更できません。
|
|
159
|
+
|
|
160
|
+
`closeOrder('stripe', orderNo)` は保存済み Session を照会し、open/unpaid の場合のみ expire を呼びます。expired 確認済みなら冪等に成功し、complete/paid は失敗します。同時終了や応答喪失時は再照会で過期を確認してから成功を返します。成功・キャンセル画面への遷移は支払完了や注文終了の証明ではありません。
|
|
161
|
+
|
|
162
|
+
参照:[Checkout Session 作成](https://docs.stripe.com/api/checkout/sessions/create)、[Session の終了](https://docs.stripe.com/api/checkout/sessions/expire)。
|
|
163
|
+
|
|
164
|
+
### Stripe 通知の状態
|
|
165
|
+
|
|
166
|
+
Stripe Provider は `mode=payment` の `checkout.session.completed`、`checkout.session.async_payment_succeeded`、`checkout.session.async_payment_failed`、`checkout.session.expired` のみ処理します。Webhook はこれらのイベントのみ購読してください。他のリソースイベントや不完全な Session は `NOTIFY_PARSE_FAILED` となり、仮の pending 注文を生成しません。
|
|
167
|
+
|
|
168
|
+
`completed` でも `payment_status=unpaid` なら `pending` であり、履行できません。`paid` / `no_payment_required` のみ `paid`、非同期決済失敗は `failed`、期限切れは `closed` です。`paidAt` は paid の場合のみ返り、成功イベントの `created` を使用するため再配信でも変わりません。
|
|
169
|
+
|
|
170
|
+
結果には `eventId` と大文字の `currency` が含まれ、`transactionId` は Checkout Session ID です。アプリケーションは共有データベースで注文・金額・通貨を照合し、通知の重複排除と状態の逆戻り防止を行う必要があります。署名検証と解析は業務トランザクションの代わりにはなりません。`success` だけでなく `status === 'paid'` を確認して履行してください。
|
|
171
|
+
|
|
172
|
+
参照:[Stripe イベント種類](https://docs.stripe.com/api/events/types)、[Checkout Session のフィールド](https://docs.stripe.com/api/checkout/sessions/object)。
|
|
173
|
+
|
|
174
|
+
### Stripe の永続化と冪等性
|
|
175
|
+
|
|
176
|
+
Stripe を有効にする前に reldb を初期化して success を確認し、その後 payment を初期化してください。終了は payment、reldb の順です。初期化は `hai_payment_stripe_operations` の作成を待ち、失敗時は Provider を公開しません。複数ノードは同一の PostgreSQL/MySQL を使用します。SQLite は開発または同一ホストで明示的に共有するファイル用で、別ホストのローカルファイルを共有状態として扱えません。
|
|
177
|
+
|
|
178
|
+
注文マッピングを必要とする最初の操作は `/v1/account` を読みます。アカウント ID とキーの test/live モードで記録を分離し、同アカウントのキー更新後もマッピングを維持します。キーは `sk_test_`、`sk_live_`、`rk_test_`、`rk_live_` のいずれかで始まり、制限付きキーにはアカウント読取権限も必要です。
|
|
179
|
+
|
|
180
|
+
注文作成はアカウント・モード・orderNo から安定した `Idempotency-Key` を生成し、DB の一意な記録で正規化リクエストと Session ID を結びます。metadata.orderNo の上書きは禁止です。照会・返金はその Session ID のみを読み、注文番号・金額・通貨を照合し、PaymentIntent を保存します。不明な注文は失敗します。返金は refundNo ごとに結果を保持し、同じ識別子でのパラメータ変更を拒否します。自由記述 reason は metadata に保存し、Stripe reason は requested_by_customer を使用します。
|
|
181
|
+
|
|
182
|
+
応答喪失や DB 保存失敗でも予約を削除せず、成功扱いにしません。結果不明の操作は DB 時刻による 23 時間の範囲内だけ同一キーで再試行できます。それ以降は Stripe と照合して記録を修復し、記録を削除して無条件に再送してはいけません。確定結果はキャッシュ TTL で削除しません。返金の processing は着金完了を示しません。参照:[Stripe の冪等性保持期間](https://docs.stripe.com/api/idempotent_requests)。
|
|
183
|
+
|
|
184
|
+
### 監査ログ
|
|
185
|
+
|
|
186
|
+
主要な決済操作が成功すると、追加設定なしで `@h-ai/audit` に監査ログを自動記録します。監査記録の失敗は warn ログのみを出力し、決済操作に影響しません。
|
|
187
|
+
|
|
188
|
+
| 操作 | `action` | 記録内容 |
|
|
189
|
+
| ---------- | ---------------- | --------------------------------------- |
|
|
190
|
+
| 注文作成 | `create_order` | provider、amount、tradeType |
|
|
191
|
+
| 決済通知 | `payment_notify` | provider、transactionId、status、amount |
|
|
192
|
+
| 返金 | `refund` | provider、refundNo、amount |
|
|
193
|
+
| 注文の終了 | `close_order` | provider |
|
|
194
|
+
|
|
195
|
+
> 注文照会(`queryOrder`)は読み取り専用で、監査ログを記録しません。
|
|
196
|
+
|
|
197
|
+
### エラー処理
|
|
198
|
+
|
|
199
|
+
```typescript
|
|
200
|
+
const result = await payment.createOrder('wechat', orderInput)
|
|
201
|
+
if (!result.success) {
|
|
202
|
+
switch (result.error.code) {
|
|
203
|
+
case HaiPaymentError.INVALID_AMOUNT.code:
|
|
204
|
+
// 金额不是正整数,或 refund.totalAmount < amount
|
|
205
|
+
break
|
|
206
|
+
case HaiPaymentError.PROVIDER_NOT_FOUND.code:
|
|
207
|
+
// Provider 未注册
|
|
208
|
+
break
|
|
209
|
+
case HaiPaymentError.CREATE_ORDER_FAILED.code:
|
|
210
|
+
// 创建订单失败
|
|
211
|
+
break
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
```
|
|
215
|
+
|
|
216
|
+
主なエラーコード:
|
|
217
|
+
|
|
218
|
+
| エラーコード | code | 説明 |
|
|
219
|
+
| ---------------------------------------- | ----------------- | -------------------- |
|
|
220
|
+
| `HaiPaymentError.CREATE_ORDER_FAILED` | `hai:payment:001` | 注文作成に失敗 |
|
|
221
|
+
| `HaiPaymentError.NOT_INITIALIZED` | `hai:payment:010` | モジュールが未初期化 |
|
|
222
|
+
| `HaiPaymentError.PROVIDER_NOT_FOUND` | `hai:payment:030` | Provider が未登録 |
|
|
223
|
+
| `HaiPaymentError.INVALID_AMOUNT` | `hai:payment:040` | 無効な金額 |
|
|
224
|
+
| `HaiPaymentError.UNSUPPORTED_TRADE_TYPE` | `hai:payment:041` | 未実装の決済方式 |
|
|
225
|
+
| `HaiPaymentError.NOTIFY_VERIFY_FAILED` | `hai:payment:050` | 通知の署名検証に失敗 |
|
|
226
|
+
| `HaiPaymentError.NOTIFY_PARSE_FAILED` | `hai:payment:051` | 通知の解析に失敗 |
|
|
227
|
+
| `HaiPaymentError.CONFIG_ERROR` | `hai:payment:070` | 無効な設定 |
|
|
228
|
+
|
|
229
|
+
## 開発とテスト
|
|
230
|
+
|
|
231
|
+
```bash
|
|
232
|
+
pnpm --filter @h-ai/payment typecheck
|
|
233
|
+
pnpm --filter @h-ai/payment lint
|
|
234
|
+
pnpm --filter @h-ai/payment build
|
|
235
|
+
pnpm --filter @h-ai/payment test
|
|
236
|
+
node scripts/check-module-jsdoc.mjs --package payment
|
|
237
|
+
pnpm --filter @h-ai/payment exec vitest run tests/payment-provider-alipay.test.ts tests/payment-provider-wechat.test.ts tests/payment-provider-stripe.test.ts
|
|
238
|
+
```
|
|
239
|
+
|
|
240
|
+
宣言の例は実際の `@h-ai/payment` / `@h-ai/payment/client` を使い、設定入力と解析済み出力、`HaiResult.success` と決済・返金の確定状態を区別します。署名、プロトコル解析、永続操作の内部ヘルパーには「模块内部上下文」と明記し、アプリからの深い import の入口にはしません。`node scripts/check-module-jsdoc.mjs --package payment` で網羅性と例の型を確認できます。実決済、ネットワーク通信、端末ブリッジ、履行は実行しないため、別途統合検証が必要です。[モジュール JSDoc 規約](../../.agents/instructions/module-conventions.md#jsdoc-与示例验证)も参照してください。
|
|
241
|
+
|
|
242
|
+
## ライセンス
|
|
243
|
+
|
|
244
|
+
Apache-2.0
|