diff --git a/src/main.ts b/src/main.ts index 1810a55..ed1bf5b 100644 --- a/src/main.ts +++ b/src/main.ts @@ -26,7 +26,7 @@ async function bootstrap() { // Swagger const config = new DocumentBuilder() - .setTitle('Payment Hub API') + .setTitle('Merchant Config API') .setDescription('Unified DCB Payment Aggregation Platform') .setVersion('1.0.0') .addBearerAuth() diff --git a/src/merchant/merchant.controller.ts b/src/merchant/controllers/merchant.controller.ts similarity index 95% rename from src/merchant/merchant.controller.ts rename to src/merchant/controllers/merchant.controller.ts index 628f9c5..b85f1a3 100644 --- a/src/merchant/merchant.controller.ts +++ b/src/merchant/controllers/merchant.controller.ts @@ -12,10 +12,10 @@ import { HttpStatus, } from '@nestjs/common'; import { ApiTags, ApiOperation, ApiResponse, ApiParam, ApiQuery } from '@nestjs/swagger'; -import { MerchantService } from './services/merchant.service'; -import { CreateMerchantPartnerDto } from './dto/create.merchant.dto'; -import { UpdateMerchantPartnerDto } from './dto/ update.merchant.dto'; -import { AddUserToMerchantDto, UpdateUserRoleDto } from './dto/merchant.user.dto'; +import { MerchantService } from '../services/merchant.service'; +import { CreateMerchantPartnerDto } from '../dto/create.merchant.dto'; +import { UpdateMerchantPartnerDto } from '../dto/ update.merchant.dto'; +import { AddUserToMerchantDto, UpdateUserRoleDto } from '../dto/merchant.user.dto'; @ApiTags('merchants') @Controller('merchants') diff --git a/src/merchant/controllers/service.controller.ts b/src/merchant/controllers/service.controller.ts new file mode 100644 index 0000000..779bf80 --- /dev/null +++ b/src/merchant/controllers/service.controller.ts @@ -0,0 +1,130 @@ +import { + Controller, + Get, + Post, + Body, + Patch, + Param, + Delete, + ParseIntPipe, + HttpCode, + HttpStatus, +} from '@nestjs/common'; +import { ApiTags, ApiOperation, ApiResponse, ApiParam } from '@nestjs/swagger'; +import { ServiceManagementService } from '../services/service.service'; +import { CreateServiceDto } from '../dto/create.service.dto'; +import { UpdateServiceDto } from '../dto/update.service.dto'; +import { CreatePlanDto } from '../dto/create.plan.dto'; +import { UpdatePlanDto } from '../dto/update.plan.dto'; + +@ApiTags('services') +@Controller('services') +export class ServiceController { + constructor(private readonly serviceManagementService: ServiceManagementService) {} + + // ==================== SERVICE ENDPOINTS ==================== + + @Post() + @ApiOperation({ summary: 'Create a new service for a merchant' }) + @ApiResponse({ status: 201, description: 'Service created successfully' }) + @ApiResponse({ status: 400, description: 'Bad request - validation failed' }) + @ApiResponse({ status: 404, description: 'Merchant not found' }) + create(@Body() createServiceDto: CreateServiceDto) { + return this.serviceManagementService.createService(createServiceDto); + } + + @Get('merchant/:merchantId') + @ApiOperation({ summary: 'Get all services for a merchant' }) + @ApiParam({ name: 'merchantId', type: Number }) + @ApiResponse({ status: 200, description: 'List of merchant services' }) + @ApiResponse({ status: 404, description: 'Merchant not found' }) + findAllByMerchant(@Param('merchantId', ParseIntPipe) merchantId: number) { + return this.serviceManagementService.findAllByMerchant(merchantId); + } + + @Get(':id') + @ApiOperation({ summary: 'Get service by ID' }) + @ApiParam({ name: 'id', type: Number }) + @ApiResponse({ status: 200, description: 'Service found' }) + @ApiResponse({ status: 404, description: 'Service not found' }) + findOne(@Param('id', ParseIntPipe) id: number) { + return this.serviceManagementService.findOneService(id); + } + + @Patch(':id') + @ApiOperation({ summary: 'Update service' }) + @ApiParam({ name: 'id', type: Number }) + @ApiResponse({ status: 200, description: 'Service updated successfully' }) + @ApiResponse({ status: 404, description: 'Service not found' }) + update( + @Param('id', ParseIntPipe) id: number, + @Body() updateServiceDto: UpdateServiceDto, + ) { + return this.serviceManagementService.updateService(id, updateServiceDto); + } + + @Delete(':id') + @HttpCode(HttpStatus.NO_CONTENT) + @ApiOperation({ summary: 'Delete service' }) + @ApiParam({ name: 'id', type: Number }) + @ApiResponse({ status: 204, description: 'Service deleted successfully' }) + @ApiResponse({ status: 404, description: 'Service not found' }) + remove(@Param('id', ParseIntPipe) id: number) { + return this.serviceManagementService.removeService(id); + } + + // ==================== PLAN ENDPOINTS ==================== + + @Post(':serviceId/plans') + @ApiOperation({ summary: 'Create a new plan for a service' }) + @ApiParam({ name: 'serviceId', type: Number }) + @ApiResponse({ status: 201, description: 'Plan created successfully' }) + @ApiResponse({ status: 400, description: 'Bad request - validation failed' }) + @ApiResponse({ status: 404, description: 'Service not found' }) + createPlan( + @Param('serviceId', ParseIntPipe) serviceId: number, + @Body() createPlanDto: CreatePlanDto, + ) { + return this.serviceManagementService.createPlan(serviceId, createPlanDto); + } + + @Get(':serviceId/plans') + @ApiOperation({ summary: 'Get all plans for a service' }) + @ApiParam({ name: 'serviceId', type: Number }) + @ApiResponse({ status: 200, description: 'List of service plans' }) + @ApiResponse({ status: 404, description: 'Service not found' }) + findAllPlans(@Param('serviceId', ParseIntPipe) serviceId: number) { + return this.serviceManagementService.findAllPlansByService(serviceId); + } + + @Get('plans/:planId') + @ApiOperation({ summary: 'Get plan by ID' }) + @ApiParam({ name: 'planId', type: Number }) + @ApiResponse({ status: 200, description: 'Plan found' }) + @ApiResponse({ status: 404, description: 'Plan not found' }) + findOnePlan(@Param('planId', ParseIntPipe) planId: number) { + return this.serviceManagementService.findOnePlan(planId); + } + + @Patch('plans/:planId') + @ApiOperation({ summary: 'Update plan' }) + @ApiParam({ name: 'planId', type: Number }) + @ApiResponse({ status: 200, description: 'Plan updated successfully' }) + @ApiResponse({ status: 404, description: 'Plan not found' }) + updatePlan( + @Param('planId', ParseIntPipe) planId: number, + @Body() updatePlanDto: UpdatePlanDto, + ) { + return this.serviceManagementService.updatePlan(planId, updatePlanDto); + } + + @Delete('plans/:planId') + @HttpCode(HttpStatus.NO_CONTENT) + @ApiOperation({ summary: 'Delete plan' }) + @ApiParam({ name: 'planId', type: Number }) + @ApiResponse({ status: 204, description: 'Plan deleted successfully' }) + @ApiResponse({ status: 404, description: 'Plan not found' }) + removePlan(@Param('planId', ParseIntPipe) planId: number) { + return this.serviceManagementService.removePlan(planId); + } +} \ No newline at end of file diff --git a/src/merchant/dto/create.plan.dto.ts b/src/merchant/dto/create.plan.dto.ts new file mode 100644 index 0000000..7154438 --- /dev/null +++ b/src/merchant/dto/create.plan.dto.ts @@ -0,0 +1,64 @@ +import { + IsString, + IsNotEmpty, + IsEnum, + IsNumber, + Min, +} from 'class-validator'; +import { ApiProperty } from '@nestjs/swagger'; +import { Periodicity, Currency } from "generated/prisma"; + +export class CreatePlanDto { + @ApiProperty({ + description: 'Plan name', + example: 'Monthly Premium', + }) + @IsString() + @IsNotEmpty() + name: string; + + @ApiProperty({ + description: 'Plan type', + enum: Periodicity, + example: 'Monthly', + }) + @IsEnum(Periodicity) + @IsNotEmpty() + type: Periodicity; + + @ApiProperty({ + description: 'Plan amount', + example: 5000, + }) + @IsNumber() + @Min(0) + @IsNotEmpty() + amount: number; + + @ApiProperty({ + description: 'Tax amount', + example: 900, + }) + @IsNumber() + @Min(0) + @IsNotEmpty() + tax: number; + + @ApiProperty({ + description: 'Currency', + enum: Currency, + example: 'XOF', + }) + @IsEnum(Currency) + @IsNotEmpty() + currency: Currency; + + @ApiProperty({ + description: 'Billing periodicity', + enum: Periodicity, + example: 'Monthly', + }) + @IsEnum(Periodicity) + @IsNotEmpty() + periodicity: Periodicity; +} \ No newline at end of file diff --git a/src/merchant/dto/create.service.dto.ts b/src/merchant/dto/create.service.dto.ts new file mode 100644 index 0000000..0a7d6cc --- /dev/null +++ b/src/merchant/dto/create.service.dto.ts @@ -0,0 +1,28 @@ +import { IsString, IsNotEmpty, IsOptional, IsInt } from 'class-validator'; +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; + +export class CreateServiceDto { + @ApiProperty({ + description: 'Service name', + example: 'Premium Streaming', + }) + @IsString() + @IsNotEmpty() + name: string; + + @ApiPropertyOptional({ + description: 'Service description', + example: 'Access to premium content streaming', + }) + @IsString() + @IsOptional() + description?: string; + + @ApiProperty({ + description: 'Merchant partner ID', + example: 1, + }) + @IsInt() + @IsNotEmpty() + merchantPartnerId: number; +} \ No newline at end of file diff --git a/src/merchant/dto/update.plan.dto.ts b/src/merchant/dto/update.plan.dto.ts new file mode 100644 index 0000000..073443c --- /dev/null +++ b/src/merchant/dto/update.plan.dto.ts @@ -0,0 +1,4 @@ +import { PartialType } from '@nestjs/swagger'; +import { CreatePlanDto } from './create.plan.dto'; + +export class UpdatePlanDto extends PartialType(CreatePlanDto) {} \ No newline at end of file diff --git a/src/merchant/dto/update.service.dto.ts b/src/merchant/dto/update.service.dto.ts new file mode 100644 index 0000000..c2da541 --- /dev/null +++ b/src/merchant/dto/update.service.dto.ts @@ -0,0 +1,7 @@ +import { PartialType } from '@nestjs/swagger'; +import { CreateServiceDto } from './create.service.dto'; +import { OmitType } from '@nestjs/swagger'; + +export class UpdateServiceDto extends PartialType( + OmitType(CreateServiceDto, ['merchantPartnerId'] as const), +) {} \ No newline at end of file diff --git a/src/merchant/entities/service.entity.ts b/src/merchant/entities/service.entity.ts new file mode 100644 index 0000000..05e1ecc --- /dev/null +++ b/src/merchant/entities/service.entity.ts @@ -0,0 +1,42 @@ +import { Service, Plan, MerchantPartner, Periodicity, Currency } from "generated/prisma"; + +export type ServiceEntity = Service; + +export type PlanEntity = Plan & { + service?: Service; +}; + +export type ServiceWithPlans = Service & { + plans: Plan[]; + merchantPartner?: MerchantPartner; +}; + +export interface CreateServiceData { + name: string; + description?: string; + merchantPartnerId: number; +} + +export interface UpdateServiceData { + name?: string; + description?: string; +} + +export interface CreatePlanData { + name: string; + type: Periodicity; + amount: number; + tax: number; + currency: Currency; + periodicity: Periodicity; + serviceId: number; +} + +export interface UpdatePlanData { + name?: string; + type?: Periodicity; + amount?: number; + tax?: number; + currency?: Currency; + periodicity?: Periodicity; +} \ No newline at end of file diff --git a/src/merchant/merchant.module.ts b/src/merchant/merchant.module.ts index 3fc6ee4..34fd50c 100644 --- a/src/merchant/merchant.module.ts +++ b/src/merchant/merchant.module.ts @@ -2,11 +2,13 @@ import { Module } from '@nestjs/common'; import { HttpModule } from '@nestjs/axios'; import { ConfigModule } from '@nestjs/config'; import { EventEmitterModule } from '@nestjs/event-emitter'; -import { MerchantController } from './merchant.controller'; +import { MerchantController } from './controllers/merchant.controller'; import { HttpUserServiceClient } from './services/user.service.client'; import { PrismaService } from 'src/shared/services/prisma.service'; import { MerchantService } from './services/merchant.service'; +import { ServiceController } from './controllers/service.controller'; +import { ServiceManagementService } from './services/service.service'; @Module({ @@ -15,9 +17,10 @@ import { MerchantService } from './services/merchant.service'; ConfigModule, EventEmitterModule.forRoot(), ], - controllers: [MerchantController], + controllers: [MerchantController,ServiceController], providers: [ MerchantService, + ServiceManagementService, PrismaService, HttpUserServiceClient ], diff --git a/src/merchant/services/service.service.ts b/src/merchant/services/service.service.ts new file mode 100644 index 0000000..3c3f5cb --- /dev/null +++ b/src/merchant/services/service.service.ts @@ -0,0 +1,267 @@ +import { Injectable, NotFoundException } from '@nestjs/common'; +import { PrismaService } from 'src/shared/services/prisma.service'; +import { EventEmitter2 } from '@nestjs/event-emitter'; +import { CreateServiceDto } from '../dto/create.service.dto'; +import { UpdateServiceDto } from '../dto/update.service.dto'; +import { CreatePlanDto } from '../dto/create.plan.dto'; +import { UpdatePlanDto } from '../dto/update.plan.dto'; +import { ServiceWithPlans, PlanEntity } from '../entities/service.entity'; + +@Injectable() +export class ServiceManagementService { + constructor( + private readonly prisma: PrismaService, + private readonly eventEmitter: EventEmitter2, + ) {} + + // ==================== SERVICE METHODS ==================== + + /** + * Create a new service for a merchant + */ + async createService(dto: CreateServiceDto): Promise { + // Check if merchant exists + const merchant = await this.prisma.merchantPartner.findUnique({ + where: { id: dto.merchantPartnerId }, + }); + + if (!merchant) { + throw new NotFoundException( + `Merchant with ID ${dto.merchantPartnerId} not found`, + ); + } + + const service = await this.prisma.service.create({ + data: { + name: dto.name, + description: dto.description, + merchantPartnerId: dto.merchantPartnerId, + }, + include: { + plans: true, + merchantPartner: true, + }, + }); + + this.eventEmitter.emit('service.created', { + serviceId: service.id, + serviceName: service.name, + merchantId: dto.merchantPartnerId, + timestamp: new Date(), + }); + + return service; + } + + /** + * Find all services for a merchant + */ + async findAllByMerchant(merchantId: number): Promise { + // Check if merchant exists + const merchant = await this.prisma.merchantPartner.findUnique({ + where: { id: merchantId }, + }); + + if (!merchant) { + throw new NotFoundException(`Merchant with ID ${merchantId} not found`); + } + + return this.prisma.service.findMany({ + where: { merchantPartnerId: merchantId }, + include: { + plans: true, + merchantPartner: true, + }, + orderBy: { + createdAt: 'desc', + }, + }); + } + + /** + * Find service by ID + */ + async findOneService(id: number): Promise { + const service = await this.prisma.service.findUnique({ + where: { id }, + include: { + plans: true, + merchantPartner: true, + }, + }); + + if (!service) { + throw new NotFoundException(`Service with ID ${id} not found`); + } + + return service; + } + + /** + * Update service + */ + async updateService( + id: number, + dto: UpdateServiceDto, + ): Promise { + await this.findOneService(id); // Check if exists + + const service = await this.prisma.service.update({ + where: { id }, + data: { + name: dto.name, + description: dto.description, + }, + include: { + plans: true, + merchantPartner: true, + }, + }); + + this.eventEmitter.emit('service.updated', { + serviceId: id, + serviceName: service.name, + timestamp: new Date(), + }); + + return service; + } + + /** + * Delete service + */ + async removeService(id: number): Promise { + await this.findOneService(id); // Check if exists + + await this.prisma.service.delete({ + where: { id }, + }); + + this.eventEmitter.emit('service.deleted', { + serviceId: id, + timestamp: new Date(), + }); + } + + // ==================== PLAN METHODS ==================== + + /** + * Create a new plan for a service + */ + async createPlan( + serviceId: number, + dto: CreatePlanDto, + ): Promise { + // Check if service exists + await this.findOneService(serviceId); + + const plan = await this.prisma.plan.create({ + data: { + name: dto.name, + type: dto.type, + amount: dto.amount, + tax: dto.tax, + currency: dto.currency, + periodicity: dto.periodicity, + serviceId, + }, + include: { + service: true, + }, + }); + + this.eventEmitter.emit('plan.created', { + planId: plan.id, + planName: plan.name, + serviceId, + amount: plan.amount, + currency: plan.currency, + timestamp: new Date(), + }); + + return plan; + } + + /** + * Find all plans for a service + */ + async findAllPlansByService(serviceId: number): Promise { + // Check if service exists + await this.findOneService(serviceId); + + return this.prisma.plan.findMany({ + where: { serviceId }, + include: { + service: true, + }, + orderBy: { + amount: 'asc', + }, + }); + } + + /** + * Find plan by ID + */ + async findOnePlan(id: number): Promise { + const plan = await this.prisma.plan.findUnique({ + where: { id }, + include: { + service: true, + }, + }); + + if (!plan) { + throw new NotFoundException(`Plan with ID ${id} not found`); + } + + return plan; + } + + /** + * Update plan + */ + async updatePlan(id: number, dto: UpdatePlanDto): Promise { + await this.findOnePlan(id); // Check if exists + + const plan = await this.prisma.plan.update({ + where: { id }, + data: { + name: dto.name, + type: dto.type, + amount: dto.amount, + tax: dto.tax, + currency: dto.currency, + periodicity: dto.periodicity, + }, + include: { + service: true, + }, + }); + + this.eventEmitter.emit('plan.updated', { + planId: id, + planName: plan.name, + amount: plan.amount, + timestamp: new Date(), + }); + + return plan; + } + + /** + * Delete plan + */ + async removePlan(id: number): Promise { + await this.findOnePlan(id); // Check if exists + + await this.prisma.plan.delete({ + where: { id }, + }); + + this.eventEmitter.emit('plan.deleted', { + planId: id, + timestamp: new Date(), + }); + } +} \ No newline at end of file