it0/packages/services/billing-service/src/infrastructure/payment-providers/wechat/wechat-pay.provider.ts

157 lines
5.3 KiB
TypeScript

import { Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import * as crypto from 'crypto';
import {
PaymentProviderPort,
PaymentProviderType,
PaymentSessionRequest,
PaymentSession,
WebhookResult,
} from '../../../domain/ports/payment-provider.port';
import { InvoiceCurrency } from '../../../domain/entities/invoice.entity';
@Injectable()
export class WeChatPayProvider implements PaymentProviderPort {
readonly providerType = PaymentProviderType.WECHAT_PAY;
private readonly logger = new Logger(WeChatPayProvider.name);
private mchId: string;
private apiKeyV3: string;
private appId: string;
private configured = false;
// WeChat Pay v3 API base
private static readonly BASE_URL = 'https://api.mch.weixin.qq.com';
constructor(private readonly configService: ConfigService) {
this.mchId = this.configService.get<string>('WECHAT_MCH_ID', '');
this.apiKeyV3 = this.configService.get<string>('WECHAT_API_KEY_V3', '');
this.appId = this.configService.get<string>('WECHAT_APP_ID', '');
this.configured = !!(this.mchId && this.apiKeyV3 && this.appId);
}
isConfigured(): boolean {
return this.configured;
}
async createPaymentSession(req: PaymentSessionRequest): Promise<PaymentSession> {
if (!this.configured) throw new Error('WeChat Pay not configured');
if (req.currency !== InvoiceCurrency.CNY) {
throw new Error('WeChat Pay only supports CNY payments');
}
const outTradeNo = `it0-${req.invoiceId}-${Date.now()}`;
const notifyUrl = this.configService.get<string>(
'WECHAT_NOTIFY_URL',
'https://it0api.szaiai.com/api/v1/billing/webhooks/wechat',
);
const body = {
appid: this.appId,
mchid: this.mchId,
description: req.description,
out_trade_no: outTradeNo,
notify_url: notifyUrl,
amount: { total: req.amountCents, currency: 'CNY' },
};
const response = await this.request('POST', '/v3/pay/transactions/native', body);
const qrCodeUrl = response.code_url;
return {
sessionId: outTradeNo,
providerPaymentId: outTradeNo,
qrCodeUrl,
redirectUrl: undefined,
clientSecret: undefined,
expiresAt: new Date(Date.now() + 2 * 60 * 60 * 1000),
};
}
async handleWebhook(rawBody: Buffer, headers: Record<string, string>): Promise<WebhookResult> {
if (!this.configured) throw new Error('WeChat Pay not configured');
// Verify HMAC-SHA256 signature
const timestamp = headers['wechatpay-timestamp'];
const nonce = headers['wechatpay-nonce'];
const signature = headers['wechatpay-signature'];
const message = `${timestamp}\n${nonce}\n${rawBody.toString()}\n`;
const expectedSig = crypto
.createHmac('sha256', this.apiKeyV3)
.update(message)
.digest('base64');
if (signature !== expectedSig) {
throw new Error('WeChat Pay webhook signature invalid');
}
const payload = JSON.parse(rawBody.toString());
const eventType = payload.event_type;
if (eventType === 'TRANSACTION.SUCCESS') {
// Decrypt resource
const resource = payload.resource;
const decrypted = this.decryptResource(resource.ciphertext, resource.associated_data, resource.nonce);
const transaction = JSON.parse(decrypted);
return { type: 'payment_succeeded', providerPaymentId: transaction.out_trade_no };
} else if (eventType === 'TRANSACTION.FAIL') {
const resource = payload.resource;
const decrypted = this.decryptResource(resource.ciphertext, resource.associated_data, resource.nonce);
const transaction = JSON.parse(decrypted);
return { type: 'payment_failed', providerPaymentId: transaction.out_trade_no };
}
return { type: 'unknown' };
}
private decryptResource(ciphertext: string, associatedData: string, nonce: string): string {
const key = Buffer.from(this.apiKeyV3, 'utf-8');
const ciphertextBuffer = Buffer.from(ciphertext, 'base64');
const authTag = ciphertextBuffer.slice(-16);
const encrypted = ciphertextBuffer.slice(0, -16);
const decipher = crypto.createDecipheriv('aes-256-gcm', key, nonce);
decipher.setAuthTag(authTag);
decipher.setAAD(Buffer.from(associatedData));
return Buffer.concat([decipher.update(encrypted), decipher.final()]).toString('utf-8');
}
private async request(method: string, path: string, body: any): Promise<any> {
const url = `${WeChatPayProvider.BASE_URL}${path}`;
const timestamp = Math.floor(Date.now() / 1000);
const nonce = crypto.randomBytes(16).toString('hex');
const bodyStr = JSON.stringify(body);
const message = `${method}\n${path}\n${timestamp}\n${nonce}\n${bodyStr}\n`;
// HMAC-SHA256 signature for request
const signature = crypto
.createHmac('sha256', this.apiKeyV3)
.update(message)
.digest('base64');
const authorization =
`WECHATPAY2-SHA256-RSA2048 mchid="${this.mchId}",` +
`nonce_str="${nonce}",timestamp="${timestamp}",` +
`signature="${signature}"`;
const res = await fetch(url, {
method,
headers: {
'Content-Type': 'application/json',
Authorization: authorization,
Accept: 'application/json',
},
body: bodyStr,
});
if (!res.ok) {
const text = await res.text();
throw new Error(`WeChat Pay API error ${res.status}: ${text}`);
}
return res.json();
}
}