From e4c4383ceb71e7acbd59f0ef04e94842d240f127 Mon Sep 17 00:00:00 2001 From: "Mamadou Khoussa [028918 DSI/DAC/DIF/DS]" Date: Mon, 1 Dec 2025 10:13:12 +0000 Subject: [PATCH] fix pagination et filter --- src/common/dto/pagination.dto.ts | 31 +++ .../paginated-response.interface.ts | 11 + src/modules/payments/dto/payment.dto.ts | 198 ++++++++++++++---- src/modules/payments/payments.controller.ts | 21 +- src/modules/payments/payments.service.ts | 155 +++++++++++++- .../subscriptions/dto/subscription.dto.ts | 126 ++++++++++- .../subscriptions/subscriptions.controller.ts | 21 +- .../subscriptions/subscriptions.service.ts | 128 ++++++++++- 8 files changed, 614 insertions(+), 77 deletions(-) create mode 100644 src/common/dto/pagination.dto.ts create mode 100644 src/common/interfaces/paginated-response.interface.ts diff --git a/src/common/dto/pagination.dto.ts b/src/common/dto/pagination.dto.ts new file mode 100644 index 0000000..bd66f57 --- /dev/null +++ b/src/common/dto/pagination.dto.ts @@ -0,0 +1,31 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { Type } from 'class-transformer'; +import { IsInt, IsOptional, Min, Max } from 'class-validator'; + +export class PaginationDto { + @ApiProperty({ + description: 'Page number', + minimum: 1, + default: 1, + required: false, + }) + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(1) + page?: number = 1; + + @ApiProperty({ + description: 'Number of items per page', + minimum: 1, + maximum: 100, + default: 10, + required: false, + }) + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(1) + @Max(100) + limit?: number = 10; +} \ No newline at end of file diff --git a/src/common/interfaces/paginated-response.interface.ts b/src/common/interfaces/paginated-response.interface.ts new file mode 100644 index 0000000..a575753 --- /dev/null +++ b/src/common/interfaces/paginated-response.interface.ts @@ -0,0 +1,11 @@ +export interface PaginatedResponse { + data: T[]; + meta: { + total: number; + page: number; + limit: number; + totalPages: number; + hasNextPage: boolean; + hasPreviousPage: boolean; + }; +} \ No newline at end of file diff --git a/src/modules/payments/dto/payment.dto.ts b/src/modules/payments/dto/payment.dto.ts index a8eafe1..f038893 100644 --- a/src/modules/payments/dto/payment.dto.ts +++ b/src/modules/payments/dto/payment.dto.ts @@ -7,9 +7,12 @@ import { IsEnum, IsDateString, isNumber, + IsInt, } from 'class-validator'; import { ApiProperty } from '@nestjs/swagger'; import { Type } from 'class-transformer'; +import { PaymentType, TransactionStatus } from 'generated/prisma'; +import { PaginationDto } from 'src/common/dto/pagination.dto'; export class ChargeDto { @ApiProperty({ description: 'User token from authentication' }) @@ -76,47 +79,7 @@ export class RefundDto { @IsOptional() metadata?: Record; } - -export class PaymentQueryDto { - @ApiProperty({ required: false, enum: ['PENDING', 'SUCCESS', 'FAILED', 'REFUNDED'] }) - @IsOptional() - @IsEnum(['PENDING', 'SUCCESS', 'FAILED', 'REFUNDED']) - status?: string; - - @ApiProperty({ required: false }) - @IsOptional() - @IsString() - userId?: string; - - @ApiProperty({ required: false }) - @IsOptional() - @IsNumber() - subscriptionId?: number; - - @ApiProperty({ required: false }) - @IsOptional() - @IsDateString() - startDate?: string; - - @ApiProperty({ required: false }) - @IsOptional() - @IsDateString() - endDate?: string; - - @ApiProperty({ required: false, default: 1 }) - @IsOptional() - @Type(() => Number) - @IsNumber() - @Min(1) - page?: number = 1; - - @ApiProperty({ required: false, default: 20 }) - @IsOptional() - @Type(() => Number) - @IsNumber() - @Min(1) - limit?: number = 20; -} + export class PaymentResponseDto { @ApiProperty() @@ -167,4 +130,157 @@ export class PaymentListResponseDto { limit: number; totalPages: number; }; +} + + +export class PaymentQueryDto extends PaginationDto { + @ApiProperty({ + description: 'Filter by payment type', + enum: PaymentType, + required: false, + }) + @IsOptional() + @IsEnum(PaymentType) + type?: PaymentType; + + @ApiProperty({ + description: 'Filter by transaction status', + enum: TransactionStatus, + required: false, + }) + @IsOptional() + @IsEnum(TransactionStatus) + status?: TransactionStatus; + + @ApiProperty({ + description: 'Filter by merchant partner ID', + required: false, + }) + @IsOptional() + @Type(() => Number) + @IsInt() + merchantPartnerId?: number; + + @ApiProperty({ + description: 'Filter by customer ID', + required: false, + }) + @IsOptional() + @Type(() => Number) + @IsInt() + customerId?: number; + + @ApiProperty({ + description: 'Filter by subscription ID', + required: false, + }) + @IsOptional() + @Type(() => Number) + @IsInt() + subscriptionId?: number; + + @ApiProperty({ + description: 'Search by external reference', + required: false, + }) + @IsOptional() + @IsString() + externalReference?: string; + + @ApiProperty({ + description: 'Search by payment reference', + required: false, + }) + @IsOptional() + @IsString() + reference?: string; + + @ApiProperty({ + description: 'Filter by currency code (e.g., XOF, XAF, EUR)', + example: 'XOF', + required: false, + }) + @IsOptional() + @IsString() + currency?: string; + + @ApiProperty({ + description: 'Filter payments with amount greater than or equal to this value', + required: false, + }) + @IsOptional() + @Type(() => Number) + amountMin?: number; + + @ApiProperty({ + description: 'Filter payments with amount less than or equal to this value', + required: false, + }) + @IsOptional() + @Type(() => Number) + amountMax?: number; + + @ApiProperty({ + description: 'Filter payments created from this date (ISO format)', + example: '2024-01-01T00:00:00Z', + required: false, + }) + @IsOptional() + @IsDateString() + createdFrom?: string; + + @ApiProperty({ + description: 'Filter payments created until this date (ISO format)', + example: '2024-12-31T23:59:59Z', + required: false, + }) + @IsOptional() + @IsDateString() + createdTo?: string; + + @ApiProperty({ + description: 'Filter payments completed from this date (ISO format)', + example: '2024-01-01T00:00:00Z', + required: false, + }) + @IsOptional() + @IsDateString() + completedFrom?: string; + + @ApiProperty({ + description: 'Filter payments completed until this date (ISO format)', + example: '2024-12-31T23:59:59Z', + required: false, + }) + @IsOptional() + @IsDateString() + completedTo?: string; + + @ApiProperty({ + description: 'Filter only failed payments (with failure reason)', + required: false, + }) + @IsOptional() + @Type(() => Boolean) + hasFailureReason?: boolean; + + @ApiProperty({ + description: 'Sort field', + enum: ['createdAt', 'completedAt', 'amount'], + default: 'createdAt', + required: false, + }) + @IsOptional() + @IsString() + sortBy?: 'createdAt' | 'completedAt' | 'amount' = 'createdAt'; + + @ApiProperty({ + description: 'Sort order', + enum: ['asc', 'desc'], + default: 'desc', + required: false, + }) + @IsOptional() + @IsEnum(['asc', 'desc']) + sortOrder?: 'asc' | 'desc' = 'desc'; } \ No newline at end of file diff --git a/src/modules/payments/payments.controller.ts b/src/modules/payments/payments.controller.ts index 6844ee6..0273646 100644 --- a/src/modules/payments/payments.controller.ts +++ b/src/modules/payments/payments.controller.ts @@ -125,23 +125,32 @@ export class PaymentsController { @Get('/') + @ApiOperation({ + summary: 'Get payment list with pagination and filters', + description: 'Retrieve payments with optional filters on status, type, dates, amounts, etc.' + }) + @ApiResponse({ + status: 200, + description: 'Paginated list of payments', + }) @ApiOperation({ summary: 'Get payments list' }) - async getAll(@Request() req) { - return this.paymentsService.findAll(); + async getAll(@Request() req, @Query() paymentQueryDto: PaymentQueryDto) { + return this.paymentsService.findAll(paymentQueryDto); } @Get('merchant/:merchantId') @ApiOperation({ summary: 'Get payments list by merchant' }) - async getAllByPaymentByMerchant(@Request() req, @Param('merchantId', ParseIntPipe) merchantId: number) { - return this.paymentsService.findAllByMerchant(merchantId); + async getAllByPaymentByMerchant(@Request() req, @Param('merchantId', ParseIntPipe) merchantId: number, @Query() queryDto: Omit,) { + return this.paymentsService.findAll({ ...queryDto, merchantPartnerId: merchantId }); } @Get('merchant/:merchantId/subscription/:subscriptionId') @ApiOperation({ summary: 'Get payments list by merchant' }) async getAllBySubscription(@Request() req, @Param('merchantId', ParseIntPipe) merchantId: number, - @Param('subscriptionId', ParseIntPipe) subscriptionId: number) { - return this.paymentsService.findAllByMerchantSubscription(merchantId,subscriptionId); + @Param('subscriptionId', ParseIntPipe) subscriptionId: number, + @Query() queryDto: Omit,) { + return this.paymentsService.findAll({ ...queryDto, merchantPartnerId: merchantId, subscriptionId: subscriptionId }); } diff --git a/src/modules/payments/payments.service.ts b/src/modules/payments/payments.service.ts index ae64a29..c97158e 100644 --- a/src/modules/payments/payments.service.ts +++ b/src/modules/payments/payments.service.ts @@ -2,9 +2,10 @@ import { Injectable, BadRequestException, Logger } from '@nestjs/common'; import { OperatorsService } from '../operators/operators.service'; import { PrismaService } from '../../shared/services/prisma.service'; import { EventEmitter2 } from '@nestjs/event-emitter'; -import { ChargeDto } from './dto/payment.dto'; +import { ChargeDto, PaymentQueryDto } from './dto/payment.dto'; import { RefundDto } from './dto/payment.dto'; -import { PaymentType, TransactionStatus } from 'generated/prisma'; +import { Payment, PaymentType, Prisma, TransactionStatus } from 'generated/prisma'; +import { PaginatedResponse } from 'src/common/interfaces/paginated-response.interface'; @Injectable() export class PaymentsService { @@ -64,17 +65,149 @@ export class PaymentsService { }); } - async findAll(): Promise { - // Check if merchant exists - return this.prisma.payment.findMany({ - // where: { merchantPartnerId: merchantId }, - - orderBy: { - createdAt: 'desc', - }, - }); + async findAll( + queryDto: PaymentQueryDto, + ): Promise> { + const { + page = 1, + limit = 10, + sortBy = 'createdAt', + sortOrder = 'desc', + } = queryDto; + + const skip = (page - 1) * limit; + const where = this.buildWhereClause(queryDto); + + // Construction du orderBy dynamique + const orderBy: Prisma.PaymentOrderByWithRelationInput = { + [sortBy]: sortOrder, + }; + + // Exécuter les requêtes en parallèle + const [payments, total] = await Promise.all([ + this.prisma.payment.findMany({ + where, + skip, + take: limit, + orderBy, + // Optionnel: inclure les relations + // include: { + // merchantPartner: true, + // subscription: true, + // reversementRequests: true, + // }, + }), + this.prisma.payment.count({ where }), + ]); + + const totalPages = Math.ceil(total / limit); + + return { + data: payments, + meta: { + total, + page, + limit, + totalPages, + hasNextPage: page < totalPages, + hasPreviousPage: page > 1, + }, + }; + } + + + private buildWhereClause( + filters: Partial + ): Prisma.PaymentWhereInput { + const { + type, + status, + merchantPartnerId, + customerId, + subscriptionId, + externalReference, + reference, + currency, + amountMin, + amountMax, + createdFrom, + createdTo, + completedFrom, + completedTo, + hasFailureReason, + } = filters; + + const where: Prisma.PaymentWhereInput = {}; + + // Filtres simples + if (type) where.type = type; + if (status) where.status = status; + if (merchantPartnerId) where.merchantPartnerId = merchantPartnerId; + if (customerId) where.customerId = customerId; + if (subscriptionId) where.subscriptionId = subscriptionId; + if (currency) where.currency = currency; + + // Filtres de recherche par référence + if (externalReference) { + where.externalReference = { + contains: externalReference, + mode: 'insensitive', + }; } + if (reference) { + where.reference = { + contains: reference, + mode: 'insensitive', + }; + } + + // Filtre sur les montants + if (amountMin !== undefined || amountMax !== undefined) { + where.amount = {}; + if (amountMin !== undefined) { + where.amount.gte = amountMin; + } + if (amountMax !== undefined) { + where.amount.lte = amountMax; + } + } + + // Filtres sur createdAt + if (createdFrom || createdTo) { + where.createdAt = {}; + if (createdFrom) { + where.createdAt.gte = new Date(createdFrom); + } + if (createdTo) { + where.createdAt.lte = new Date(createdTo); + } + } + + // Filtres sur completedAt + if (completedFrom || completedTo) { + where.completedAt = {}; + if (completedFrom) { + where.completedAt.gte = new Date(completedFrom); + } + if (completedTo) { + where.completedAt.lte = new Date(completedTo); + } + } + + // Filtre sur les paiements échoués + if (hasFailureReason !== undefined) { + if (hasFailureReason) { + where.failureReason = { not: null }; + } else { + where.failureReason = null; + } + } + + return where; + } + + refundPayment(paymentId: string, partnerId: any, refundDto: RefundDto) { throw new Error('Method not implemented.'); } diff --git a/src/modules/subscriptions/dto/subscription.dto.ts b/src/modules/subscriptions/dto/subscription.dto.ts index 854c9df..86d24f7 100644 --- a/src/modules/subscriptions/dto/subscription.dto.ts +++ b/src/modules/subscriptions/dto/subscription.dto.ts @@ -1,6 +1,9 @@ -import { IsString, IsOptional, IsNumber, IsEnum, IsBoolean, Min } from 'class-validator'; +import { IsString, IsOptional, IsNumber, IsEnum,IsDateString, IsBoolean, Min, IsInt } from 'class-validator'; import { ApiProperty } from '@nestjs/swagger'; - +import { PaginationDto } from 'src/common/dto/pagination.dto'; +import { Type } from 'class-transformer'; +import { Periodicity, SubscriptionStatus } from 'generated/prisma'; + export class CreateSubscriptionDto { @ApiProperty() @IsString() @@ -44,4 +47,123 @@ export class UpdateSubscriptionDto { @ApiProperty({ required: false }) @IsOptional() metadata?: Record; +} + +export class SubscriptionQueryDto extends PaginationDto { + @ApiProperty({ + description: 'Filter by merchant partner ID', + required: false, + }) + @IsOptional() + @Type(() => Number) + @IsInt() + merchantPartnerId?: number; + + + @ApiProperty({ + description: 'Filter by subscription status', + enum: SubscriptionStatus, + required: false, + }) + @IsOptional() + @IsEnum(SubscriptionStatus) + status?: SubscriptionStatus; + + @ApiProperty({ + description: 'Filter by periodicity', + enum: Periodicity, + required: false, + }) + @IsOptional() + @IsEnum(Periodicity) + periodicity?: Periodicity; + + + @ApiProperty({ + description: 'Filter by service ID', + required: false, + }) + @IsOptional() + @Type(() => Number) + @IsInt() + serviceId?: number; + + @ApiProperty({ + description: 'Filter subscriptions starting from this date (ISO format)', + example: '2024-01-01', + required: false, + }) + @IsOptional() + @IsDateString() + startDateFrom?: string; + + @ApiProperty({ + description: 'Filter subscriptions starting until this date (ISO format)', + example: '2024-12-31', + required: false, + }) + @IsOptional() + @IsDateString() + startDateTo?: string; + + @ApiProperty({ + description: 'Filter subscriptions ending from this date (ISO format)', + example: '2024-01-01', + required: false, + }) + @IsOptional() + @IsDateString() + endDateFrom?: string; + + @ApiProperty({ + description: 'Filter subscriptions ending until this date (ISO format)', + example: '2024-12-31', + required: false, + }) + @IsOptional() + @IsDateString() + endDateTo?: string; + + @ApiProperty({ + description: 'Filter subscriptions created from this date (ISO format)', + example: '2024-01-01T00:00:00Z', + required: false, + }) + @IsOptional() + @IsDateString() + createdFrom?: string; + + @ApiProperty({ + description: 'Filter subscriptions created until this date (ISO format)', + example: '2024-12-31T23:59:59Z', + required: false, + }) + @IsOptional() + @IsDateString() + createdTo?: string; + + @ApiProperty({ + description: 'Filter subscriptions with next payment from this date', + required: false, + }) + @IsOptional() + @IsDateString() + nextPaymentFrom?: string; + + @ApiProperty({ + description: 'Filter subscriptions with next payment until this date', + required: false, + }) + @IsOptional() + @IsDateString() + nextPaymentTo?: string; + + @ApiProperty({ + description: 'Filter by customer ID', + required: false, + }) + @IsOptional() + @Type(() => Number) + @IsInt() + customerId?: number; } \ No newline at end of file diff --git a/src/modules/subscriptions/subscriptions.controller.ts b/src/modules/subscriptions/subscriptions.controller.ts index 8b90a5b..46d23b6 100644 --- a/src/modules/subscriptions/subscriptions.controller.ts +++ b/src/modules/subscriptions/subscriptions.controller.ts @@ -13,10 +13,11 @@ import { Logger, ParseIntPipe, } from '@nestjs/common'; -import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger'; +import { ApiTags, ApiOperation, ApiQuery,ApiBearerAuth, ApiResponse } from '@nestjs/swagger'; import { SubscriptionsService } from './subscriptions.service'; -import { CreateSubscriptionDto, UpdateSubscriptionDto } from './dto/subscription.dto'; +import { CreateSubscriptionDto, SubscriptionQueryDto, UpdateSubscriptionDto } from './dto/subscription.dto'; import { JwtAuthGuard } from 'src/common/guards/jwt-auth.guard'; +import { PaginationDto } from 'src/common/dto/pagination.dto'; @ApiTags('subscriptions') @Controller('subscriptions') @@ -41,15 +42,21 @@ export class SubscriptionsController { } @Get('/') - @ApiOperation({ summary: 'Get subscription list' }) - async getAll(@Request() req) { - return this.subscriptionsService.findAll(); + @ApiOperation({ summary: 'Get subscription list with pagination' }) + @ApiQuery({ type: PaginationDto }) + @ApiResponse({ + status: 200, + description: 'Paginated list of subscriptions', + }) + async getAll(@Request() req, @Query() paginationDto: SubscriptionQueryDto,) { + return this.subscriptionsService.findAll(paginationDto); } @Get('merchant/:merchantId') @ApiOperation({ summary: 'Get subscription list by merchant' }) - async getAllByMErchant(@Request() req, @Param('merchantId', ParseIntPipe) merchantId: number) { - return this.subscriptionsService.findAllByMerchant(merchantId); + async getAllByMErchant(@Request() req, @Param('merchantId', ParseIntPipe,) merchantId: number, paginationDto: Omit ,) { + const page = {...paginationDto, merchantPartnerId: merchantId}; + return this.subscriptionsService.findAll(page); } diff --git a/src/modules/subscriptions/subscriptions.service.ts b/src/modules/subscriptions/subscriptions.service.ts index dcc11b3..bbd6502 100644 --- a/src/modules/subscriptions/subscriptions.service.ts +++ b/src/modules/subscriptions/subscriptions.service.ts @@ -3,9 +3,11 @@ import { InjectQueue } from '@nestjs/bull'; import bull from 'bull'; import { PrismaService } from '../../shared/services/prisma.service'; import { PaymentsService } from '../payments/payments.service'; -import { CreateSubscriptionDto, UpdateSubscriptionDto } from './dto/subscription.dto'; -import { Subscription } from 'generated/prisma'; +import { CreateSubscriptionDto, SubscriptionQueryDto, UpdateSubscriptionDto } from './dto/subscription.dto'; +import { Prisma, Subscription } from 'generated/prisma'; import { OperatorsService } from '../operators/operators.service'; +import { PaginationDto } from 'src/common/dto/pagination.dto'; +import { PaginatedResponse } from 'src/common/interfaces/paginated-response.interface'; //import { SubscriptionStatus } from '@prisma/client'; //import { SubscriptionStatus, Prisma } from '@prisma/client'; @@ -46,16 +48,122 @@ export class SubscriptionsService { }); } - async findAll(): Promise { - // Check if merchant exists + async findAll( paginationDto: SubscriptionQueryDto,): Promise> { + const { page = 1, limit = 10, status, + periodicity, + merchantPartnerId, + customerId, + serviceId, + startDateFrom, + startDateTo, + endDateFrom, + endDateTo, + createdFrom, + createdTo, + nextPaymentFrom, + nextPaymentTo, } = paginationDto; + const skip = (page - 1) * limit; - return this.prisma.subscription.findMany({ - // where: { merchantPartnerId: merchantId }, - - orderBy: { - createdAt: 'desc', + // Construction du where clause dynamique + const where: Prisma.SubscriptionWhereInput = {}; + + // Filtre par status + if (status) { + where.status = status; + } + + // Filtre par periodicity + if (periodicity) { + where.periodicity = periodicity; + } + + // Filtre par IDs + if (merchantPartnerId) { + where.merchantPartnerId = merchantPartnerId; + } + + if (customerId) { + where.customerId = customerId; + } + + if (serviceId) { + where.serviceId = serviceId; + } + + // Filtres sur startDate + if (startDateFrom || startDateTo) { + where.startDate = {}; + if (startDateFrom) { + where.startDate.gte = new Date(startDateFrom); + } + if (startDateTo) { + where.startDate.lte = new Date(startDateTo); + } + } + + // Filtres sur endDate + if (endDateFrom || endDateTo) { + where.endDate = {}; + if (endDateFrom) { + where.endDate.gte = new Date(endDateFrom); + } + if (endDateTo) { + where.endDate.lte = new Date(endDateTo); + } + } + + // Filtres sur createdAt + if (createdFrom || createdTo) { + where.createdAt = {}; + if (createdFrom) { + where.createdAt.gte = new Date(createdFrom); + } + if (createdTo) { + where.createdAt.lte = new Date(createdTo); + } + } + + // Filtres sur nextPaymentDate + if (nextPaymentFrom || nextPaymentTo) { + where.nextPaymentDate = {}; + if (nextPaymentFrom) { + where.nextPaymentDate.gte = new Date(nextPaymentFrom); + } + if (nextPaymentTo) { + where.nextPaymentDate.lte = new Date(nextPaymentTo); + } + } + // Check if merchant exists + const [subscriptions, total] = await Promise.all([ + this.prisma.subscription.findMany({ + where, + skip, + take: limit, + orderBy: { + createdAt: 'desc', + }, + // Vous pouvez inclure des relations si nécessaire + // include: { + // merchantPartner: true, + // service: true, + // }, + }), + this.prisma.subscription.count(), + ]); + + const totalPages = Math.ceil(total / limit); + + return { + data: subscriptions, + meta: { + total, + page, + limit, + totalPages, + hasNextPage: page < totalPages, + hasPreviousPage: page > 1, }, - }); + }; } getInvoices(id: string, partnerId: any) {