feat(payments): implement Payments module with VNPay, MoMo, ZaloPay integration
Implement complete payment processing module following DDD + CQRS patterns: - Domain layer: PaymentEntity aggregate, Money value object, domain events - Infrastructure: PrismaPaymentRepository, VnpayService, MomoService, ZalopayService - PaymentGatewayFactory pattern for provider abstraction - CQRS Commands: CreatePayment, HandleCallback, RefundPayment - CQRS Queries: GetPaymentStatus, ListTransactions - Callback/webhook endpoints with signature verification and idempotency - 23 unit tests covering domain, VNPay service, and gateway factory Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
@@ -0,0 +1,200 @@
|
||||
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<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}`);
|
||||
|
||||
return {
|
||||
paymentUrl: result.order_url,
|
||||
providerTxId: appTransId,
|
||||
};
|
||||
} catch (error) {
|
||||
this.logger.error(`ZaloPay createPaymentUrl error: ${error}`);
|
||||
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 === mac;
|
||||
|
||||
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}`,
|
||||
);
|
||||
|
||||
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}`,
|
||||
);
|
||||
|
||||
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}`;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user