Files
goodgo-platform/apps/api/src/modules/payments/infrastructure/services/zalopay.service.ts
Ho Ngoc Hai 34202f2527 refactor(api): replace new Logger() with DI LoggerService and split large files
- Migrate 30 files from `new Logger(ClassName.name)` to injected LoggerService
  for consistent PII masking and centralized logging config
- Split prisma-admin-query.repository.ts (313→121 lines) into admin-stats.queries.ts
  and admin-user.queries.ts
- Split admin.controller.ts (285→154 lines) into admin-moderation.controller.ts
- Split prisma-listing.repository.ts (274→111 lines) into listing-read.queries.ts
- Update 28 test files with mock LoggerService
- All 831 tests passing, zero direct new Logger() calls remaining

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-10 05:35:04 +07:00

206 lines
5.6 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 ZalopayService implements IPaymentGateway {
readonly provider: PaymentProvider = 'ZALOPAY';
private readonly appId: string;
private readonly key1: string;
private readonly key2: string;
private readonly endpoint: string;
constructor(
private readonly config: ConfigService,
private readonly logger: LoggerService,
) {
this.appId = this.config.getOrThrow<string>('ZALOPAY_APP_ID');
this.key1 = this.config.getOrThrow<string>('ZALOPAY_KEY1');
this.key2 = this.config.getOrThrow<string>('ZALOPAY_KEY2');
this.endpoint = this.config.get<string>('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}`, 'ZalopayService');
return {
paymentUrl: result.order_url,
providerTxId: appTransId,
};
} catch (error) {
this.logger.error(`ZaloPay createPaymentUrl error: ${error}`, undefined, 'ZalopayService');
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.length > 0 &&
reqMac.length === mac.length &&
crypto.timingSafeEqual(Buffer.from(reqMac, 'hex'), Buffer.from(mac, 'hex'));
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}`,
'ZalopayService',
);
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}`,
'ZalopayService',
);
return {
success,
refundTxId: success ? mRefundId : null,
};
} catch (error) {
this.logger.error(`ZaloPay refund error: ${error}`, undefined, 'ZalopayService');
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}`;
}
}