157 lines
5.3 KiB
TypeScript
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();
|
|
}
|
|
}
|