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 @@
export { PaymentsController } from './payments.controller';

View File

@@ -0,0 +1,107 @@
import {
Body,
Controller,
Get,
Ip,
Param,
Post,
Query,
UseGuards,
} from '@nestjs/common';
import { CommandBus, QueryBus } from '@nestjs/cqrs';
import { JwtAuthGuard } from '@modules/auth/presentation/guards/jwt-auth.guard';
import { RolesGuard } from '@modules/auth/presentation/guards/roles.guard';
import { CurrentUser } from '@modules/auth/presentation/decorators/current-user.decorator';
import { Roles } from '@modules/auth/presentation/decorators/roles.decorator';
import { type JwtPayload } from '@modules/auth/infrastructure/services/token.service';
import { CreatePaymentCommand } from '../../application/commands/create-payment/create-payment.command';
import { HandleCallbackCommand } from '../../application/commands/handle-callback/handle-callback.command';
import { RefundPaymentCommand } from '../../application/commands/refund-payment/refund-payment.command';
import { GetPaymentStatusQuery } from '../../application/queries/get-payment-status/get-payment-status.query';
import { ListTransactionsQuery } from '../../application/queries/list-transactions/list-transactions.query';
import { type CreatePaymentResult } from '../../application/commands/create-payment/create-payment.handler';
import { type HandleCallbackResult } from '../../application/commands/handle-callback/handle-callback.handler';
import { type RefundPaymentResult } from '../../application/commands/refund-payment/refund-payment.handler';
import { type PaymentStatusDto } from '../../application/queries/get-payment-status/get-payment-status.handler';
import { type TransactionListDto } from '../../application/queries/list-transactions/list-transactions.handler';
import { CreatePaymentDto } from '../dto/create-payment.dto';
import { RefundPaymentDto } from '../dto/refund-payment.dto';
import { ListTransactionsDto } from '../dto/list-transactions.dto';
import { type PaymentProvider } from '@prisma/client';
@Controller('payments')
export class PaymentsController {
constructor(
private readonly commandBus: CommandBus,
private readonly queryBus: QueryBus,
) {}
@UseGuards(JwtAuthGuard)
@Post()
async createPayment(
@Body() dto: CreatePaymentDto,
@CurrentUser() user: JwtPayload,
@Ip() ip: string,
): Promise<CreatePaymentResult> {
return this.commandBus.execute(
new CreatePaymentCommand(
user.sub,
dto.provider,
dto.type,
dto.amountVND,
dto.description,
dto.returnUrl,
ip || '127.0.0.1',
dto.transactionId,
dto.idempotencyKey,
),
);
}
@Post('callback/:provider')
async handleCallback(
@Param('provider') provider: string,
@Body() callbackData: Record<string, string>,
@Query() queryData: Record<string, string>,
): Promise<HandleCallbackResult> {
const providerUpper = provider.toUpperCase() as PaymentProvider;
// Merge query params and body (VNPay sends via query, MoMo/ZaloPay via body)
const mergedData = { ...queryData, ...callbackData };
return this.commandBus.execute(
new HandleCallbackCommand(providerUpper, mergedData),
);
}
@UseGuards(JwtAuthGuard)
@Get(':id')
async getPaymentStatus(
@Param('id') id: string,
@CurrentUser() user: JwtPayload,
): Promise<PaymentStatusDto> {
return this.queryBus.execute(new GetPaymentStatusQuery(id, user.sub));
}
@UseGuards(JwtAuthGuard)
@Get()
async listTransactions(
@CurrentUser() user: JwtPayload,
@Query() dto: ListTransactionsDto,
): Promise<TransactionListDto> {
return this.queryBus.execute(
new ListTransactionsQuery(user.sub, dto.status, dto.limit, dto.offset),
);
}
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles('ADMIN')
@Post(':id/refund')
async refundPayment(
@Param('id') id: string,
@Body() dto: RefundPaymentDto,
@CurrentUser() user: JwtPayload,
): Promise<RefundPaymentResult> {
return this.commandBus.execute(
new RefundPaymentCommand(id, dto.reason, user.sub),
);
}
}

View File

@@ -0,0 +1,35 @@
import {
IsEnum,
IsOptional,
IsString,
IsUrl,
MinLength,
} from 'class-validator';
import { Transform } from 'class-transformer';
import { PaymentProvider, PaymentType } from '@prisma/client';
export class CreatePaymentDto {
@IsEnum(PaymentProvider)
provider!: PaymentProvider;
@IsEnum(PaymentType)
type!: PaymentType;
@Transform(({ value }) => BigInt(value))
amountVND!: bigint;
@IsString()
@MinLength(1)
description!: string;
@IsUrl()
returnUrl!: string;
@IsOptional()
@IsString()
transactionId?: string;
@IsOptional()
@IsString()
idempotencyKey?: string;
}

View File

@@ -0,0 +1,3 @@
export { CreatePaymentDto } from './create-payment.dto';
export { RefundPaymentDto } from './refund-payment.dto';
export { ListTransactionsDto } from './list-transactions.dto';

View File

@@ -0,0 +1,19 @@
import { IsEnum, IsInt, IsOptional, Max, Min } from 'class-validator';
import { PaymentStatus } from '@prisma/client';
export class ListTransactionsDto {
@IsOptional()
@IsEnum(PaymentStatus)
status?: PaymentStatus;
@IsOptional()
@IsInt()
@Min(1)
@Max(100)
limit?: number;
@IsOptional()
@IsInt()
@Min(0)
offset?: number;
}

View File

@@ -0,0 +1,7 @@
import { IsString, MinLength } from 'class-validator';
export class RefundPaymentDto {
@IsString()
@MinLength(1)
reason!: string;
}