Auto-fix 326 `@typescript-eslint/consistent-type-imports` violations across 182 files with `pnpm lint --fix`. Suppress 1 `no-empty-pattern` in Playwright e2e fixture where empty destructuring is idiomatic. All 1454 unit tests pass. Typecheck clean. Co-Authored-By: Paperclip <noreply@paperclip.ing>
187 lines
5.9 KiB
TypeScript
187 lines
5.9 KiB
TypeScript
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<string>('VNPAY_TMN_CODE');
|
|
this.hashSecret = this.config.getOrThrow<string>('VNPAY_HASH_SECRET');
|
|
this.baseUrl = this.config.get<string>('VNPAY_BASE_URL', 'https://sandbox.vnpayment.vn/paymentv2/vpcpay.html');
|
|
this.apiUrl = this.config.get<string>('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}`, 'VnpayService');
|
|
|
|
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 != 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<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}`,
|
|
'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<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;
|
|
}
|
|
}
|