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 @@
|
||||
export { PaymentsController } from './payments.controller';
|
||||
@@ -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),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
3
apps/api/src/modules/payments/presentation/dto/index.ts
Normal file
3
apps/api/src/modules/payments/presentation/dto/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export { CreatePaymentDto } from './create-payment.dto';
|
||||
export { RefundPaymentDto } from './refund-payment.dto';
|
||||
export { ListTransactionsDto } from './list-transactions.dto';
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import { IsString, MinLength } from 'class-validator';
|
||||
|
||||
export class RefundPaymentDto {
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
reason!: string;
|
||||
}
|
||||
Reference in New Issue
Block a user