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:
Ho Ngoc Hai
2026-04-08 01:57:23 +07:00
parent 207a2013f3
commit ad7713968a
42 changed files with 1985 additions and 0 deletions

View File

@@ -0,0 +1,15 @@
import { type PaymentProvider, type PaymentType } from '@prisma/client';
export class CreatePaymentCommand {
constructor(
public readonly userId: string,
public readonly provider: PaymentProvider,
public readonly type: PaymentType,
public readonly amountVND: bigint,
public readonly description: string,
public readonly returnUrl: string,
public readonly ipAddress: string,
public readonly transactionId?: string,
public readonly idempotencyKey?: string,
) {}
}

View File

@@ -0,0 +1,108 @@
import {
BadRequestException,
ConflictException,
Inject,
Logger,
} from '@nestjs/common';
import { CommandHandler, EventBus, type ICommandHandler } from '@nestjs/cqrs';
import { createId } from '@paralleldrive/cuid2';
import { CreatePaymentCommand } from './create-payment.command';
import {
PAYMENT_REPOSITORY,
type IPaymentRepository,
} from '../../../domain/repositories/payment.repository';
import {
PAYMENT_GATEWAY_FACTORY,
type IPaymentGatewayFactory,
} from '../../../infrastructure/services/payment-gateway.interface';
import { PaymentEntity } from '../../../domain/entities/payment.entity';
import { Money } from '../../../domain/value-objects/money.vo';
import { ErrorCode } from '@modules/shared/domain/error-codes';
export interface CreatePaymentResult {
paymentId: string;
paymentUrl: string;
providerTxId: string;
}
@CommandHandler(CreatePaymentCommand)
export class CreatePaymentHandler implements ICommandHandler<CreatePaymentCommand> {
private readonly logger = new Logger(CreatePaymentHandler.name);
constructor(
@Inject(PAYMENT_REPOSITORY)
private readonly paymentRepo: IPaymentRepository,
@Inject(PAYMENT_GATEWAY_FACTORY)
private readonly gatewayFactory: IPaymentGatewayFactory,
private readonly eventBus: EventBus,
) {}
async execute(command: CreatePaymentCommand): Promise<CreatePaymentResult> {
// Idempotency check
if (command.idempotencyKey) {
const existing = await this.paymentRepo.findByIdempotencyKey(command.idempotencyKey);
if (existing) {
if (existing.status === 'PENDING' || existing.status === 'PROCESSING') {
throw new ConflictException({
code: ErrorCode.PAYMENT_ALREADY_PROCESSED,
message: 'Thanh toán với idempotency key này đã tồn tại',
paymentId: existing.id,
});
}
throw new ConflictException({
code: ErrorCode.PAYMENT_ALREADY_PROCESSED,
message: 'Thanh toán đã được xử lý',
});
}
}
// Validate amount
const moneyResult = Money.create(command.amountVND);
if (moneyResult.isErr) {
throw new BadRequestException({
code: ErrorCode.PAYMENT_INVALID_AMOUNT,
message: moneyResult.unwrapErr(),
});
}
const money = moneyResult.unwrap();
const paymentId = createId();
// Create domain entity
const payment = PaymentEntity.createNew(
paymentId,
command.userId,
command.provider,
command.type,
money,
command.transactionId,
command.idempotencyKey,
);
// Get payment gateway and create URL
const gateway = this.gatewayFactory.getGateway(command.provider);
const { paymentUrl, providerTxId } = await gateway.createPaymentUrl({
orderId: paymentId,
amountVND: command.amountVND,
description: command.description,
returnUrl: command.returnUrl,
ipAddress: command.ipAddress,
});
// Mark processing and save
payment.markProcessing(providerTxId);
await this.paymentRepo.save(payment);
// Publish domain events
const events = payment.clearDomainEvents();
for (const event of events) {
this.eventBus.publish(event);
}
this.logger.log(
`Payment created: id=${paymentId}, provider=${command.provider}, amount=${command.amountVND}`,
);
return { paymentId, paymentUrl, providerTxId };
}
}

View File

@@ -0,0 +1,8 @@
import { type PaymentProvider } from '@prisma/client';
export class HandleCallbackCommand {
constructor(
public readonly provider: PaymentProvider,
public readonly callbackData: Record<string, string>,
) {}
}

View File

