- Create custom class-validator decorators: IsVietnamPhone, IsVietnamDistrict, IsVND - Replace process.env/requireEnv() with NestJS ConfigService DI in VNPay, MoMo, ZaloPay services - Update all payment infrastructure tests with ConfigService mocks (42 tests passing) TEC-1569 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
199 lines
5.9 KiB
TypeScript
199 lines
5.9 KiB
TypeScript
import * as crypto from 'crypto';
|
|
import { Injectable, Logger } from '@nestjs/common';
|
|
import { ConfigService } from '@nestjs/config';
|
|
import { type PaymentProvider } from '@prisma/client';
|
|
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 readonly partnerCode: string;
|
|
private readonly accessKey: string;
|
|
private readonly secretKey: string;
|
|
private readonly endpoint: string;
|
|
|
|
constructor(private readonly config: ConfigService) {
|
|
this.partnerCode = this.config.getOrThrow<string>('MOMO_PARTNER_CODE');
|
|
this.accessKey = this.config.getOrThrow<string>('MOMO_ACCESS_KEY');
|
|
this.secretKey = this.config.getOrThrow<string>('MOMO_SECRET_KEY');
|
|
this.endpoint = this.config.get<string>('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.length > 0 &&
|
|
receivedSignature.length === expectedSignature.length &&
|
|
crypto.timingSafeEqual(Buffer.from(receivedSignature, 'hex'), Buffer.from(expectedSignature, 'hex'));
|
|
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 };
|
|
}
|
|
}
|
|
}
|