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 { 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): 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}`, ); 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}`, ); 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}`; } }