import crypto from 'node:crypto'; import { HttpError } from '../../core/http.js'; import { config as appConfig } from '../../core/config.js'; import { optionalPublicBoolean, optionalPublicString, providerEndpoint, requirePublicString, requireSecretString, type TenantPaymentProviderConfig, } from '../../core/tenant-provider-config.js'; export type PaymentProviderName = 'wechat_pay' | 'alipay' | 'manual'; export interface PaymentOrderInput { tenantId: string; orderId: string; orderNo: string; description: string; amountCents: number; userId: string; openId?: string; clientIp?: string; returnUrl?: string; quitUrl?: string; } export interface PaymentCreateResult { provider: PaymentProviderName; method: string; providerTradeNo?: string; paymentParams: Record; raw: Record; } export interface PaymentNotificationResult { provider: PaymentProviderName; eventId: string; eventType: string; orderNo: string; providerTradeNo: string; amountCents: number; paidAt?: string; raw: Record; } export interface PaymentProvider { name: PaymentProviderName; createPayment(input: PaymentOrderInput): Promise; parseNotification(input: { headers: Record; body: Record; rawBody: string; }): Promise; } function base64urlDecode(value: string) { return Buffer.from(value.replace(/-/g, '+').replace(/_/g, '/'), 'base64'); } function objectValue(value: unknown): Record { return value && typeof value === 'object' && !Array.isArray(value) ? (value as Record) : {}; } function requiredBodyString(body: Record, key: string, code: string) { const value = body[key]; if (typeof value === 'string' && value.trim()) return value.trim(); throw new HttpError(400, `${key} is required`, code); } function optionalBodyString(body: Record, key: string) { const value = body[key]; return typeof value === 'string' && value.trim() ? value.trim() : ''; } function rsaSignSha256(privateKey: string, message: string) { return crypto.createSign('RSA-SHA256').update(message, 'utf8').sign(privateKey, 'base64'); } function rsaVerifySha256(publicKey: string, message: string, signature: string) { return crypto.createVerify('RSA-SHA256').update(message, 'utf8').verify(publicKey, signature, 'base64'); } function normalizePem(value: string, label: 'PRIVATE KEY' | 'PUBLIC KEY') { if (value.includes('-----BEGIN')) return value; const wrapped = value.match(/.{1,64}/g)?.join('\n') || value; return `-----BEGIN ${label}-----\n${wrapped}\n-----END ${label}-----`; } function safeJson(value: unknown) { return value && typeof value === 'object' && !Array.isArray(value) ? (value as Record) : {}; } function canonicalForm(params: Record) { return Object.keys(params) .filter(key => params[key] !== undefined && params[key] !== null && params[key] !== '') .sort() .map(key => `${key}=${params[key]}`) .join('&'); } function encodedForm(params: Record) { return Object.keys(params) .filter(key => params[key] !== undefined && params[key] !== null && params[key] !== '') .sort() .map(key => `${encodeURIComponent(key)}=${encodeURIComponent(params[key])}`) .join('&'); } function wechatPrivateKey(config: TenantPaymentProviderConfig) { return normalizePem(requireSecretString(config, ['privateKey', 'merchantPrivateKey'], 'PAYMENT_SECRET_REQUIRED'), 'PRIVATE KEY'); } function randomNonce(size = 16) { return crypto.randomBytes(size).toString('base64url'); } class WechatPayProvider implements PaymentProvider { readonly name = 'wechat_pay' as const; constructor(private readonly config: TenantPaymentProviderConfig) {} async createPayment(input: PaymentOrderInput): Promise { const appId = requirePublicString(this.config, ['appId'], 'PAYMENT_PUBLIC_CONFIG_REQUIRED'); const mchId = requirePublicString(this.config, ['merchantId', 'mchId'], 'PAYMENT_PUBLIC_CONFIG_REQUIRED'); const merchantSerialNo = requirePublicString(this.config, ['merchantSerialNo'], 'PAYMENT_PUBLIC_CONFIG_REQUIRED'); const notifyUrl = requirePublicString(this.config, ['notifyUrl'], 'PAYMENT_PUBLIC_CONFIG_REQUIRED'); const endpoint = providerEndpoint( this.config, 'https://api.mch.weixin.qq.com/v3/pay/transactions/jsapi', ['api.mch.weixin.qq.com'], 'PAYMENT_ENDPOINT_NOT_ALLOWED', ); const openId = input.openId || optionalPublicString(this.config, ['testOpenId']); if (!openId) { throw new HttpError(400, 'openId is required for WeChat JSAPI payment', 'WECHAT_OPENID_REQUIRED'); } const payload = JSON.stringify({ appid: appId, mchid: mchId, description: input.description.slice(0, 120), out_trade_no: input.orderNo, notify_url: notifyUrl, amount: { total: input.amountCents, currency: 'CNY', }, payer: { openid: openId, }, }); const timestamp = Math.floor(Date.now() / 1000).toString(); const nonce = randomNonce(); const url = new URL(endpoint); const message = ['POST', `${url.pathname}${url.search}`, timestamp, nonce, payload].join('\n') + '\n'; const signature = rsaSignSha256(wechatPrivateKey(this.config), message); const response = await fetch(endpoint, { method: 'POST', headers: { accept: 'application/json', authorization: `WECHATPAY2-SHA256-RSA2048 mchid="${mchId}",nonce_str="${nonce}",signature="${signature}",timestamp="${timestamp}",serial_no="${merchantSerialNo}"`, 'content-type': 'application/json', }, body: payload, }); const raw = safeJson(await response.json().catch(() => ({}))); if (!response.ok) { throw new HttpError(502, 'WeChat Pay create transaction failed', 'PAYMENT_PROVIDER_CREATE_FAILED'); } const prepayId = typeof raw.prepay_id === 'string' ? raw.prepay_id : ''; if (!prepayId) throw new HttpError(502, 'WeChat Pay response is missing prepay_id', 'PAYMENT_PROVIDER_RESPONSE_INVALID'); const payTimestamp = Math.floor(Date.now() / 1000).toString(); const payNonce = randomNonce(); const packageValue = `prepay_id=${prepayId}`; const paySignMessage = [appId, payTimestamp, payNonce, packageValue].join('\n') + '\n'; const paySign = rsaSignSha256(wechatPrivateKey(this.config), paySignMessage); return { provider: this.name, method: 'jsapi', providerTradeNo: prepayId, paymentParams: { appId, timeStamp: payTimestamp, nonceStr: payNonce, package: packageValue, signType: 'RSA', paySign, }, raw: { prepayId, }, }; } async parseNotification(input: { headers: Record; body: Record; rawBody: string; }): Promise { const publicKey = optionalPublicString(this.config, ['wechatpayPublicKey', 'platformCertificatePublicKey']) || requireSecretString(this.config, ['wechatpayPublicKey', 'platformCertificatePublicKey', 'publicKey'], 'PAYMENT_SECRET_REQUIRED'); const allowMockSignature = !appConfig.isProduction && optionalPublicBoolean(this.config, 'allowLocalMock', false); if (publicKey && input.headers['wechatpay-signature']) { const timestamp = input.headers['wechatpay-timestamp'] || ''; const nonceHeader = input.headers['wechatpay-nonce'] || ''; const message = `${timestamp}\n${nonceHeader}\n${input.rawBody}\n`; if (!rsaVerifySha256(normalizePem(publicKey, 'PUBLIC KEY'), message, input.headers['wechatpay-signature'])) { throw new HttpError(400, 'WeChat Pay notification signature invalid', 'PAYMENT_SIGNATURE_INVALID'); } } else if (!allowMockSignature) { throw new HttpError(400, 'WeChat Pay notification signature is required', 'PAYMENT_SIGNATURE_REQUIRED'); } const apiV3Key = requireSecretString(this.config, ['apiV3Key', 'apiv3Key'], 'PAYMENT_SECRET_REQUIRED'); const resource = objectValue(input.body.resource); const ciphertext = requiredBodyString(resource, 'ciphertext', 'PAYMENT_NOTIFICATION_INVALID'); const nonce = requiredBodyString(resource, 'nonce', 'PAYMENT_NOTIFICATION_INVALID'); const associatedData = optionalBodyString(resource, 'associated_data'); let decrypted: Record; try { const decipher = crypto.createDecipheriv('aes-256-gcm', Buffer.from(apiV3Key, 'utf8'), Buffer.from(nonce, 'utf8')); if (associatedData) decipher.setAAD(Buffer.from(associatedData, 'utf8')); const encrypted = base64urlDecode(ciphertext); const authTag = encrypted.subarray(encrypted.length - 16); const cipherText = encrypted.subarray(0, encrypted.length - 16); decipher.setAuthTag(authTag); const buffer = Buffer.concat([decipher.update(cipherText), decipher.final()]); decrypted = safeJson(JSON.parse(buffer.toString('utf8'))); } catch { throw new HttpError(400, 'WeChat Pay notification decrypt failed', 'PAYMENT_NOTIFICATION_INVALID'); } const tradeState = requiredBodyString(decrypted, 'trade_state', 'PAYMENT_NOTIFICATION_INVALID'); if (tradeState !== 'SUCCESS') { throw new HttpError(409, `WeChat Pay trade state is ${tradeState}`, 'PAYMENT_NOT_PAID'); } const orderNo = requiredBodyString(decrypted, 'out_trade_no', 'PAYMENT_NOTIFICATION_INVALID'); const providerTradeNo = requiredBodyString(decrypted, 'transaction_id', 'PAYMENT_NOTIFICATION_INVALID'); const amount = objectValue(decrypted.amount); const amountCents = Number(amount.total); if (!Number.isInteger(amountCents) || amountCents < 0) { throw new HttpError(400, 'WeChat Pay notification amount is invalid', 'PAYMENT_NOTIFICATION_INVALID'); } return { provider: this.name, eventId: requiredBodyString(input.body, 'id', 'PAYMENT_NOTIFICATION_INVALID'), eventType: optionalBodyString(input.body, 'event_type') || 'TRANSACTION.SUCCESS', orderNo, providerTradeNo, amountCents, paidAt: optionalBodyString(decrypted, 'success_time') || undefined, raw: { notification: input.body, transaction: decrypted, headers: { serial: input.headers['wechatpay-serial'] || '', timestamp: input.headers['wechatpay-timestamp'] || '', }, }, }; } } class AlipayProvider implements PaymentProvider { readonly name = 'alipay' as const; constructor(private readonly config: TenantPaymentProviderConfig) {} async createPayment(input: PaymentOrderInput): Promise { const appId = requirePublicString(this.config, ['appId'], 'PAYMENT_PUBLIC_CONFIG_REQUIRED'); const notifyUrl = requirePublicString(this.config, ['notifyUrl'], 'PAYMENT_PUBLIC_CONFIG_REQUIRED'); const returnUrl = input.returnUrl || optionalPublicString(this.config, ['returnUrl']); const quitUrl = input.quitUrl || optionalPublicString(this.config, ['quitUrl']); const gateway = providerEndpoint( this.config, 'https://openapi.alipay.com/gateway.do', ['openapi.alipay.com'], 'PAYMENT_ENDPOINT_NOT_ALLOWED', ); const privateKey = normalizePem(requireSecretString(this.config, ['privateKey', 'appPrivateKey'], 'PAYMENT_SECRET_REQUIRED'), 'PRIVATE KEY'); const bizContent: Record = { out_trade_no: input.orderNo, total_amount: (input.amountCents / 100).toFixed(2), subject: input.description.slice(0, 120), product_code: 'QUICK_WAP_WAY', }; if (quitUrl) bizContent.quit_url = quitUrl; const params: Record = { app_id: appId, method: 'alipay.trade.wap.pay', charset: 'utf-8', sign_type: 'RSA2', timestamp: new Date().toISOString().replace('T', ' ').slice(0, 19), version: '1.0', notify_url: notifyUrl, biz_content: JSON.stringify(bizContent), }; if (returnUrl) params.return_url = returnUrl; params.sign = rsaSignSha256(privateKey, canonicalForm(params)); return { provider: this.name, method: 'wap', paymentParams: { gateway, method: 'GET', query: params, url: `${gateway}?${encodedForm(params)}`, }, raw: { outTradeNo: input.orderNo, }, }; } async parseNotification(input: { headers: Record; body: Record; rawBody: string; }): Promise { const alipayPublicKey = normalizePem( requireSecretString(this.config, ['alipayPublicKey', 'publicKey'], 'PAYMENT_SECRET_REQUIRED'), 'PUBLIC KEY', ); const params: Record = {}; for (const [key, value] of Object.entries(input.body)) { if (typeof value === 'string') params[key] = value; } const sign = params.sign || ''; const signType = params.sign_type || 'RSA2'; delete params.sign; delete params.sign_type; if (signType !== 'RSA2') throw new HttpError(400, 'Alipay notification must use RSA2', 'PAYMENT_SIGNATURE_INVALID'); if (!rsaVerifySha256(alipayPublicKey, canonicalForm(params), sign)) { throw new HttpError(400, 'Alipay notification signature invalid', 'PAYMENT_SIGNATURE_INVALID'); } if (!['TRADE_SUCCESS', 'TRADE_FINISHED'].includes(params.trade_status || '')) { throw new HttpError(409, `Alipay trade status is ${params.trade_status || 'unknown'}`, 'PAYMENT_NOT_PAID'); } const totalAmount = Number(params.total_amount || params.receipt_amount); const amountCents = Math.round(totalAmount * 100); if (!Number.isInteger(amountCents) || amountCents < 0) { throw new HttpError(400, 'Alipay notification amount is invalid', 'PAYMENT_NOTIFICATION_INVALID'); } return { provider: this.name, eventId: params.notify_id || `${params.trade_no}:${params.trade_status}`, eventType: params.trade_status || 'TRADE_SUCCESS', orderNo: params.out_trade_no, providerTradeNo: params.trade_no, amountCents, paidAt: params.gmt_payment || params.notify_time || undefined, raw: { notification: input.body, }, }; } } class ManualPaymentProvider implements PaymentProvider { readonly name = 'manual' as const; async createPayment(input: PaymentOrderInput): Promise { return { provider: this.name, method: 'manual', paymentParams: { orderNo: input.orderNo, amountCents: input.amountCents, }, raw: {}, }; } async parseNotification(): Promise { throw new HttpError(501, 'Manual provider does not support webhook notifications', 'PAYMENT_PROVIDER_NOT_SUPPORTED'); } } export function normalizePaymentProvider(value: string): PaymentProviderName { const normalized = value.toLowerCase().replace(/[-\s]/g, '_'); if (['wechat', 'wechatpay', 'wxpay', 'wx_pay', 'wechat_pay'].includes(normalized)) return 'wechat_pay'; if (['alipay', 'ali_pay'].includes(normalized)) return 'alipay'; return 'manual'; } export function paymentProviderAliases(value: string) { const provider = normalizePaymentProvider(value); if (provider === 'wechat_pay') return ['wechat_pay', 'wechat-pay', 'wechatpay', 'wxpay']; if (provider === 'alipay') return ['alipay', 'ali_pay']; return ['manual']; } export function createPaymentProvider( name: string, config?: TenantPaymentProviderConfig | null, ): PaymentProvider { const provider = normalizePaymentProvider(name); if (provider === 'wechat_pay') { if (!config) throw new HttpError(503, 'WeChat Pay provider is not configured', 'PAYMENT_PROVIDER_NOT_CONFIGURED'); return new WechatPayProvider(config); } if (provider === 'alipay') { if (!config) throw new HttpError(503, 'Alipay provider is not configured', 'PAYMENT_PROVIDER_NOT_CONFIGURED'); return new AlipayProvider(config); } return new ManualPaymentProvider(); } export function allowsLocalMockPayment(config: TenantPaymentProviderConfig | null) { return config ? optionalPublicBoolean(config, 'allowLocalMock', false) : false; }