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