Files
goodgo-platform/apps/api/src/modules/payments/infrastructure/services/momo.service.ts
Ho Ngoc Hai ee50b4c07c feat(api): add Vietnam validators and migrate payment services to ConfigService
- 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>
2026-04-09 09:23:10 +07:00

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 };
}
}
}