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:
@@ -0,0 +1,14 @@
|
||||
export {
|
||||
PAYMENT_GATEWAY_FACTORY,
|
||||
type IPaymentGateway,
|
||||
type IPaymentGatewayFactory,
|
||||
type CreatePaymentUrlParams,
|
||||
type CreatePaymentUrlResult,
|
||||
type CallbackVerifyResult,
|
||||
type RefundParams,
|
||||
type RefundResult,
|
||||
} from './payment-gateway.interface';
|
||||
export { PaymentGatewayFactory } from './payment-gateway.factory';
|
||||
export { VnpayService } from './vnpay.service';
|
||||
export { MomoService } from './momo.service';
|
||||
export { ZalopayService } from './zalopay.service';
|
||||
@@ -0,0 +1,198 @@
|
||||
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 MomoService implements IPaymentGateway {
|
||||
private readonly logger = new Logger(MomoService.name);
|
||||
readonly provider: PaymentProvider = 'MOMO';
|
||||
|
||||
private get partnerCode(): string {
|
||||
return process.env['MOMO_PARTNER_CODE'] ?? '';
|
||||
}
|
||||
|
||||
private get accessKey(): string {
|
||||
return process.env['MOMO_ACCESS_KEY'] ?? '';
|
||||
}
|
||||
|
||||
private get secretKey(): string {
|
||||
return process.env['MOMO_SECRET_KEY'] ?? '';
|
||||
}
|
||||
|
||||
private get endpoint(): string {
|
||||
return process.env['MOMO_ENDPOINT'] ?? 'https://test-payment.momo.vn/v2/gateway/api';
|
||||
}
|
||||
|
||||
async createPaymentUrl(params: CreatePaymentUrlParams): Promise<CreatePaymentUrlResult> {
|
||||
const requestId = crypto.randomUUID();
|
||||
const requestType = 'payWithMethod';
|
||||
const extraData = '';
|
||||
const autoCapture = true;
|
||||
const lang = 'vi';
|
||||
const amount = params.amountVND.toString();
|
||||
|
||||
const rawSignature = [
|
||||
`accessKey=${this.accessKey}`,
|
||||
`amount=${amount}`,
|
||||
`extraData=${extraData}`,
|
||||
`ipnUrl=${params.returnUrl}`,
|
||||
`orderId=${params.orderId}`,
|
||||
`orderInfo=${params.description}`,
|
||||
`partnerCode=${this.partnerCode}`,
|
||||
`redirectUrl=${params.returnUrl}`,
|
||||
`requestId=${requestId}`,
|
||||
`requestType=${requestType}`,
|
||||
].join('&');
|
||||
|
||||
const signature = crypto
|
||||
.createHmac('sha256', this.secretKey)
|
||||
.update(rawSignature)
|
||||
.digest('hex');
|
||||
|
||||
const body = {
|
||||
partnerCode: this.partnerCode,
|
||||
partnerName: 'GoodGo',
|
||||
storeId: 'GoodGo',
|
||||
requestId,
|
||||
amount: Number(amount),
|
||||
orderId: params.orderId,
|
||||
orderInfo: params.description,
|
||||
redirectUrl: params.returnUrl,
|
||||
ipnUrl: params.returnUrl,
|
||||
lang,
|
||||
requestType,
|
||||
autoCapture,
|
||||
extraData,
|
||||
signature,
|
||||
};
|
||||
|
||||
try {
|
||||
const response = await fetch(`${this.endpoint}/create`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
const result = await response.json() as { resultCode: number; payUrl: string };
|
||||
|
||||
if (result.resultCode !== 0) {
|
||||
throw new Error(`MoMo create payment failed: resultCode=${result.resultCode}`);
|
||||
}
|
||||
|
||||
this.logger.log(`MoMo payment URL created for order ${params.orderId}`);
|
||||
|
||||
return {
|
||||
paymentUrl: result.payUrl,
|
||||
providerTxId: params.orderId,
|
||||
};
|
||||
} catch (error) {
|
||||
this.logger.error(`MoMo createPaymentUrl error: ${error}`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
verifyCallback(data: Record<string, string>): CallbackVerifyResult {
|
||||
const orderId = data['orderId'] ?? '';
|
||||
const providerTxId = data['transId'] ?? '';
|
||||
const resultCode = data['resultCode'];
|
||||
const receivedSignature = data['signature'] ?? '';
|
||||
|
||||
const rawSignature = [
|
||||
`accessKey=${this.accessKey}`,
|
||||
`amount=${data['amount']}`,
|
||||
`extraData=${data['extraData'] ?? ''}`,
|
||||
`message=${data['message'] ?? ''}`,
|
||||
`orderId=${orderId}`,
|
||||
`orderInfo=${data['orderInfo'] ?? ''}`,
|
||||
`orderType=${data['orderType'] ?? ''}`,
|
||||
`partnerCode=${this.partnerCode}`,
|
||||
`payType=${data['payType'] ?? ''}`,
|
||||
`requestId=${data['requestId'] ?? ''}`,
|
||||
`responseTime=${data['responseTime'] ?? ''}`,
|
||||
`resultCode=${resultCode}`,
|
||||
`transId=${providerTxId}`,
|
||||
].join('&');
|
||||
|
||||
const expectedSignature = crypto
|
||||
.createHmac('sha256', this.secretKey)
|
||||
.update(rawSignature)
|
||||
.digest('hex');
|
||||
|
||||
const isValid = receivedSignature === expectedSignature;
|
||||
const isSuccess = isValid && resultCode === '0';
|
||||
|
||||
this.logger.log(
|
||||
`MoMo callback verified: orderId=${orderId}, valid=${isValid}, success=${isSuccess}`,
|
||||
);
|
||||
|
||||
return {
|
||||
isValid,
|
||||
orderId,
|
||||
providerTxId,
|
||||
isSuccess,
|
||||
rawData: data,
|
||||
};
|
||||
}
|
||||
|
||||
async refund(params: RefundParams): Promise<RefundResult> {
|
||||
const requestId = crypto.randomUUID();
|
||||
const amount = params.amountVND.toString();
|
||||
|
||||
const rawSignature = [
|
||||
`accessKey=${this.accessKey}`,
|
||||
`amount=${amount}`,
|
||||
`description=${params.reason}`,
|
||||
`orderId=${requestId}`,
|
||||
`partnerCode=${this.partnerCode}`,
|
||||
`requestId=${requestId}`,
|
||||
`transId=${params.providerTxId}`,
|
||||
].join('&');
|
||||
|
||||
const signature = crypto
|
||||
.createHmac('sha256', this.secretKey)
|
||||
.update(rawSignature)
|
||||
.digest('hex');
|
||||
|
||||
const body = {
|
||||
partnerCode: this.partnerCode,
|
||||
orderId: requestId,
|
||||
requestId,
|
||||
amount: Number(amount),
|
||||
transId: Number(params.providerTxId),
|
||||
lang: 'vi',
|
||||
description: params.reason,
|
||||
signature,
|
||||
};
|
||||
|
||||
try {
|
||||
const response = await fetch(`${this.endpoint}/refund`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
const result = await response.json() as { resultCode: number };
|
||||
const success = result.resultCode === 0;
|
||||
|
||||
this.logger.log(
|
||||
`MoMo refund ${success ? 'successful' : 'failed'} for ${params.providerTxId}`,
|
||||
);
|
||||
|
||||
return {
|
||||
success,
|
||||
refundTxId: success ? requestId : null,
|
||||
};
|
||||
} catch (error) {
|
||||
this.logger.error(`MoMo refund error: ${error}`);
|
||||
return { success: false, refundTxId: null };
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { Injectable, BadRequestException } from '@nestjs/common';
|
||||
import { type PaymentProvider } from '@prisma/client';
|
||||
import {
|
||||
type IPaymentGateway,
|
||||
type IPaymentGatewayFactory,
|
||||
} from './payment-gateway.interface';
|
||||
import { VnpayService } from './vnpay.service';
|
||||
import { MomoService } from './momo.service';
|
||||
import { ZalopayService } from './zalopay.service';
|
||||
|
||||
@Injectable()
|
||||
export class PaymentGatewayFactory implements IPaymentGatewayFactory {
|
||||
private readonly gateways: Map<PaymentProvider, IPaymentGateway>;
|
||||
|
||||
constructor(
|
||||
private readonly vnpay: VnpayService,
|
||||
private readonly momo: MomoService,
|
||||
private readonly zalopay: ZalopayService,
|
||||
) {
|
||||
this.gateways = new Map<PaymentProvider, IPaymentGateway>([
|
||||
['VNPAY', vnpay],
|
||||
['MOMO', momo],
|
||||
['ZALOPAY', zalopay],
|
||||
]);
|
||||
}
|
||||
|
||||
getGateway(provider: PaymentProvider): IPaymentGateway {
|
||||
const gateway = this.gateways.get(provider);
|
||||
if (!gateway) {
|
||||
throw new BadRequestException(`Nhà cung cấp thanh toán không được hỗ trợ: ${provider}`);
|
||||
}
|
||||
return gateway;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import { type PaymentProvider } from '@prisma/client';
|
||||
|
||||
export const PAYMENT_GATEWAY_FACTORY = Symbol('PAYMENT_GATEWAY_FACTORY');
|
||||
|
||||
export interface CreatePaymentUrlParams {
|
||||
orderId: string;
|
||||
amountVND: bigint;
|
||||
description: string;
|
||||
returnUrl: string;
|
||||
ipAddress: string;
|
||||
}
|
||||
|
||||
export interface CreatePaymentUrlResult {
|
||||
paymentUrl: string;
|
||||
providerTxId: string;
|
||||
}
|
||||
|
||||
export interface CallbackVerifyResult {
|
||||
isValid: boolean;
|
||||
orderId: string;
|
||||
providerTxId: string;
|
||||
isSuccess: boolean;
|
||||
rawData: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface RefundParams {
|
||||
providerTxId: string;
|
||||
amountVND: bigint;
|
||||
reason: string;
|
||||
}
|
||||
|
||||
export interface RefundResult {
|
||||
success: boolean;
|
||||
refundTxId: string | null;
|
||||
}
|
||||
|
||||
export interface IPaymentGateway {
|
||||
readonly provider: PaymentProvider;
|
||||
createPaymentUrl(params: CreatePaymentUrlParams): Promise<CreatePaymentUrlResult>;
|
||||
verifyCallback(data: Record<string, string>): CallbackVerifyResult;
|
||||
refund(params: RefundParams): Promise<RefundResult>;
|
||||
}
|
||||
|
||||
export interface IPaymentGatewayFactory {
|
||||
getGateway(provider: PaymentProvider): IPaymentGateway;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,200 @@
|
||||
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 ZalopayService implements IPaymentGateway {
|
||||
private readonly logger = new Logger(ZalopayService.name);
|
||||
readonly provider: PaymentProvider = 'ZALOPAY';
|
||||
|
||||
private get appId(): string {
|
||||
return process.env['ZALOPAY_APP_ID'] ?? '';
|
||||
}
|
||||
|
||||
private get key1(): string {
|
||||
return process.env['ZALOPAY_KEY1'] ?? '';
|
||||
}
|
||||
|
||||
private get key2(): string {
|
||||
return process.env['ZALOPAY_KEY2'] ?? '';
|
||||
}
|
||||
|
||||
private get endpoint(): string {
|
||||
return process.env['ZALOPAY_ENDPOINT'] ?? 'https://sb-openapi.zalopay.vn/v2';
|
||||
}
|
||||
|
||||
async createPaymentUrl(params: CreatePaymentUrlParams): Promise<CreatePaymentUrlResult> {
|
||||
const now = new Date();
|
||||
const appTransId = `${this.formatYYMMDD(now)}_${params.orderId}`;
|
||||
const appTime = now.getTime();
|
||||
const amount = Number(params.amountVND);
|
||||
const embedData = JSON.stringify({ redirecturl: params.returnUrl });
|
||||
const items = JSON.stringify([]);
|
||||
|
||||
const data = [
|
||||
this.appId,
|
||||
appTransId,
|
||||
appTime,
|
||||
amount,
|
||||
embedData,
|
||||
items,
|
||||
].join('|');
|
||||
|
||||
const mac = crypto
|
||||
.createHmac('sha256', this.key1)
|
||||
.update(data)
|
||||
.digest('hex');
|
||||
|
||||
const body = {
|
||||
app_id: Number(this.appId),
|
||||
app_trans_id: appTransId,
|
||||
app_user: params.orderId,
|
||||
app_time: appTime,
|
||||
amount,
|
||||
item: items,
|
||||
description: params.description,
|
||||
embed_data: embedData,
|
||||
callback_url: params.returnUrl,
|
||||
mac,
|
||||
};
|
||||
|
||||
try {
|
||||
const response = await fetch(`${this.endpoint}/create`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
const result = await response.json() as {
|
||||
return_code: number;
|
||||
order_url: string;
|
||||
zp_trans_token: string;
|
||||
};
|
||||
|
||||
if (result.return_code !== 1) {
|
||||
throw new Error(`ZaloPay create payment failed: return_code=${result.return_code}`);
|
||||
}
|
||||
|
||||
this.logger.log(`ZaloPay payment URL created for order ${params.orderId}`);
|
||||
|
||||
return {
|
||||
paymentUrl: result.order_url,
|
||||
providerTxId: appTransId,
|
||||
};
|
||||
} catch (error) {
|
||||
this.logger.error(`ZaloPay createPaymentUrl error: ${error}`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
verifyCallback(data: Record<string, string>): CallbackVerifyResult {
|
||||
const dataStr = data['data'] ?? '';
|
||||
const reqMac = data['mac'] ?? '';
|
||||
|
||||
const mac = crypto
|
||||
.createHmac('sha256', this.key2)
|
||||
.update(dataStr)
|
||||
.digest('hex');
|
||||
|
||||
const isValid = reqMac === mac;
|
||||
|
||||
let parsedData: Record<string, unknown> = {};
|
||||
let orderId = '';
|
||||
let providerTxId = '';
|
||||
|
||||
if (isValid) {
|
||||
try {
|
||||
parsedData = JSON.parse(dataStr);
|
||||
orderId = String(parsedData['app_trans_id'] ?? '');
|
||||
providerTxId = String(parsedData['zp_trans_id'] ?? '');
|
||||
} catch {
|
||||
return {
|
||||
isValid: false,
|
||||
orderId: '',
|
||||
providerTxId: '',
|
||||
isSuccess: false,
|
||||
rawData: data,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
this.logger.log(
|
||||
`ZaloPay callback verified: orderId=${orderId}, valid=${isValid}`,
|
||||
);
|
||||
|
||||
return {
|
||||
isValid,
|
||||
orderId,
|
||||
providerTxId,
|
||||
isSuccess: isValid,
|
||||
rawData: { ...data, parsed: parsedData },
|
||||
};
|
||||
}
|
||||
|
||||
async refund(params: RefundParams): Promise<RefundResult> {
|
||||
const now = Date.now();
|
||||
const mRefundId = `${this.formatYYMMDD(new Date())}_${this.appId}_${now}`;
|
||||
const amount = Number(params.amountVND);
|
||||
|
||||
const data = [
|
||||
this.appId,
|
||||
params.providerTxId,
|
||||
amount,
|
||||
params.reason,
|
||||
now,
|
||||
].join('|');
|
||||
|
||||
const mac = crypto
|
||||
.createHmac('sha256', this.key1)
|
||||
.update(data)
|
||||
.digest('hex');
|
||||
|
||||
const body = {
|
||||
app_id: Number(this.appId),
|
||||
zp_trans_id: params.providerTxId,
|
||||
m_refund_id: mRefundId,
|
||||
amount,
|
||||
timestamp: now,
|
||||
description: params.reason,
|
||||
mac,
|
||||
};
|
||||
|
||||
try {
|
||||
const response = await fetch(`${this.endpoint}/refund`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
const result = await response.json() as { return_code: number };
|
||||
const success = result.return_code === 1;
|
||||
|
||||
this.logger.log(
|
||||
`ZaloPay refund ${success ? 'successful' : 'failed'} for ${params.providerTxId}`,
|
||||
);
|
||||
|
||||
return {
|
||||
success,
|
||||
refundTxId: success ? mRefundId : null,
|
||||
};
|
||||
} catch (error) {
|
||||
this.logger.error(`ZaloPay refund error: ${error}`);
|
||||
return { success: false, refundTxId: null };
|
||||
}
|
||||
}
|
||||
|
||||
private formatYYMMDD(date: Date): string {
|
||||
const yy = date.getFullYear().toString().slice(-2);
|
||||
const mm = (date.getMonth() + 1).toString().padStart(2, '0');
|
||||
const dd = date.getDate().toString().padStart(2, '0');
|
||||
return `${yy}${mm}${dd}`;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user