@@ -0,0 +1,98 @@
import {
BadRequestException,
Inject,
Logger,
NotFoundException,
} from '@nestjs/common';
import { CommandHandler, EventBus, type ICommandHandler } from '@nestjs/cqrs';
import { HandleCallbackCommand } from './handle-callback.command';
import {
PAYMENT_REPOSITORY,
type IPaymentRepository,
} from '../../../domain/repositories/payment.repository';
import {
PAYMENT_GATEWAY_FACTORY,
type IPaymentGatewayFactory,
} from '../../../infrastructure/services/payment-gateway.interface';
import { ErrorCode } from '@modules/shared/domain/error-codes';
export interface HandleCallbackResult {
paymentId: string;
status: string;
isSuccess: boolean;
}
@CommandHandler(HandleCallbackCommand)
export class HandleCallbackHandler implements ICommandHandler<HandleCallbackCommand> {
private readonly logger = new Logger(HandleCallbackHandler.name);
constructor(
@Inject(PAYMENT_REPOSITORY)
private readonly paymentRepo: IPaymentRepository,
@Inject(PAYMENT_GATEWAY_FACTORY)
private readonly gatewayFactory: IPaymentGatewayFactory,
private readonly eventBus: EventBus,
) {}
async execute(command: HandleCallbackCommand): Promise<HandleCallbackResult> {
const gateway = this.gatewayFactory.getGateway(command.provider);
const result = gateway.verifyCallback(command.callbackData);
if (!result.isValid) {
this.logger.warn(
`Invalid callback signature for provider=${command.provider}`,
);
throw new BadRequestException({
code: ErrorCode.PAYMENT_FAILED,
message: 'Chữ ký callback không hợp lệ',
});
}
// Find payment by orderId (which is the payment ID)
const payment = await this.paymentRepo.findById(result.orderId);
if (!payment) {
this.logger.warn(`Payment not found for orderId=${result.orderId}`);
throw new NotFoundException({
code: ErrorCode.NOT_FOUND,
message: 'Không tìm thấy thanh toán',
});
}
// Idempotency: if already completed/failed, return current state
if (payment.status === 'COMPLETED' || payment.status === 'FAILED' || payment.status === 'REFUNDED') {
this.logger.log(
`Payment ${payment.id} already in terminal state: ${payment.status}`,
);
return {
paymentId: payment.id,
status: payment.status,
isSuccess: payment.status === 'COMPLETED',
};
}
// Update payment status
if (result.isSuccess) {
payment.markCompleted(result.rawData);
} else {
payment.markFailed(result.rawData);
}
await this.paymentRepo.update(payment);
// Publish domain events
const events = payment.clearDomainEvents();
for (const event of events) {
this.eventBus.publish(event);
}
this.logger.log(
`Payment ${payment.id} callback processed: status=${payment.status}`,
);
return {
paymentId: payment.id,
status: payment.status,
isSuccess: result.isSuccess,
};
}
}

View File

@@ -0,0 +1,7 @@
export class RefundPaymentCommand {
constructor(
public readonly paymentId: string,
public readonly reason: string,
public readonly requestedBy: string,
) {}
}

View File

@@ -0,0 +1,81 @@
import {
BadRequestException,
Inject,
Logger,
NotFoundException,
} from '@nestjs/common';
import { CommandHandler, type ICommandHandler } from '@nestjs/cqrs';
import { RefundPaymentCommand } from './refund-payment.command';
import {
PAYMENT_REPOSITORY,
type IPaymentRepository,
} from '../../../domain/repositories/payment.repository';
import {
PAYMENT_GATEWAY_FACTORY,
type IPaymentGatewayFactory,
} from '../../../infrastructure/services/payment-gateway.interface';
import { ErrorCode } from '@modules/shared/domain/error-codes';
export interface RefundPaymentResult {
paymentId: string;
refundTxId: string | null;
success: boolean;
}
@CommandHandler(RefundPaymentCommand)
export class RefundPaymentHandler implements ICommandHandler<RefundPaymentCommand> {
private readonly logger = new Logger(RefundPaymentHandler.name);
constructor(
@Inject(PAYMENT_REPOSITORY)
private readonly paymentRepo: IPaymentRepository,
@Inject(PAYMENT_GATEWAY_FACTORY)
private readonly gatewayFactory: IPaymentGatewayFactory,
) {}
async execute(command: RefundPaymentCommand): Promise<RefundPaymentResult> {
const payment = await this.paymentRepo.findById(command.paymentId);
if (!payment) {
throw new NotFoundException({
code: ErrorCode.NOT_FOUND,
message: 'Không tìm thấy thanh toán',
});
}
if (payment.status !== 'COMPLETED') {
throw new BadRequestException({
code: ErrorCode.PAYMENT_FAILED,
message: 'Chỉ có thể hoàn tiền cho thanh toán đã hoàn tất',
});
}
if (!payment.providerTxId) {
throw new BadRequestException({
code: ErrorCode.PAYMENT_FAILED,
message: 'Không có mã giao dịch từ nhà cung cấp',
});
}
const gateway = this.gatewayFactory.getGateway(payment.provider);
const result = await gateway.refund({
providerTxId: payment.providerTxId,
amountVND: payment.amount.value,
reason: command.reason,
});
if (result.success) {
payment.markRefunded();
await this.paymentRepo.update(payment);
}
this.logger.log(
`Refund ${result.success ? 'successful' : 'failed'} for payment ${command.paymentId}`,
);
return {
paymentId: command.paymentId,
refundTxId: result.refundTxId,
success: result.success,
};
}
}

