import * as crypto from 'crypto'; import { Injectable } from '@nestjs/common'; import { type ConfigService } from '@nestjs/config'; import { type PaymentProvider } from '@prisma/client'; import { type LoggerService } from '@modules/shared'; import { type IPaymentGateway, type CreatePaymentUrlParams, type CreatePaymentUrlResult, type CallbackVerifyResult, type RefundParams, type RefundResult, } from './payment-gateway.interface'; @Injectable() export class VnpayService implements IPaymentGateway { readonly provider: PaymentProvider = 'VNPAY'; private readonly tmnCode: string; private readonly hashSecret: string; private readonly baseUrl: string; private readonly apiUrl: string; constructor( private readonly config: ConfigService, private readonly logger: LoggerService, ) { this.tmnCode = this.config.getOrThrow('VNPAY_TMN_CODE'); this.hashSecret = this.config.getOrThrow('VNPAY_HASH_SECRET'); this.baseUrl = this.config.get('VNPAY_BASE_URL', 'https://sandbox.vnpayment.vn/paymentv2/vpcpay.html'); this.apiUrl = this.config.get('VNPAY_API_URL', 'https://sandbox.vnpayment.vn/merchant_webapi/api/transaction'); } async createPaymentUrl(params: CreatePaymentUrlParams): Promise { const now = new Date(); const createDate = this.formatDate(now); const expireDate = this.formatDate(new Date(now.getTime() + 15 * 60 * 1000)); const vnpParams: Record = { 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}`, 'VnpayService'); return { paymentUrl, providerTxId: params.orderId, }; } verifyCallback(data: Record): 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 != null && checkSum.length === secureHash.length && crypto.timingSafeEqual(Buffer.from(secureHash, 'hex'), Buffer.from(checkSum, 'hex')); const responseCode = data['vnp_ResponseCode']; const isSuccess = isValid && responseCode === '00'; this.logger.log( `VNPay callback verified: orderId=${orderId}, valid=${isValid}, success=${isSuccess}`, 'VnpayService', ); return { isValid, orderId, providerTxId, isSuccess, rawData: data, }; } async refund(params: RefundParams): Promise { const now = new Date(); const requestId = crypto.randomUUID(); const refundData: Record = { 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; const success = result['vnp_ResponseCode'] === '00'; this.logger.log( `VNPay refund ${success ? 'successful' : 'failed'} for ${params.providerTxId}`, 'VnpayService', ); return { success, refundTxId: success ? requestId : null, }; } catch (error) { this.logger.error(`VNPay refund error: ${error}`, undefined, 'VnpayService'); return { success: false, refundTxId: null }; } } private formatDate(date: Date): string { return date .toISOString() .replace(/[-:T]/g, '') .slice(0, 14); } private sortObject(obj: Record): Record { const sorted: Record = {}; const keys = Object.keys(obj).sort(); for (const key of keys) { sorted[key] = obj[key]!; } return sorted; } }