import * as crypto from 'crypto'; import { Injectable } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { type PaymentProvider } from '@prisma/client'; import { 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; private readonly callbackBaseUrl: string; constructor( private readonly config: ConfigService, private readonly logger: LoggerService, ) { this.appId = this.config.getOrThrow('ZALOPAY_APP_ID'); this.key1 = this.config.getOrThrow('ZALOPAY_KEY1'); this.key2 = this.config.getOrThrow('ZALOPAY_KEY2'); this.endpoint = this.config.get('ZALOPAY_ENDPOINT', 'https://sb-openapi.zalopay.vn/v2'); this.callbackBaseUrl = this.config.get('PAYMENT_CALLBACK_BASE_URL', 'https://api.goodgo.vn'); } async createPaymentUrl(params: CreatePaymentUrlParams): Promise { const now = new Date(); const appTransId = `${this.formatYYMMDD(now)}_${params.orderId}`; const appTime = now.getTime(); const amount = Number(params.amountVND); // embed_data carries the frontend redirect URL; callback_url is the backend IPN endpoint const embedData = JSON.stringify({ redirecturl: params.returnUrl }); const callbackUrl = params.callbackUrl ?? `${this.callbackBaseUrl}/api/v1/payments/callback/zalopay`; 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: callbackUrl, 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): 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 = {}; 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 { 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}`; } }