View File

@@ -0,0 +1,11 @@
export { CreatePaymentCommand } from './commands/create-payment/create-payment.command';
export { CreatePaymentHandler, type CreatePaymentResult } from './commands/create-payment/create-payment.handler';
export { HandleCallbackCommand } from './commands/handle-callback/handle-callback.command';
export { HandleCallbackHandler, type HandleCallbackResult } from './commands/handle-callback/handle-callback.handler';
export { RefundPaymentCommand } from './commands/refund-payment/refund-payment.command';
export { RefundPaymentHandler, type RefundPaymentResult } from './commands/refund-payment/refund-payment.handler';
export { GetPaymentStatusQuery } from './queries/get-payment-status/get-payment-status.query';
export { GetPaymentStatusHandler, type PaymentStatusDto } from './queries/get-payment-status/get-payment-status.handler';
export { ListTransactionsQuery } from './queries/list-transactions/list-transactions.query';
export { ListTransactionsHandler, type TransactionListDto } from './queries/list-transactions/list-transactions.handler';

View File

@@ -0,0 +1,59 @@
import {
ForbiddenException,
Inject,
NotFoundException,
} from '@nestjs/common';
import { type IQueryHandler, QueryHandler } from '@nestjs/cqrs';
import { GetPaymentStatusQuery } from './get-payment-status.query';
import {
PAYMENT_REPOSITORY,
type IPaymentRepository,
} from '../../../domain/repositories/payment.repository';
import { ErrorCode } from '@modules/shared/domain/error-codes';
export interface PaymentStatusDto {
id: string;
provider: string;
type: string;
amountVND: string;
status: string;
providerTxId: string | null;
createdAt: Date;
updatedAt: Date;
}
@QueryHandler(GetPaymentStatusQuery)
export class GetPaymentStatusHandler implements IQueryHandler<GetPaymentStatusQuery> {
constructor(
@Inject(PAYMENT_REPOSITORY)
private readonly paymentRepo: IPaymentRepository,
) {}
async execute(query: GetPaymentStatusQuery): Promise<PaymentStatusDto> {
const payment = await this.paymentRepo.findById(query.paymentId);
if (!payment) {
throw new NotFoundException({
code: ErrorCode.NOT_FOUND,
message: 'Không tìm thấy thanh toán',
});
}
if (payment.userId !== query.userId) {
throw new ForbiddenException({
code: ErrorCode.FORBIDDEN,
message: 'Bạn không có quyền xem thanh toán này',
});
}
return {
id: payment.id,
provider: payment.provider,
type: payment.type,
amountVND: payment.amount.value.toString(),
status: payment.status,
providerTxId: payment.providerTxId,
createdAt: payment.createdAt,
updatedAt: payment.updatedAt,
};
}
}

View File

@@ -0,0 +1,6 @@
export class GetPaymentStatusQuery {
constructor(
public readonly paymentId: string,
public readonly userId: string,
) {}
}

View File

@@ -0,0 +1,58 @@
import { Inject } from '@nestjs/common';
import { type IQueryHandler, QueryHandler } from '@nestjs/cqrs';
import { ListTransactionsQuery } from './list-transactions.query';
import {
PAYMENT_REPOSITORY,
type IPaymentRepository,
} from '../../../domain/repositories/payment.repository';
export interface TransactionItemDto {
id: string;
provider: string;
type: string;
amountVND: string;
status: string;
providerTxId: string | null;
createdAt: Date;
}
export interface TransactionListDto {
items: TransactionItemDto[];
total: number;
limit: number;
offset: number;
}
@QueryHandler(ListTransactionsQuery)
export class ListTransactionsHandler implements IQueryHandler<ListTransactionsQuery> {
constructor(
@Inject(PAYMENT_REPOSITORY)
private readonly paymentRepo: IPaymentRepository,
) {}
async execute(query: ListTransactionsQuery): Promise<TransactionListDto> {
const limit = Math.min(query.limit ?? 20, 100);
const offset = query.offset ?? 0;
const { items, total } = await this.paymentRepo.findByUserId(query.userId, {
status: query.status,
limit,
offset,
});
return {
items: items.map((payment) => ({
id: payment.id,
provider: payment.provider,
type: payment.type,
amountVND: payment.amount.value.toString(),
status: payment.status,
providerTxId: payment.providerTxId,
createdAt: payment.createdAt,
})),
total,
limit,
offset,
};
}
}

View File

@@ -0,0 +1,10 @@
import { type PaymentStatus } from '@prisma/client';
export class ListTransactionsQuery {
constructor(
public readonly userId: string,
public readonly status?: PaymentStatus,
public readonly limit?: number,
public readonly offset?: number,
) {}
}