feat(payments): implement Payments module with VNPay, MoMo, ZaloPay integration

Implement complete payment processing module following DDD + CQRS patterns:

- Domain layer: PaymentEntity aggregate, Money value object, domain events
- Infrastructure: PrismaPaymentRepository, VnpayService, MomoService, ZalopayService
- PaymentGatewayFactory pattern for provider abstraction
- CQRS Commands: CreatePayment, HandleCallback, RefundPayment
- CQRS Queries: GetPaymentStatus, ListTransactions
- Callback/webhook endpoints with signature verification and idempotency
- 23 unit tests covering domain, VNPay service, and gateway factory

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Ho Ngoc Hai
2026-04-08 01:57:23 +07:00
parent 207a2013f3
commit ad7713968a
42 changed files with 1985 additions and 0 deletions

View File

@@ -0,0 +1,181 @@
import { Injectable, Logger } from '@nestjs/common';
import { type PaymentProvider } from '@prisma/client';
import * as crypto from 'crypto';
import {
type IPaymentGateway,
type CreatePaymentUrlParams,
type CreatePaymentUrlResult,
type CallbackVerifyResult,
type RefundParams,
type RefundResult,
} from './payment-gateway.interface';
@Injectable()
export class VnpayService implements IPaymentGateway {
private readonly logger = new Logger(VnpayService.name);
readonly provider: PaymentProvider = 'VNPAY';
private get tmnCode(): string {
return process.env['VNPAY_TMN_CODE'] ?? '';
}
private get hashSecret(): string {
return process.env['VNPAY_HASH_SECRET'] ?? '';
}
private get baseUrl(): string {
return process.env['VNPAY_BASE_URL'] ?? 'https://sandbox.vnpayment.vn/paymentv2/vpcpay.html';
}
private get apiUrl(): string {
return process.env['VNPAY_API_URL'] ?? 'https://sandbox.vnpayment.vn/merchant_webapi/api/transaction';
}
async createPaymentUrl(params: CreatePaymentUrlParams): Promise<CreatePaymentUrlResult> {
const now = new Date();
const createDate = this.formatDate(now);
const expireDate = this.formatDate(new Date(now.getTime() + 15 * 60 * 1000));
const vnpParams: Record<string, string> = {
vnp_Version: '2.1.0',
vnp_Command: 'pay',
vnp_TmnCode: this.tmnCode,
vnp_Locale: 'vn',
vnp_CurrCode: 'VND',
vnp_TxnRef: params.orderId,
vnp_OrderInfo: params.description,
vnp_OrderType: 'other',
vnp_Amount: (params.amountVND * 100n).toString(),
vnp_ReturnUrl: params.returnUrl,
vnp_IpAddr: params.ipAddress,
vnp_CreateDate: createDate,
vnp_ExpireDate: expireDate,
};
const sortedParams = this.sortObject(vnpParams);
const signData = new URLSearchParams(sortedParams).toString();
const hmac = crypto.createHmac('sha512', this.hashSecret);
const signed = hmac.update(Buffer.from(signData, 'utf-8')).digest('hex');
sortedParams['vnp_SecureHash'] = signed;
const paymentUrl = `${this.baseUrl}?${new URLSearchParams(sortedParams).toString()}`;
this.logger.log(`VNPay payment URL created for order ${params.orderId}`);
return {
paymentUrl,
providerTxId: params.orderId,
};
}
verifyCallback(data: Record<string, string>): CallbackVerifyResult {
const secureHash = data['vnp_SecureHash'];
const orderId = data['vnp_TxnRef'] ?? '';
const providerTxId = data['vnp_TransactionNo'] ?? '';
const verifyParams = { ...data };
delete verifyParams['vnp_SecureHash'];
delete verifyParams['vnp_SecureHashType'];
const sortedParams = this.sortObject(verifyParams);
const signData = new URLSearchParams(sortedParams).toString();
const hmac = crypto.createHmac('sha512', this.hashSecret);
const checkSum = hmac.update(Buffer.from(signData, 'utf-8')).digest('hex');
const isValid = secureHash === checkSum;
const responseCode = data['vnp_ResponseCode'];
const isSuccess = isValid && responseCode === '00';
this.logger.log(
`VNPay callback verified: orderId=${orderId}, valid=${isValid}, success=${isSuccess}`,
);
return {
isValid,
orderId,
providerTxId,
isSuccess,
rawData: data,
};
}
async refund(params: RefundParams): Promise<RefundResult> {
const now = new Date();
const requestId = crypto.randomUUID();
const refundData: Record<string, string> = {
vnp_RequestId: requestId,
vnp_Version: '2.1.0',
vnp_Command: 'refund',
vnp_TmnCode: this.tmnCode,
vnp_TransactionType: '02',
vnp_TxnRef: params.providerTxId,
vnp_Amount: (params.amountVND * 100n).toString(),
vnp_OrderInfo: params.reason,
vnp_TransactionDate: this.formatDate(now),
vnp_CreateDate: this.formatDate(now),
vnp_CreateBy: 'system',
vnp_IpAddr: '127.0.0.1',
};
const signData = [
refundData['vnp_RequestId'],
refundData['vnp_Version'],
refundData['vnp_Command'],
refundData['vnp_TmnCode'],
refundData['vnp_TransactionType'],
refundData['vnp_TxnRef'],
refundData['vnp_Amount'],
refundData['vnp_TransactionDate'],
refundData['vnp_CreateBy'],
refundData['vnp_CreateDate'],
refundData['vnp_IpAddr'],
refundData['vnp_OrderInfo'],
].join('|');
const hmac = crypto.createHmac('sha512', this.hashSecret);
refundData['vnp_SecureHash'] = hmac
.update(Buffer.from(signData, 'utf-8'))
.digest('hex');
try {
const response = await fetch(this.apiUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(refundData),
});
const result = await response.json() as Record<string, string>;
const success = result['vnp_ResponseCode'] === '00';
this.logger.log(
`VNPay refund ${success ? 'successful' : 'failed'} for ${params.providerTxId}`,
);
return {
success,
refundTxId: success ? requestId : null,
};
} catch (error) {
this.logger.error(`VNPay refund error: ${error}`);
return { success: false, refundTxId: null };
}
}
private formatDate(date: Date): string {
return date
.toISOString()
.replace(/[-:T]/g, '')
.slice(0, 14);
}
private sortObject(obj: Record<string, string>): Record<string, string> {
const sorted: Record<string, string> = {};
const keys = Object.keys(obj).sort();
for (const key of keys) {
sorted[key] = obj[key]!;
}
return sorted;
}
}