Cell - WeChat Pay Component
Add @celljs/wechat-pay to the dependencies to take payments with WeChat Pay API v3 (微信支付): an injectable WechatPayClient signs the requests and verifies the signatures of the answers, and a notification endpoint verifies, decrypts and hands payment and refund notifications to your handlers. It uses Node's crypto and fetch, with no third-party dependency.
# cell.yml: configuration is server-only unless a schema marks it public; take credentials from the environment
wechatPay:
mchId: ${env.WECHAT_PAY_MCH_ID} # 商户号
appId: ${env.WECHAT_PAY_APP_ID} # 公众号 / 小程序 / App 的 AppID
serialNo: ${env.WECHAT_PAY_SERIAL_NO} # 商户 API 证书序列号
privateKey: ${env.WECHAT_PAY_PRIVATE_KEY} # apiclient_key.pem: its PEM text or its path
apiV3Key: ${env.WECHAT_PAY_API_V3_KEY} # APIv3 密钥
publicKeyId: ${env.WECHAT_PAY_PUBLIC_KEY_ID} # 微信支付公钥 ID: PUB_KEY_ID_...
publicKey: ${env.WECHAT_PAY_PUBLIC_KEY} # 微信支付公钥, PEM
notifyUrl: https://shop.example.com/wechat-pay/notifyimport { Autowired } from '@celljs/core';
import { Body, Controller, Post } from '@celljs/mvc/lib/node';
import { WechatPayClient, WechatPayNotification, WechatPayNotificationHandler, WechatPayTransaction } from '@celljs/wechat-pay';
@Controller('orders')
export class OrderController {
@Autowired(WechatPayClient)
protected readonly wechatPay: WechatPayClient;
@Post('native') // 扫码支付: show code_url as a QR code
async native(@Body() order: { id: string }) {
return this.wechatPay.createNativeOrder({ description: 'Cell T-shirt', out_trade_no: order.id, amount: { total: 100 } }); // in fen: 1 yuan
}
@Post('jsapi') // 小程序 / 公众号: the page calls wx.requestPayment(params)
async jsapi(@Body() order: { id: string; openid: string }) {
const { prepay_id } = await this.wechatPay.createJsapiOrder({ description: 'Cell T-shirt', out_trade_no: order.id, amount: { total: 100 }, payer: { openid: order.openid } });
return this.wechatPay.jsapiParams(prepay_id);
}
}
@WechatPayNotificationHandler('TRANSACTION.SUCCESS')
export class PaidHandler implements WechatPayNotificationHandler<WechatPayTransaction> {
async handle({ resource }: WechatPayNotification<WechatPayTransaction>) {
// resource is decrypted: out_trade_no, transaction_id, trade_state, amount.total...
// Mark the order paid once: the same notification can come again.
}
}Client
The payloads use the field names of WeChat Pay's documentation (snake_case), so its pages apply as they are. appid, mchid and notify_url default to the configuration.
| Method | API |
|---|---|
createJsapiOrder(order) → { prepay_id } | JSAPI / 小程序下单; then jsapiParams(prepayId, appId?) for wx.requestPayment / getBrandWCPayRequest |
createAppOrder(order) → { prepay_id } | App 下单; then appParams(prepayId, appId?) for the App SDK |
createNativeOrder(order) → { code_url } | Native 扫码下单 |
createH5Order(order) → { h5_url } | H5 下单 (scene_info.h5_info required) |
queryOrder({ out_trade_no } | { transaction_id }) | 查询订单 |
closeOrder(outTradeNo) | 关闭订单 |
refund(request) / queryRefund(outRefundNo) | 申请退款 / 查询单笔退款 |
tradeBill(query) / fundFlowBill(query), then download(download_url) | 交易账单 / 资金账单 |
request(method, path, body?, { query, headers }) | Any other API, signed and verified |
parseNotification(headers, rawBody) | Verifies and decrypts a notification received on a route of your own |
WechatPayUtil has the primitives: signMessage, verifyMessage, decrypt (AEAD_AES_256_GCM), encrypt (RSA-OAEP for sensitive fields; send the key's serial as Wechatpay-Serial in headers).
Notifications
The endpoint is POST /wechat-pay/notify (wechatPay.notification.url, under server.path); set its absolute URL as notifyUrl. It runs before the body parser and authentication, reads the body as sent, checks the signature and that the timestamp is within tolerance seconds (300), decrypts the resource and runs the @WechatPayNotificationHandler components of the event type (TRANSACTION.SUCCESS, REFUND.SUCCESS, REFUND.ABNORMAL, REFUND.CLOSED), then those declared without types, one after the other. It answers:
- 204 when they all succeed (or when no handler takes the event type: logged as a warning);
- 401
{"code":"FAIL"}when the signature or the timestamp is wrong; - 500
{"code":"FAIL"}when a handler throws: WeChat Pay sends the notification again later, so handlers must be idempotent.
Notifications only say what happened: for amounts that matter, check resource.amount.total against the order, or call queryOrder.
Keys that verify WeChat Pay
Merchants created since 2024 have a WeChat Pay public key (publicKeyId + publicKey). Older merchants verify with platform certificates (平台证书): leave publicKey out and the client downloads them from /v3/certificates when a signature names a serial it does not know (at most once a minute), or give them as platformCertificates (PEM, one or a list). Both can be set during a migration: the serial in Wechatpay-Serial selects the key.
Configuration
wechatPay:
baseUrl: https://api.mch.weixin.qq.com
timeout: 10000 # milliseconds per request
notification:
enabled: true
url: /wechat-pay/notify
tolerance: 300 # secondsErrors
Failures are WechatPayErrors with a stable code and a hint:
| Code | When |
|---|---|
CELL_WECHAT_PAY_NOT_CONFIGURED | mchId, serialNo, privateKey, apiV3Key or the AppID is missing |
CELL_WECHAT_PAY_API_ERROR | WeChat Pay answered an error: status and detail.code (ORDER_NOT_EXIST, PARAM_ERROR...) |
CELL_WECHAT_PAY_SIGNATURE_INVALID | An answer or a notification is not signed by WeChat Pay |
CELL_WECHAT_PAY_TIMESTAMP_EXPIRED | A notification's timestamp is outside tolerance |
CELL_WECHAT_PAY_UNKNOWN_SERIAL | No public key or platform certificate has the serial of a signature |
CELL_WECHAT_PAY_DECRYPT_FAILED | The resource cannot be decrypted: wrong apiV3Key |
WeChat Pay API v3 has no sandbox: test with a real merchant and small amounts, and expose the notification endpoint with a tunnel during development.