diff --git a/apps/backend/src/app.module.ts b/apps/backend/src/app.module.ts index be64677c..462460a8 100644 --- a/apps/backend/src/app.module.ts +++ b/apps/backend/src/app.module.ts @@ -27,6 +27,7 @@ import { EmailModule } from '@modules/email/email.module'; import { OnboardingModule } from '@modules/onboarding/onboarding.module'; import { WhatsAppModule } from '@modules/whatsapp/whatsapp.module'; import { SalesModule } from '@modules/sales/sales.module'; +import { CommissionsModule } from '@modules/commissions/commissions.module'; @Module({ imports: [ @@ -86,6 +87,7 @@ import { SalesModule } from '@modules/sales/sales.module'; OnboardingModule, WhatsAppModule, SalesModule, + CommissionsModule, ], }) export class AppModule {} diff --git a/apps/backend/src/modules/commissions/commissions.module.ts b/apps/backend/src/modules/commissions/commissions.module.ts new file mode 100644 index 00000000..8b38b7d8 --- /dev/null +++ b/apps/backend/src/modules/commissions/commissions.module.ts @@ -0,0 +1,55 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; + +// Entities +import { CommissionScheme } from './entities/commission-scheme.entity'; +import { CommissionAssignment } from './entities/commission-assignment.entity'; +import { CommissionEntry } from './entities/commission-entry.entity'; +import { CommissionPeriod } from './entities/commission-period.entity'; + +// Services +import { SchemesService } from './services/schemes.service'; +import { AssignmentsService } from './services/assignments.service'; +import { EntriesService } from './services/entries.service'; +import { PeriodsService } from './services/periods.service'; +import { CommissionsDashboardService } from './services/commissions-dashboard.service'; + +// Controllers +import { SchemesController } from './controllers/schemes.controller'; +import { AssignmentsController } from './controllers/assignments.controller'; +import { EntriesController } from './controllers/entries.controller'; +import { PeriodsController } from './controllers/periods.controller'; +import { DashboardController } from './controllers/dashboard.controller'; + +@Module({ + imports: [ + TypeOrmModule.forFeature([ + CommissionScheme, + CommissionAssignment, + CommissionEntry, + CommissionPeriod, + ]), + ], + controllers: [ + SchemesController, + AssignmentsController, + EntriesController, + PeriodsController, + DashboardController, + ], + providers: [ + SchemesService, + AssignmentsService, + EntriesService, + PeriodsService, + CommissionsDashboardService, + ], + exports: [ + SchemesService, + AssignmentsService, + EntriesService, + PeriodsService, + CommissionsDashboardService, + ], +}) +export class CommissionsModule {} diff --git a/apps/backend/src/modules/commissions/controllers/assignments.controller.ts b/apps/backend/src/modules/commissions/controllers/assignments.controller.ts new file mode 100644 index 00000000..24c6eab2 --- /dev/null +++ b/apps/backend/src/modules/commissions/controllers/assignments.controller.ts @@ -0,0 +1,139 @@ +import { + Controller, + Get, + Post, + Patch, + Delete, + Body, + Param, + Query, + ParseUUIDPipe, + UseGuards, +} from '@nestjs/common'; +import { ApiTags, ApiOperation, ApiResponse, ApiBearerAuth, ApiQuery } from '@nestjs/swagger'; +import { JwtAuthGuard } from '../../auth/guards/jwt-auth.guard'; +import { CurrentTenant } from '../../tenants/decorators/current-tenant.decorator'; +import { CurrentUser } from '../../auth/decorators/current-user.decorator'; +import { AssignmentsService, AssignmentFilters, PaginationOptions } from '../services/assignments.service'; +import { CreateAssignmentDto, UpdateAssignmentDto } from '../dto'; + +@ApiTags('Commissions - Assignments') +@ApiBearerAuth() +@UseGuards(JwtAuthGuard) +@Controller('commissions/assignments') +export class AssignmentsController { + constructor(private readonly assignmentsService: AssignmentsService) {} + + @Get() + @ApiOperation({ summary: 'Get all commission assignments' }) + @ApiQuery({ name: 'page', required: false, type: Number }) + @ApiQuery({ name: 'limit', required: false, type: Number }) + @ApiQuery({ name: 'user_id', required: false, type: String }) + @ApiQuery({ name: 'scheme_id', required: false, type: String }) + @ApiQuery({ name: 'is_active', required: false, type: Boolean }) + @ApiQuery({ name: 'sortBy', required: false, type: String }) + @ApiQuery({ name: 'sortOrder', required: false, enum: ['ASC', 'DESC'] }) + async findAll( + @CurrentTenant() tenantId: string, + @Query('page') page?: number, + @Query('limit') limit?: number, + @Query('user_id') user_id?: string, + @Query('scheme_id') scheme_id?: string, + @Query('is_active') is_active?: boolean, + @Query('sortBy') sortBy?: string, + @Query('sortOrder') sortOrder?: 'ASC' | 'DESC', + ) { + const filters: AssignmentFilters = { user_id, scheme_id, is_active }; + const pagination: PaginationOptions = { page, limit, sortBy, sortOrder }; + return this.assignmentsService.findAll(tenantId, filters, pagination); + } + + @Get(':id') + @ApiOperation({ summary: 'Get an assignment by ID' }) + @ApiResponse({ status: 200, description: 'Assignment found' }) + @ApiResponse({ status: 404, description: 'Assignment not found' }) + async findOne( + @CurrentTenant() tenantId: string, + @Param('id', ParseUUIDPipe) id: string, + ) { + return this.assignmentsService.findOne(tenantId, id); + } + + @Get('user/:userId') + @ApiOperation({ summary: 'Get assignments for a user' }) + async findByUser( + @CurrentTenant() tenantId: string, + @Param('userId', ParseUUIDPipe) userId: string, + ) { + return this.assignmentsService.findByUser(tenantId, userId); + } + + @Get('user/:userId/active') + @ApiOperation({ summary: 'Get active scheme for a user' }) + async getActiveScheme( + @CurrentTenant() tenantId: string, + @Param('userId', ParseUUIDPipe) userId: string, + ) { + const assignment = await this.assignmentsService.getActiveScheme(tenantId, userId); + if (!assignment) { + return { message: 'No active commission scheme assigned to this user' }; + } + return assignment; + } + + @Get('scheme/:schemeId/users') + @ApiOperation({ summary: 'Get users assigned to a scheme' }) + async getSchemeAssignees( + @CurrentTenant() tenantId: string, + @Param('schemeId', ParseUUIDPipe) schemeId: string, + ) { + return this.assignmentsService.getSchemeAssignees(tenantId, schemeId); + } + + @Post() + @ApiOperation({ summary: 'Assign a commission scheme to a user' }) + @ApiResponse({ status: 201, description: 'Assignment created' }) + @ApiResponse({ status: 400, description: 'Invalid scheme or duplicate assignment' }) + async assign( + @CurrentTenant() tenantId: string, + @CurrentUser('id') userId: string, + @Body() dto: CreateAssignmentDto, + ) { + return this.assignmentsService.assign(tenantId, dto, userId); + } + + @Patch(':id') + @ApiOperation({ summary: 'Update an assignment' }) + @ApiResponse({ status: 200, description: 'Assignment updated' }) + @ApiResponse({ status: 404, description: 'Assignment not found' }) + async update( + @CurrentTenant() tenantId: string, + @Param('id', ParseUUIDPipe) id: string, + @Body() dto: UpdateAssignmentDto, + ) { + return this.assignmentsService.update(tenantId, id, dto); + } + + @Delete(':id') + @ApiOperation({ summary: 'Remove an assignment (deactivate)' }) + @ApiResponse({ status: 200, description: 'Assignment removed' }) + @ApiResponse({ status: 404, description: 'Assignment not found' }) + async remove( + @CurrentTenant() tenantId: string, + @Param('id', ParseUUIDPipe) id: string, + ) { + await this.assignmentsService.remove(tenantId, id); + return { message: 'Assignment removed successfully' }; + } + + @Post(':id/deactivate') + @ApiOperation({ summary: 'Deactivate an assignment' }) + @ApiResponse({ status: 200, description: 'Assignment deactivated' }) + @ApiResponse({ status: 404, description: 'Assignment not found' }) + async deactivate( + @CurrentTenant() tenantId: string, + @Param('id', ParseUUIDPipe) id: string, + ) { + return this.assignmentsService.deactivate(tenantId, id); + } +} diff --git a/apps/backend/src/modules/commissions/controllers/dashboard.controller.ts b/apps/backend/src/modules/commissions/controllers/dashboard.controller.ts new file mode 100644 index 00000000..7b7b89ea --- /dev/null +++ b/apps/backend/src/modules/commissions/controllers/dashboard.controller.ts @@ -0,0 +1,99 @@ +import { + Controller, + Get, + Param, + Query, + ParseUUIDPipe, + UseGuards, +} from '@nestjs/common'; +import { ApiTags, ApiOperation, ApiResponse, ApiBearerAuth, ApiQuery } from '@nestjs/swagger'; +import { JwtAuthGuard } from '../../auth/guards/jwt-auth.guard'; +import { CurrentTenant } from '../../tenants/decorators/current-tenant.decorator'; +import { CurrentUser } from '../../auth/decorators/current-user.decorator'; +import { CommissionsDashboardService } from '../services/commissions-dashboard.service'; + +@ApiTags('Commissions - Dashboard') +@ApiBearerAuth() +@UseGuards(JwtAuthGuard) +@Controller('commissions/dashboard') +export class DashboardController { + constructor(private readonly dashboardService: CommissionsDashboardService) {} + + @Get('summary') + @ApiOperation({ summary: 'Get commissions dashboard summary' }) + @ApiResponse({ status: 200, description: 'Dashboard summary' }) + async getSummary(@CurrentTenant() tenantId: string) { + return this.dashboardService.getSummary(tenantId); + } + + @Get('by-user') + @ApiOperation({ summary: 'Get earnings breakdown by user' }) + @ApiQuery({ name: 'date_from', required: false, type: String }) + @ApiQuery({ name: 'date_to', required: false, type: String }) + async getEarningsByUser( + @CurrentTenant() tenantId: string, + @Query('date_from') dateFrom?: string, + @Query('date_to') dateTo?: string, + ) { + return this.dashboardService.getEarningsByUser( + tenantId, + dateFrom ? new Date(dateFrom) : undefined, + dateTo ? new Date(dateTo) : undefined, + ); + } + + @Get('by-period') + @ApiOperation({ summary: 'Get earnings breakdown by period' }) + async getEarningsByPeriod(@CurrentTenant() tenantId: string) { + return this.dashboardService.getEarningsByPeriod(tenantId); + } + + @Get('top-earners') + @ApiOperation({ summary: 'Get top earning users' }) + @ApiQuery({ name: 'limit', required: false, type: Number, description: 'Number of results (default: 10)' }) + @ApiQuery({ name: 'date_from', required: false, type: String }) + @ApiQuery({ name: 'date_to', required: false, type: String }) + async getTopEarners( + @CurrentTenant() tenantId: string, + @Query('limit') limit?: number, + @Query('date_from') dateFrom?: string, + @Query('date_to') dateTo?: string, + ) { + return this.dashboardService.getTopEarners( + tenantId, + limit || 10, + dateFrom ? new Date(dateFrom) : undefined, + dateTo ? new Date(dateTo) : undefined, + ); + } + + @Get('my-earnings') + @ApiOperation({ summary: 'Get current user earnings' }) + @ApiResponse({ status: 200, description: 'User earnings summary' }) + async getMyEarnings( + @CurrentTenant() tenantId: string, + @CurrentUser('id') userId: string, + ) { + return this.dashboardService.getMyEarnings(tenantId, userId); + } + + @Get('user/:userId/earnings') + @ApiOperation({ summary: 'Get specific user earnings' }) + @ApiResponse({ status: 200, description: 'User earnings summary' }) + async getUserEarnings( + @CurrentTenant() tenantId: string, + @Param('userId', ParseUUIDPipe) userId: string, + ) { + return this.dashboardService.getMyEarnings(tenantId, userId); + } + + @Get('scheme/:schemeId/performance') + @ApiOperation({ summary: 'Get scheme performance metrics' }) + @ApiResponse({ status: 200, description: 'Scheme performance' }) + async getSchemePerformance( + @CurrentTenant() tenantId: string, + @Param('schemeId', ParseUUIDPipe) schemeId: string, + ) { + return this.dashboardService.getSchemePerformance(tenantId, schemeId); + } +} diff --git a/apps/backend/src/modules/commissions/controllers/entries.controller.ts b/apps/backend/src/modules/commissions/controllers/entries.controller.ts new file mode 100644 index 00000000..4c42ddcb --- /dev/null +++ b/apps/backend/src/modules/commissions/controllers/entries.controller.ts @@ -0,0 +1,207 @@ +import { + Controller, + Get, + Post, + Patch, + Body, + Param, + Query, + ParseUUIDPipe, + UseGuards, +} from '@nestjs/common'; +import { ApiTags, ApiOperation, ApiResponse, ApiBearerAuth, ApiQuery } from '@nestjs/swagger'; +import { JwtAuthGuard } from '../../auth/guards/jwt-auth.guard'; +import { CurrentTenant } from '../../tenants/decorators/current-tenant.decorator'; +import { CurrentUser } from '../../auth/decorators/current-user.decorator'; +import { EntriesService, EntryFilters, PaginationOptions } from '../services/entries.service'; +import { CalculateCommissionDto, UpdateEntryStatusDto, BulkApproveDto, BulkRejectDto } from '../dto'; +import { EntryStatus } from '../entities/commission-entry.entity'; + +@ApiTags('Commissions - Entries') +@ApiBearerAuth() +@UseGuards(JwtAuthGuard) +@Controller('commissions/entries') +export class EntriesController { + constructor(private readonly entriesService: EntriesService) {} + + @Get() + @ApiOperation({ summary: 'Get all commission entries' }) + @ApiQuery({ name: 'page', required: false, type: Number }) + @ApiQuery({ name: 'limit', required: false, type: Number }) + @ApiQuery({ name: 'user_id', required: false, type: String }) + @ApiQuery({ name: 'scheme_id', required: false, type: String }) + @ApiQuery({ name: 'period_id', required: false, type: String }) + @ApiQuery({ name: 'status', required: false, enum: EntryStatus }) + @ApiQuery({ name: 'reference_type', required: false, type: String }) + @ApiQuery({ name: 'date_from', required: false, type: String }) + @ApiQuery({ name: 'date_to', required: false, type: String }) + @ApiQuery({ name: 'sortBy', required: false, type: String }) + @ApiQuery({ name: 'sortOrder', required: false, enum: ['ASC', 'DESC'] }) + async findAll( + @CurrentTenant() tenantId: string, + @Query('page') page?: number, + @Query('limit') limit?: number, + @Query('user_id') user_id?: string, + @Query('scheme_id') scheme_id?: string, + @Query('period_id') period_id?: string, + @Query('status') status?: EntryStatus, + @Query('reference_type') reference_type?: string, + @Query('date_from') date_from?: string, + @Query('date_to') date_to?: string, + @Query('sortBy') sortBy?: string, + @Query('sortOrder') sortOrder?: 'ASC' | 'DESC', + ) { + const filters: EntryFilters = { + user_id, + scheme_id, + period_id, + status, + reference_type, + date_from: date_from ? new Date(date_from) : undefined, + date_to: date_to ? new Date(date_to) : undefined, + }; + const pagination: PaginationOptions = { page, limit, sortBy, sortOrder }; + return this.entriesService.findAll(tenantId, filters, pagination); + } + + @Get('pending') + @ApiOperation({ summary: 'Get pending commission entries' }) + @ApiQuery({ name: 'page', required: false, type: Number }) + @ApiQuery({ name: 'limit', required: false, type: Number }) + async findPending( + @CurrentTenant() tenantId: string, + @Query('page') page?: number, + @Query('limit') limit?: number, + ) { + return this.entriesService.findAll( + tenantId, + { status: EntryStatus.PENDING }, + { page, limit, sortBy: 'created_at', sortOrder: 'ASC' }, + ); + } + + @Get(':id') + @ApiOperation({ summary: 'Get a commission entry by ID' }) + @ApiResponse({ status: 200, description: 'Entry found' }) + @ApiResponse({ status: 404, description: 'Entry not found' }) + async findOne( + @CurrentTenant() tenantId: string, + @Param('id', ParseUUIDPipe) id: string, + ) { + return this.entriesService.findOne(tenantId, id); + } + + @Get('user/:userId') + @ApiOperation({ summary: 'Get commission entries for a user' }) + @ApiQuery({ name: 'page', required: false, type: Number }) + @ApiQuery({ name: 'limit', required: false, type: Number }) + async findByUser( + @CurrentTenant() tenantId: string, + @Param('userId', ParseUUIDPipe) userId: string, + @Query('page') page?: number, + @Query('limit') limit?: number, + ) { + return this.entriesService.findByUser(tenantId, userId, { page, limit }); + } + + @Get('period/:periodId') + @ApiOperation({ summary: 'Get commission entries for a period' }) + @ApiQuery({ name: 'page', required: false, type: Number }) + @ApiQuery({ name: 'limit', required: false, type: Number }) + async findByPeriod( + @CurrentTenant() tenantId: string, + @Param('periodId', ParseUUIDPipe) periodId: string, + @Query('page') page?: number, + @Query('limit') limit?: number, + ) { + return this.entriesService.findByPeriod(tenantId, periodId, { page, limit }); + } + + @Get('reference/:type/:refId') + @ApiOperation({ summary: 'Get commission entry by reference' }) + async findByReference( + @CurrentTenant() tenantId: string, + @Param('type') referenceType: string, + @Param('refId', ParseUUIDPipe) referenceId: string, + ) { + const entry = await this.entriesService.findByReference(tenantId, referenceType, referenceId); + if (!entry) { + return { message: 'No commission entry found for this reference' }; + } + return entry; + } + + @Post('calculate') + @ApiOperation({ summary: 'Calculate and create a commission entry' }) + @ApiResponse({ status: 201, description: 'Commission calculated and entry created' }) + @ApiResponse({ status: 400, description: 'User has no active scheme or amount below threshold' }) + async calculate( + @CurrentTenant() tenantId: string, + @Body() dto: CalculateCommissionDto, + ) { + return this.entriesService.calculate(tenantId, dto, true); + } + + @Post('simulate') + @ApiOperation({ summary: 'Simulate commission calculation (no entry created)' }) + @ApiResponse({ status: 200, description: 'Commission calculated' }) + @ApiResponse({ status: 400, description: 'User has no active scheme' }) + async simulate( + @CurrentTenant() tenantId: string, + @Body() dto: CalculateCommissionDto, + ) { + return this.entriesService.calculate(tenantId, dto, false); + } + + @Patch(':id/status') + @ApiOperation({ summary: 'Update commission entry status' }) + @ApiResponse({ status: 200, description: 'Status updated' }) + @ApiResponse({ status: 400, description: 'Entry is finalized and cannot be modified' }) + @ApiResponse({ status: 404, description: 'Entry not found' }) + async updateStatus( + @CurrentTenant() tenantId: string, + @CurrentUser('id') userId: string, + @Param('id', ParseUUIDPipe) id: string, + @Body() dto: UpdateEntryStatusDto, + ) { + return this.entriesService.updateStatus(tenantId, id, dto, userId); + } + + @Post('bulk-approve') + @ApiOperation({ summary: 'Approve multiple commission entries' }) + @ApiResponse({ status: 200, description: 'Entries approved' }) + async bulkApprove( + @CurrentTenant() tenantId: string, + @CurrentUser('id') userId: string, + @Body() dto: BulkApproveDto, + ) { + return this.entriesService.bulkApprove(tenantId, dto.entry_ids, userId); + } + + @Post('bulk-reject') + @ApiOperation({ summary: 'Reject multiple commission entries' }) + @ApiResponse({ status: 200, description: 'Entries rejected' }) + async bulkReject( + @CurrentTenant() tenantId: string, + @Body() dto: BulkRejectDto, + ) { + return this.entriesService.bulkReject(tenantId, dto.entry_ids, dto.reason); + } + + @Post('cancel/:type/:refId') + @ApiOperation({ summary: 'Cancel commission entry by reference' }) + @ApiResponse({ status: 200, description: 'Entry cancelled' }) + @ApiResponse({ status: 400, description: 'Cannot cancel paid commission' }) + async cancelByReference( + @CurrentTenant() tenantId: string, + @Param('type') referenceType: string, + @Param('refId', ParseUUIDPipe) referenceId: string, + @Body('reason') reason?: string, + ) { + const entry = await this.entriesService.cancelByReference(tenantId, referenceType, referenceId, reason); + if (!entry) { + return { message: 'No commission entry found for this reference' }; + } + return entry; + } +} diff --git a/apps/backend/src/modules/commissions/controllers/index.ts b/apps/backend/src/modules/commissions/controllers/index.ts new file mode 100644 index 00000000..3f912aa5 --- /dev/null +++ b/apps/backend/src/modules/commissions/controllers/index.ts @@ -0,0 +1,5 @@ +export * from './schemes.controller'; +export * from './assignments.controller'; +export * from './entries.controller'; +export * from './periods.controller'; +export * from './dashboard.controller'; diff --git a/apps/backend/src/modules/commissions/controllers/periods.controller.ts b/apps/backend/src/modules/commissions/controllers/periods.controller.ts new file mode 100644 index 00000000..4d06593e --- /dev/null +++ b/apps/backend/src/modules/commissions/controllers/periods.controller.ts @@ -0,0 +1,154 @@ +import { + Controller, + Get, + Post, + Body, + Param, + Query, + ParseUUIDPipe, + UseGuards, +} from '@nestjs/common'; +import { ApiTags, ApiOperation, ApiResponse, ApiBearerAuth, ApiQuery } from '@nestjs/swagger'; +import { JwtAuthGuard } from '../../auth/guards/jwt-auth.guard'; +import { CurrentTenant } from '../../tenants/decorators/current-tenant.decorator'; +import { CurrentUser } from '../../auth/decorators/current-user.decorator'; +import { PeriodsService, PeriodFilters, PaginationOptions } from '../services/periods.service'; +import { CreatePeriodDto, MarkPeriodPaidDto } from '../dto'; +import { PeriodStatus } from '../entities/commission-period.entity'; + +@ApiTags('Commissions - Periods') +@ApiBearerAuth() +@UseGuards(JwtAuthGuard) +@Controller('commissions/periods') +export class PeriodsController { + constructor(private readonly periodsService: PeriodsService) {} + + @Get() + @ApiOperation({ summary: 'Get all commission periods' }) + @ApiQuery({ name: 'page', required: false, type: Number }) + @ApiQuery({ name: 'limit', required: false, type: Number }) + @ApiQuery({ name: 'status', required: false, enum: PeriodStatus }) + @ApiQuery({ name: 'date_from', required: false, type: String }) + @ApiQuery({ name: 'date_to', required: false, type: String }) + @ApiQuery({ name: 'sortBy', required: false, type: String }) + @ApiQuery({ name: 'sortOrder', required: false, enum: ['ASC', 'DESC'] }) + async findAll( + @CurrentTenant() tenantId: string, + @Query('page') page?: number, + @Query('limit') limit?: number, + @Query('status') status?: PeriodStatus, + @Query('date_from') date_from?: string, + @Query('date_to') date_to?: string, + @Query('sortBy') sortBy?: string, + @Query('sortOrder') sortOrder?: 'ASC' | 'DESC', + ) { + const filters: PeriodFilters = { + status, + date_from: date_from ? new Date(date_from) : undefined, + date_to: date_to ? new Date(date_to) : undefined, + }; + const pagination: PaginationOptions = { page, limit, sortBy, sortOrder }; + return this.periodsService.findAll(tenantId, filters, pagination); + } + + @Get('open') + @ApiOperation({ summary: 'Get current open period' }) + async getOpenPeriod(@CurrentTenant() tenantId: string) { + const period = await this.periodsService.getOpenPeriod(tenantId); + if (!period) { + return { message: 'No open commission period' }; + } + return period; + } + + @Get(':id') + @ApiOperation({ summary: 'Get a period by ID' }) + @ApiResponse({ status: 200, description: 'Period found' }) + @ApiResponse({ status: 404, description: 'Period not found' }) + async findOne( + @CurrentTenant() tenantId: string, + @Param('id', ParseUUIDPipe) id: string, + ) { + return this.periodsService.findOne(tenantId, id); + } + + @Get(':id/summary') + @ApiOperation({ summary: 'Get period summary with breakdown' }) + @ApiResponse({ status: 200, description: 'Period summary' }) + @ApiResponse({ status: 404, description: 'Period not found' }) + async getSummary( + @CurrentTenant() tenantId: string, + @Param('id', ParseUUIDPipe) id: string, + ) { + return this.periodsService.getSummary(tenantId, id); + } + + @Post() + @ApiOperation({ summary: 'Create a new commission period' }) + @ApiResponse({ status: 201, description: 'Period created' }) + @ApiResponse({ status: 400, description: 'Period dates overlap with existing' }) + async create( + @CurrentTenant() tenantId: string, + @CurrentUser('id') userId: string, + @Body() dto: CreatePeriodDto, + ) { + return this.periodsService.create(tenantId, dto, userId); + } + + @Post(':id/close') + @ApiOperation({ summary: 'Close a period' }) + @ApiResponse({ status: 200, description: 'Period closed' }) + @ApiResponse({ status: 400, description: 'Period cannot be closed' }) + @ApiResponse({ status: 404, description: 'Period not found' }) + async close( + @CurrentTenant() tenantId: string, + @CurrentUser('id') userId: string, + @Param('id', ParseUUIDPipe) id: string, + ) { + return this.periodsService.close(tenantId, id, userId); + } + + @Post(':id/reopen') + @ApiOperation({ summary: 'Reopen a closed period' }) + @ApiResponse({ status: 200, description: 'Period reopened' }) + @ApiResponse({ status: 400, description: 'Only closed periods can be reopened' }) + @ApiResponse({ status: 404, description: 'Period not found' }) + async reopen( + @CurrentTenant() tenantId: string, + @Param('id', ParseUUIDPipe) id: string, + ) { + return this.periodsService.reopen(tenantId, id); + } + + @Post(':id/processing') + @ApiOperation({ summary: 'Mark period as processing' }) + @ApiResponse({ status: 200, description: 'Period marked as processing' }) + @ApiResponse({ status: 400, description: 'Only closed periods can be marked as processing' }) + @ApiResponse({ status: 404, description: 'Period not found' }) + async markAsProcessing( + @CurrentTenant() tenantId: string, + @Param('id', ParseUUIDPipe) id: string, + ) { + return this.periodsService.markAsProcessing(tenantId, id); + } + + @Post(':id/pay') + @ApiOperation({ summary: 'Mark period as paid' }) + @ApiResponse({ status: 200, description: 'Period marked as paid' }) + @ApiResponse({ status: 400, description: 'Period not ready for payment' }) + @ApiResponse({ status: 404, description: 'Period not found' }) + async markAsPaid( + @CurrentTenant() tenantId: string, + @CurrentUser('id') userId: string, + @Param('id', ParseUUIDPipe) id: string, + @Body() dto: MarkPeriodPaidDto, + ) { + return this.periodsService.markAsPaid( + tenantId, + id, + userId, + dto.payment_reference, + dto.payment_notes, + ); + } +} diff --git a/apps/backend/src/modules/commissions/controllers/schemes.controller.ts b/apps/backend/src/modules/commissions/controllers/schemes.controller.ts new file mode 100644 index 00000000..56013d64 --- /dev/null +++ b/apps/backend/src/modules/commissions/controllers/schemes.controller.ts @@ -0,0 +1,127 @@ +import { + Controller, + Get, + Post, + Patch, + Delete, + Body, + Param, + Query, + ParseUUIDPipe, + UseGuards, +} from '@nestjs/common'; +import { ApiTags, ApiOperation, ApiResponse, ApiBearerAuth, ApiQuery } from '@nestjs/swagger'; +import { JwtAuthGuard } from '../../auth/guards/jwt-auth.guard'; +import { CurrentTenant } from '../../tenants/decorators/current-tenant.decorator'; +import { CurrentUser } from '../../auth/decorators/current-user.decorator'; +import { SchemesService, SchemeFilters, PaginationOptions } from '../services/schemes.service'; +import { CreateSchemeDto, UpdateSchemeDto } from '../dto'; +import { SchemeType } from '../entities/commission-scheme.entity'; + +@ApiTags('Commissions - Schemes') +@ApiBearerAuth() +@UseGuards(JwtAuthGuard) +@Controller('commissions/schemes') +export class SchemesController { + constructor(private readonly schemesService: SchemesService) {} + + @Get() + @ApiOperation({ summary: 'Get all commission schemes' }) + @ApiQuery({ name: 'page', required: false, type: Number }) + @ApiQuery({ name: 'limit', required: false, type: Number }) + @ApiQuery({ name: 'type', required: false, enum: SchemeType }) + @ApiQuery({ name: 'is_active', required: false, type: Boolean }) + @ApiQuery({ name: 'search', required: false, type: String }) + @ApiQuery({ name: 'sortBy', required: false, type: String }) + @ApiQuery({ name: 'sortOrder', required: false, enum: ['ASC', 'DESC'] }) + async findAll( + @CurrentTenant() tenantId: string, + @Query('page') page?: number, + @Query('limit') limit?: number, + @Query('type') type?: SchemeType, + @Query('is_active') is_active?: boolean, + @Query('search') search?: string, + @Query('sortBy') sortBy?: string, + @Query('sortOrder') sortOrder?: 'ASC' | 'DESC', + ) { + const filters: SchemeFilters = { type, is_active, search }; + const pagination: PaginationOptions = { page, limit, sortBy, sortOrder }; + return this.schemesService.findAll(tenantId, filters, pagination); + } + + @Get('active') + @ApiOperation({ summary: 'Get all active commission schemes' }) + async findActive(@CurrentTenant() tenantId: string) { + return this.schemesService.findActive(tenantId); + } + + @Get(':id') + @ApiOperation({ summary: 'Get a commission scheme by ID' }) + @ApiResponse({ status: 200, description: 'Scheme found' }) + @ApiResponse({ status: 404, description: 'Scheme not found' }) + async findOne( + @CurrentTenant() tenantId: string, + @Param('id', ParseUUIDPipe) id: string, + ) { + return this.schemesService.findOne(tenantId, id); + } + + @Post() + @ApiOperation({ summary: 'Create a new commission scheme' }) + @ApiResponse({ status: 201, description: 'Scheme created' }) + async create( + @CurrentTenant() tenantId: string, + @CurrentUser('id') userId: string, + @Body() dto: CreateSchemeDto, + ) { + return this.schemesService.create(tenantId, dto, userId); + } + + @Patch(':id') + @ApiOperation({ summary: 'Update a commission scheme' }) + @ApiResponse({ status: 200, description: 'Scheme updated' }) + @ApiResponse({ status: 404, description: 'Scheme not found' }) + async update( + @CurrentTenant() tenantId: string, + @Param('id', ParseUUIDPipe) id: string, + @Body() dto: UpdateSchemeDto, + ) { + return this.schemesService.update(tenantId, id, dto); + } + + @Delete(':id') + @ApiOperation({ summary: 'Delete a commission scheme (soft delete)' }) + @ApiResponse({ status: 200, description: 'Scheme deleted' }) + @ApiResponse({ status: 404, description: 'Scheme not found' }) + async remove( + @CurrentTenant() tenantId: string, + @Param('id', ParseUUIDPipe) id: string, + ) { + await this.schemesService.remove(tenantId, id); + return { message: 'Scheme deleted successfully' }; + } + + @Post(':id/duplicate') + @ApiOperation({ summary: 'Duplicate a commission scheme' }) + @ApiResponse({ status: 201, description: 'Scheme duplicated' }) + @ApiResponse({ status: 404, description: 'Scheme not found' }) + async duplicate( + @CurrentTenant() tenantId: string, + @CurrentUser('id') userId: string, + @Param('id', ParseUUIDPipe) id: string, + @Body('name') newName?: string, + ) { + return this.schemesService.duplicate(tenantId, id, newName, userId); + } + + @Post(':id/toggle-active') + @ApiOperation({ summary: 'Toggle scheme active status' }) + @ApiResponse({ status: 200, description: 'Scheme status toggled' }) + @ApiResponse({ status: 404, description: 'Scheme not found' }) + async toggleActive( + @CurrentTenant() tenantId: string, + @Param('id', ParseUUIDPipe) id: string, + ) { + return this.schemesService.toggleActive(tenantId, id); + } +} diff --git a/apps/backend/src/modules/commissions/dto/calculate-commission.dto.ts b/apps/backend/src/modules/commissions/dto/calculate-commission.dto.ts new file mode 100644 index 00000000..17adf041 --- /dev/null +++ b/apps/backend/src/modules/commissions/dto/calculate-commission.dto.ts @@ -0,0 +1,36 @@ +import { IsString, IsOptional, IsNumber, IsUUID, IsObject, Min } from 'class-validator'; +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; + +export class CalculateCommissionDto { + @ApiProperty({ description: 'User ID (salesperson who earned the commission)' }) + @IsUUID() + user_id: string; + + @ApiProperty({ description: 'Type of reference (e.g., "sale", "opportunity", "order")' }) + @IsString() + reference_type: string; + + @ApiProperty({ description: 'ID of the reference record' }) + @IsUUID() + reference_id: string; + + @ApiProperty({ description: 'Base amount to calculate commission from' }) + @IsNumber() + @Min(0) + base_amount: number; + + @ApiPropertyOptional({ description: 'Currency code', default: 'USD' }) + @IsOptional() + @IsString() + currency?: string; + + @ApiPropertyOptional({ description: 'Additional notes' }) + @IsOptional() + @IsString() + notes?: string; + + @ApiPropertyOptional({ description: 'Additional metadata (e.g., product info)' }) + @IsOptional() + @IsObject() + metadata?: Record; +} diff --git a/apps/backend/src/modules/commissions/dto/create-assignment.dto.ts b/apps/backend/src/modules/commissions/dto/create-assignment.dto.ts new file mode 100644 index 00000000..c8d094a3 --- /dev/null +++ b/apps/backend/src/modules/commissions/dto/create-assignment.dto.ts @@ -0,0 +1,43 @@ +import { + IsString, + IsOptional, + IsNumber, + IsBoolean, + IsDateString, + IsUUID, + Min, + Max, +} from 'class-validator'; +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; + +export class CreateAssignmentDto { + @ApiProperty({ description: 'User ID to assign the scheme to' }) + @IsUUID() + user_id: string; + + @ApiProperty({ description: 'Commission scheme ID' }) + @IsUUID() + scheme_id: string; + + @ApiPropertyOptional({ description: 'Assignment start date', default: 'Current timestamp' }) + @IsOptional() + @IsDateString() + starts_at?: Date; + + @ApiPropertyOptional({ description: 'Assignment end date (null for indefinite)' }) + @IsOptional() + @IsDateString() + ends_at?: Date; + + @ApiPropertyOptional({ description: 'Custom rate override for this user (0-100)' }) + @IsOptional() + @IsNumber() + @Min(0) + @Max(100) + custom_rate?: number; + + @ApiPropertyOptional({ description: 'Whether the assignment is active', default: true }) + @IsOptional() + @IsBoolean() + is_active?: boolean; +} diff --git a/apps/backend/src/modules/commissions/dto/create-period.dto.ts b/apps/backend/src/modules/commissions/dto/create-period.dto.ts new file mode 100644 index 00000000..f4a55ee2 --- /dev/null +++ b/apps/backend/src/modules/commissions/dto/create-period.dto.ts @@ -0,0 +1,33 @@ +import { IsString, IsDateString, IsOptional } from 'class-validator'; +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; + +export class CreatePeriodDto { + @ApiProperty({ description: 'Period name (e.g., "January 2026", "Week 1-2026")' }) + @IsString() + name: string; + + @ApiProperty({ description: 'Period start date' }) + @IsDateString() + starts_at: Date; + + @ApiProperty({ description: 'Period end date' }) + @IsDateString() + ends_at: Date; + + @ApiPropertyOptional({ description: 'Currency for the period', default: 'USD' }) + @IsOptional() + @IsString() + currency?: string; +} + +export class MarkPeriodPaidDto { + @ApiPropertyOptional({ description: 'Payment reference (e.g., transaction ID)' }) + @IsOptional() + @IsString() + payment_reference?: string; + + @ApiPropertyOptional({ description: 'Payment notes' }) + @IsOptional() + @IsString() + payment_notes?: string; +} diff --git a/apps/backend/src/modules/commissions/dto/create-scheme.dto.ts b/apps/backend/src/modules/commissions/dto/create-scheme.dto.ts new file mode 100644 index 00000000..96f12348 --- /dev/null +++ b/apps/backend/src/modules/commissions/dto/create-scheme.dto.ts @@ -0,0 +1,103 @@ +import { + IsString, + IsOptional, + IsEnum, + IsNumber, + IsBoolean, + IsArray, + ValidateNested, + Min, + Max, + IsUUID, +} from 'class-validator'; +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { Type } from 'class-transformer'; +import { SchemeType, AppliesTo, TierConfig } from '../entities/commission-scheme.entity'; + +export class TierConfigDto { + @ApiProperty({ description: 'Tier lower bound (inclusive)' }) + @IsNumber() + @Min(0) + from: number; + + @ApiPropertyOptional({ description: 'Tier upper bound (exclusive), null for unlimited' }) + @IsOptional() + @IsNumber() + @Min(0) + to: number | null; + + @ApiProperty({ description: 'Commission rate for this tier (0-100)' }) + @IsNumber() + @Min(0) + @Max(100) + rate: number; +} + +export class CreateSchemeDto { + @ApiProperty({ description: 'Scheme name' }) + @IsString() + name: string; + + @ApiPropertyOptional({ description: 'Scheme description' }) + @IsOptional() + @IsString() + description?: string; + + @ApiProperty({ enum: SchemeType, description: 'Commission calculation type', default: SchemeType.PERCENTAGE }) + @IsEnum(SchemeType) + type: SchemeType; + + @ApiPropertyOptional({ description: 'Commission rate for percentage type (0-100)', default: 0 }) + @IsOptional() + @IsNumber() + @Min(0) + @Max(100) + rate?: number; + + @ApiPropertyOptional({ description: 'Fixed commission amount', default: 0 }) + @IsOptional() + @IsNumber() + @Min(0) + fixed_amount?: number; + + @ApiPropertyOptional({ description: 'Tier configurations for tiered type', type: [TierConfigDto] }) + @IsOptional() + @IsArray() + @ValidateNested({ each: true }) + @Type(() => TierConfigDto) + tiers?: TierConfig[]; + + @ApiPropertyOptional({ enum: AppliesTo, description: 'What the commission applies to', default: AppliesTo.ALL }) + @IsOptional() + @IsEnum(AppliesTo) + applies_to?: AppliesTo; + + @ApiPropertyOptional({ description: 'Product IDs when applies_to is "products"', type: [String] }) + @IsOptional() + @IsArray() + @IsUUID('4', { each: true }) + product_ids?: string[]; + + @ApiPropertyOptional({ description: 'Category IDs when applies_to is "categories"', type: [String] }) + @IsOptional() + @IsArray() + @IsUUID('4', { each: true }) + category_ids?: string[]; + + @ApiPropertyOptional({ description: 'Minimum sale amount to qualify for commission', default: 0 }) + @IsOptional() + @IsNumber() + @Min(0) + min_amount?: number; + + @ApiPropertyOptional({ description: 'Maximum commission per sale (cap)' }) + @IsOptional() + @IsNumber() + @Min(0) + max_amount?: number; + + @ApiPropertyOptional({ description: 'Whether the scheme is active', default: true }) + @IsOptional() + @IsBoolean() + is_active?: boolean; +} diff --git a/apps/backend/src/modules/commissions/dto/index.ts b/apps/backend/src/modules/commissions/dto/index.ts new file mode 100644 index 00000000..22461f7b --- /dev/null +++ b/apps/backend/src/modules/commissions/dto/index.ts @@ -0,0 +1,7 @@ +export * from './create-scheme.dto'; +export * from './update-scheme.dto'; +export * from './create-assignment.dto'; +export * from './update-assignment.dto'; +export * from './calculate-commission.dto'; +export * from './update-entry-status.dto'; +export * from './create-period.dto'; diff --git a/apps/backend/src/modules/commissions/dto/update-assignment.dto.ts b/apps/backend/src/modules/commissions/dto/update-assignment.dto.ts new file mode 100644 index 00000000..5a266aab --- /dev/null +++ b/apps/backend/src/modules/commissions/dto/update-assignment.dto.ts @@ -0,0 +1,21 @@ +import { IsOptional, IsNumber, IsBoolean, IsDateString, Min, Max } from 'class-validator'; +import { ApiPropertyOptional } from '@nestjs/swagger'; + +export class UpdateAssignmentDto { + @ApiPropertyOptional({ description: 'Assignment end date' }) + @IsOptional() + @IsDateString() + ends_at?: Date; + + @ApiPropertyOptional({ description: 'Custom rate override (0-100)' }) + @IsOptional() + @IsNumber() + @Min(0) + @Max(100) + custom_rate?: number; + + @ApiPropertyOptional({ description: 'Whether the assignment is active' }) + @IsOptional() + @IsBoolean() + is_active?: boolean; +} diff --git a/apps/backend/src/modules/commissions/dto/update-entry-status.dto.ts b/apps/backend/src/modules/commissions/dto/update-entry-status.dto.ts new file mode 100644 index 00000000..d932c078 --- /dev/null +++ b/apps/backend/src/modules/commissions/dto/update-entry-status.dto.ts @@ -0,0 +1,31 @@ +import { IsEnum, IsOptional, IsString } from 'class-validator'; +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { EntryStatus } from '../entities/commission-entry.entity'; + +export class UpdateEntryStatusDto { + @ApiProperty({ enum: EntryStatus, description: 'New status for the commission entry' }) + @IsEnum(EntryStatus) + status: EntryStatus; + + @ApiPropertyOptional({ description: 'Notes about the status change (e.g., rejection reason)' }) + @IsOptional() + @IsString() + notes?: string; +} + +export class BulkApproveDto { + @ApiProperty({ description: 'Array of entry IDs to approve', type: [String] }) + @IsString({ each: true }) + entry_ids: string[]; +} + +export class BulkRejectDto { + @ApiProperty({ description: 'Array of entry IDs to reject', type: [String] }) + @IsString({ each: true }) + entry_ids: string[]; + + @ApiPropertyOptional({ description: 'Reason for rejection' }) + @IsOptional() + @IsString() + reason?: string; +} diff --git a/apps/backend/src/modules/commissions/dto/update-scheme.dto.ts b/apps/backend/src/modules/commissions/dto/update-scheme.dto.ts new file mode 100644 index 00000000..705d2c16 --- /dev/null +++ b/apps/backend/src/modules/commissions/dto/update-scheme.dto.ts @@ -0,0 +1,4 @@ +import { PartialType } from '@nestjs/swagger'; +import { CreateSchemeDto } from './create-scheme.dto'; + +export class UpdateSchemeDto extends PartialType(CreateSchemeDto) {} diff --git a/apps/backend/src/modules/commissions/entities/commission-assignment.entity.ts b/apps/backend/src/modules/commissions/entities/commission-assignment.entity.ts new file mode 100644 index 00000000..cb7bd91c --- /dev/null +++ b/apps/backend/src/modules/commissions/entities/commission-assignment.entity.ts @@ -0,0 +1,70 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + ManyToOne, + JoinColumn, +} from 'typeorm'; +import { User } from '../../auth/entities/user.entity'; +import { CommissionScheme } from './commission-scheme.entity'; + +@Entity({ name: 'assignments', schema: 'commissions' }) +export class CommissionAssignment { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ type: 'uuid' }) + tenant_id: string; + + @Column({ type: 'uuid' }) + user_id: string; + + @ManyToOne(() => User) + @JoinColumn({ name: 'user_id' }) + user?: User; + + @Column({ type: 'uuid' }) + scheme_id: string; + + @ManyToOne(() => CommissionScheme, (scheme) => scheme.assignments) + @JoinColumn({ name: 'scheme_id' }) + scheme?: CommissionScheme; + + @Column({ type: 'timestamp with time zone', default: () => 'NOW()' }) + starts_at: Date; + + @Column({ type: 'timestamp with time zone', nullable: true }) + ends_at: Date | null; + + @Column({ type: 'decimal', precision: 5, scale: 2, nullable: true }) + custom_rate: number | null; + + @Column({ type: 'boolean', default: true }) + is_active: boolean; + + @CreateDateColumn({ type: 'timestamp with time zone' }) + created_at: Date; + + @Column({ type: 'uuid', nullable: true }) + created_by: string | null; + + @ManyToOne(() => User, { nullable: true }) + @JoinColumn({ name: 'created_by' }) + creator?: User; + + // Computed: Check if assignment is currently active + get isCurrentlyActive(): boolean { + const now = new Date(); + return ( + this.is_active && + this.starts_at <= now && + (this.ends_at === null || this.ends_at > now) + ); + } + + // Computed: Get effective rate (custom or scheme default) + getEffectiveRate(schemeRate: number): number { + return this.custom_rate !== null ? Number(this.custom_rate) : schemeRate; + } +} diff --git a/apps/backend/src/modules/commissions/entities/commission-entry.entity.ts b/apps/backend/src/modules/commissions/entities/commission-entry.entity.ts new file mode 100644 index 00000000..dbe669ac --- /dev/null +++ b/apps/backend/src/modules/commissions/entities/commission-entry.entity.ts @@ -0,0 +1,126 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + ManyToOne, + JoinColumn, +} from 'typeorm'; +import { User } from '../../auth/entities/user.entity'; +import { CommissionScheme } from './commission-scheme.entity'; +import { CommissionAssignment } from './commission-assignment.entity'; +import { CommissionPeriod } from './commission-period.entity'; + +export enum EntryStatus { + PENDING = 'pending', + APPROVED = 'approved', + REJECTED = 'rejected', + PAID = 'paid', + CANCELLED = 'cancelled', +} + +@Entity({ name: 'entries', schema: 'commissions' }) +export class CommissionEntry { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ type: 'uuid' }) + tenant_id: string; + + @Column({ type: 'uuid' }) + user_id: string; + + @ManyToOne(() => User) + @JoinColumn({ name: 'user_id' }) + user?: User; + + @Column({ type: 'uuid' }) + scheme_id: string; + + @ManyToOne(() => CommissionScheme) + @JoinColumn({ name: 'scheme_id' }) + scheme?: CommissionScheme; + + @Column({ type: 'uuid', nullable: true }) + assignment_id: string | null; + + @ManyToOne(() => CommissionAssignment, { nullable: true }) + @JoinColumn({ name: 'assignment_id' }) + assignment?: CommissionAssignment; + + @Column({ type: 'varchar', length: 50 }) + reference_type: string; + + @Column({ type: 'uuid' }) + reference_id: string; + + @Column({ type: 'decimal', precision: 15, scale: 2 }) + base_amount: number; + + @Column({ type: 'decimal', precision: 5, scale: 2 }) + rate_applied: number; + + @Column({ type: 'decimal', precision: 15, scale: 2 }) + commission_amount: number; + + @Column({ type: 'varchar', length: 3, default: 'USD' }) + currency: string; + + @Column({ + type: 'enum', + enum: EntryStatus, + default: EntryStatus.PENDING, + }) + status: EntryStatus; + + @Column({ type: 'uuid', nullable: true }) + period_id: string | null; + + @ManyToOne(() => CommissionPeriod, { nullable: true }) + @JoinColumn({ name: 'period_id' }) + period?: CommissionPeriod; + + @Column({ type: 'timestamp with time zone', nullable: true }) + paid_at: Date | null; + + @Column({ type: 'varchar', length: 255, nullable: true }) + payment_reference: string | null; + + @Column({ type: 'text', nullable: true }) + notes: string | null; + + @Column({ type: 'jsonb', default: {} }) + metadata: Record; + + @CreateDateColumn({ type: 'timestamp with time zone' }) + created_at: Date; + + @UpdateDateColumn({ type: 'timestamp with time zone' }) + updated_at: Date; + + @Column({ type: 'uuid', nullable: true }) + approved_by: string | null; + + @ManyToOne(() => User, { nullable: true }) + @JoinColumn({ name: 'approved_by' }) + approver?: User; + + @Column({ type: 'timestamp with time zone', nullable: true }) + approved_at: Date | null; + + // Computed: Check if entry can be modified + get isModifiable(): boolean { + return this.status === EntryStatus.PENDING; + } + + // Computed: Check if entry can be approved + get isApprovable(): boolean { + return this.status === EntryStatus.PENDING; + } + + // Computed: Check if entry is finalized + get isFinalized(): boolean { + return [EntryStatus.PAID, EntryStatus.REJECTED, EntryStatus.CANCELLED].includes(this.status); + } +} diff --git a/apps/backend/src/modules/commissions/entities/commission-period.entity.ts b/apps/backend/src/modules/commissions/entities/commission-period.entity.ts new file mode 100644 index 00000000..dc17d846 --- /dev/null +++ b/apps/backend/src/modules/commissions/entities/commission-period.entity.ts @@ -0,0 +1,112 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + ManyToOne, + JoinColumn, + OneToMany, +} from 'typeorm'; +import { User } from '../../auth/entities/user.entity'; +import { CommissionEntry } from './commission-entry.entity'; + +export enum PeriodStatus { + OPEN = 'open', + CLOSED = 'closed', + PROCESSING = 'processing', + PAID = 'paid', +} + +@Entity({ name: 'periods', schema: 'commissions' }) +export class CommissionPeriod { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ type: 'uuid' }) + tenant_id: string; + + @Column({ type: 'varchar', length: 100 }) + name: string; + + @Column({ type: 'timestamp with time zone' }) + starts_at: Date; + + @Column({ type: 'timestamp with time zone' }) + ends_at: Date; + + @Column({ type: 'int', default: 0 }) + total_entries: number; + + @Column({ type: 'decimal', precision: 15, scale: 2, default: 0 }) + total_amount: number; + + @Column({ type: 'varchar', length: 3, default: 'USD' }) + currency: string; + + @Column({ + type: 'enum', + enum: PeriodStatus, + default: PeriodStatus.OPEN, + }) + status: PeriodStatus; + + @Column({ type: 'timestamp with time zone', nullable: true }) + closed_at: Date | null; + + @Column({ type: 'uuid', nullable: true }) + closed_by: string | null; + + @ManyToOne(() => User, { nullable: true }) + @JoinColumn({ name: 'closed_by' }) + closedByUser?: User; + + @Column({ type: 'timestamp with time zone', nullable: true }) + paid_at: Date | null; + + @Column({ type: 'uuid', nullable: true }) + paid_by: string | null; + + @ManyToOne(() => User, { nullable: true }) + @JoinColumn({ name: 'paid_by' }) + paidByUser?: User; + + @Column({ type: 'varchar', length: 255, nullable: true }) + payment_reference: string | null; + + @Column({ type: 'text', nullable: true }) + payment_notes: string | null; + + @CreateDateColumn({ type: 'timestamp with time zone' }) + created_at: Date; + + @Column({ type: 'uuid', nullable: true }) + created_by: string | null; + + @ManyToOne(() => User, { nullable: true }) + @JoinColumn({ name: 'created_by' }) + creator?: User; + + @OneToMany(() => CommissionEntry, (entry) => entry.period) + entries?: CommissionEntry[]; + + // Computed: Check if period is open for new entries + get isOpen(): boolean { + return this.status === PeriodStatus.OPEN; + } + + // Computed: Check if period can be closed + get isCloseable(): boolean { + return this.status === PeriodStatus.OPEN; + } + + // Computed: Check if period is ready for payment + get isPayable(): boolean { + return this.status === PeriodStatus.CLOSED || this.status === PeriodStatus.PROCESSING; + } + + // Computed: Calculate period duration in days + get durationDays(): number { + const diff = this.ends_at.getTime() - this.starts_at.getTime(); + return Math.ceil(diff / (1000 * 60 * 60 * 24)); + } +} diff --git a/apps/backend/src/modules/commissions/entities/commission-scheme.entity.ts b/apps/backend/src/modules/commissions/entities/commission-scheme.entity.ts new file mode 100644 index 00000000..f04eee77 --- /dev/null +++ b/apps/backend/src/modules/commissions/entities/commission-scheme.entity.ts @@ -0,0 +1,117 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + ManyToOne, + JoinColumn, + OneToMany, +} from 'typeorm'; +import { User } from '../../auth/entities/user.entity'; +import { CommissionAssignment } from './commission-assignment.entity'; + +export enum SchemeType { + PERCENTAGE = 'percentage', + FIXED = 'fixed', + TIERED = 'tiered', +} + +export enum AppliesTo { + ALL = 'all', + PRODUCTS = 'products', + CATEGORIES = 'categories', +} + +export interface TierConfig { + from: number; + to: number | null; + rate: number; +} + +@Entity({ name: 'schemes', schema: 'commissions' }) +export class CommissionScheme { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ type: 'uuid' }) + tenant_id: string; + + @Column({ type: 'varchar', length: 100 }) + name: string; + + @Column({ type: 'text', nullable: true }) + description: string | null; + + @Column({ + type: 'enum', + enum: SchemeType, + default: SchemeType.PERCENTAGE, + }) + type: SchemeType; + + @Column({ type: 'decimal', precision: 5, scale: 2, default: 0 }) + rate: number; + + @Column({ type: 'decimal', precision: 15, scale: 2, default: 0 }) + fixed_amount: number; + + @Column({ type: 'jsonb', default: [] }) + tiers: TierConfig[]; + + @Column({ + type: 'enum', + enum: AppliesTo, + default: AppliesTo.ALL, + }) + applies_to: AppliesTo; + + @Column({ type: 'uuid', array: true, default: '{}' }) + product_ids: string[]; + + @Column({ type: 'uuid', array: true, default: '{}' }) + category_ids: string[]; + + @Column({ type: 'decimal', precision: 15, scale: 2, default: 0 }) + min_amount: number; + + @Column({ type: 'decimal', precision: 15, scale: 2, nullable: true }) + max_amount: number | null; + + @Column({ type: 'boolean', default: true }) + is_active: boolean; + + @CreateDateColumn({ type: 'timestamp with time zone' }) + created_at: Date; + + @UpdateDateColumn({ type: 'timestamp with time zone' }) + updated_at: Date; + + @Column({ type: 'uuid', nullable: true }) + created_by: string | null; + + @ManyToOne(() => User, { nullable: true }) + @JoinColumn({ name: 'created_by' }) + creator?: User; + + @Column({ type: 'timestamp with time zone', nullable: true }) + deleted_at: Date | null; + + @OneToMany(() => CommissionAssignment, (assignment) => assignment.scheme) + assignments?: CommissionAssignment[]; + + // Computed: Get rate for a specific amount (useful for tiered schemes) + getRateForAmount(amount: number): number { + if (this.type !== SchemeType.TIERED || !this.tiers.length) { + return Number(this.rate); + } + + for (const tier of this.tiers) { + if (amount >= tier.from && (tier.to === null || amount < tier.to)) { + return tier.rate; + } + } + + return 0; + } +} diff --git a/apps/backend/src/modules/commissions/entities/index.ts b/apps/backend/src/modules/commissions/entities/index.ts new file mode 100644 index 00000000..fd575b8e --- /dev/null +++ b/apps/backend/src/modules/commissions/entities/index.ts @@ -0,0 +1,4 @@ +export * from './commission-scheme.entity'; +export * from './commission-assignment.entity'; +export * from './commission-entry.entity'; +export * from './commission-period.entity'; diff --git a/apps/backend/src/modules/commissions/services/assignments.service.ts b/apps/backend/src/modules/commissions/services/assignments.service.ts new file mode 100644 index 00000000..62125386 --- /dev/null +++ b/apps/backend/src/modules/commissions/services/assignments.service.ts @@ -0,0 +1,183 @@ +import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository, LessThanOrEqual, MoreThan, IsNull, Or } from 'typeorm'; +import { CommissionAssignment } from '../entities/commission-assignment.entity'; +import { CommissionScheme } from '../entities/commission-scheme.entity'; +import { CreateAssignmentDto, UpdateAssignmentDto } from '../dto'; + +export interface AssignmentFilters { + user_id?: string; + scheme_id?: string; + is_active?: boolean; +} + +export interface PaginationOptions { + page?: number; + limit?: number; + sortBy?: string; + sortOrder?: 'ASC' | 'DESC'; +} + +export interface PaginatedResult { + data: T[]; + total: number; + page: number; + limit: number; + totalPages: number; +} + +@Injectable() +export class AssignmentsService { + constructor( + @InjectRepository(CommissionAssignment) + private readonly assignmentRepository: Repository, + @InjectRepository(CommissionScheme) + private readonly schemeRepository: Repository, + ) {} + + async findAll( + tenantId: string, + filters: AssignmentFilters = {}, + pagination: PaginationOptions = {}, + ): Promise> { + const { page = 1, limit = 20, sortBy = 'created_at', sortOrder = 'DESC' } = pagination; + const skip = (page - 1) * limit; + + const queryBuilder = this.assignmentRepository.createQueryBuilder('assignment') + .leftJoinAndSelect('assignment.user', 'user') + .leftJoinAndSelect('assignment.scheme', 'scheme') + .where('assignment.tenant_id = :tenantId', { tenantId }); + + if (filters.user_id) { + queryBuilder.andWhere('assignment.user_id = :userId', { userId: filters.user_id }); + } + + if (filters.scheme_id) { + queryBuilder.andWhere('assignment.scheme_id = :schemeId', { schemeId: filters.scheme_id }); + } + + if (filters.is_active !== undefined) { + queryBuilder.andWhere('assignment.is_active = :isActive', { isActive: filters.is_active }); + } + + const [data, total] = await queryBuilder + .orderBy(`assignment.${sortBy}`, sortOrder) + .skip(skip) + .take(limit) + .getManyAndCount(); + + return { + data, + total, + page, + limit, + totalPages: Math.ceil(total / limit), + }; + } + + async findOne(tenantId: string, id: string): Promise { + const assignment = await this.assignmentRepository.findOne({ + where: { id, tenant_id: tenantId }, + relations: ['user', 'scheme', 'creator'], + }); + + if (!assignment) { + throw new NotFoundException(`Commission assignment with ID ${id} not found`); + } + + return assignment; + } + + async findByUser(tenantId: string, userId: string): Promise { + return this.assignmentRepository.find({ + where: { + tenant_id: tenantId, + user_id: userId, + is_active: true, + }, + relations: ['scheme'], + order: { starts_at: 'DESC' }, + }); + } + + async getActiveScheme(tenantId: string, userId: string): Promise { + const now = new Date(); + + return this.assignmentRepository.findOne({ + where: { + tenant_id: tenantId, + user_id: userId, + is_active: true, + starts_at: LessThanOrEqual(now), + ends_at: Or(IsNull(), MoreThan(now)), + }, + relations: ['scheme'], + order: { starts_at: 'DESC' }, + }); + } + + async assign(tenantId: string, dto: CreateAssignmentDto, createdBy?: string): Promise { + // Verify scheme exists and is active + const scheme = await this.schemeRepository.findOne({ + where: { id: dto.scheme_id, tenant_id: tenantId, is_active: true, deleted_at: undefined }, + }); + + if (!scheme) { + throw new BadRequestException('Invalid or inactive commission scheme'); + } + + // Check for overlapping active assignments + const existingAssignment = await this.getActiveScheme(tenantId, dto.user_id); + if (existingAssignment && existingAssignment.scheme_id === dto.scheme_id) { + throw new BadRequestException('User already has an active assignment for this scheme'); + } + + const assignment = this.assignmentRepository.create({ + ...dto, + tenant_id: tenantId, + created_by: createdBy, + }); + + return this.assignmentRepository.save(assignment); + } + + async update(tenantId: string, id: string, dto: UpdateAssignmentDto): Promise { + const assignment = await this.findOne(tenantId, id); + + if (dto.ends_at && dto.ends_at <= assignment.starts_at) { + throw new BadRequestException('End date must be after start date'); + } + + Object.assign(assignment, dto); + return this.assignmentRepository.save(assignment); + } + + async remove(tenantId: string, id: string): Promise { + const assignment = await this.findOne(tenantId, id); + assignment.is_active = false; + assignment.ends_at = new Date(); + await this.assignmentRepository.save(assignment); + } + + async deactivate(tenantId: string, id: string): Promise { + const assignment = await this.findOne(tenantId, id); + assignment.is_active = false; + assignment.ends_at = new Date(); + return this.assignmentRepository.save(assignment); + } + + async getSchemeAssignees(tenantId: string, schemeId: string): Promise { + const now = new Date(); + + return this.assignmentRepository.find({ + where: { + tenant_id: tenantId, + scheme_id: schemeId, + is_active: true, + starts_at: LessThanOrEqual(now), + ends_at: Or(IsNull(), MoreThan(now)), + }, + relations: ['user'], + }); + } +} diff --git a/apps/backend/src/modules/commissions/services/commissions-dashboard.service.ts b/apps/backend/src/modules/commissions/services/commissions-dashboard.service.ts new file mode 100644 index 00000000..375a8d86 --- /dev/null +++ b/apps/backend/src/modules/commissions/services/commissions-dashboard.service.ts @@ -0,0 +1,371 @@ +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository, Between, MoreThanOrEqual, LessThanOrEqual } from 'typeorm'; +import { CommissionEntry, EntryStatus } from '../entities/commission-entry.entity'; +import { CommissionPeriod, PeriodStatus } from '../entities/commission-period.entity'; +import { CommissionScheme } from '../entities/commission-scheme.entity'; +import { CommissionAssignment } from '../entities/commission-assignment.entity'; + +export interface DashboardSummary { + total_pending: number; + total_approved: number; + total_paid: number; + pending_count: number; + approved_count: number; + paid_count: number; + active_schemes: number; + active_assignments: number; + open_periods: number; + currency: string; +} + +export interface UserEarnings { + user_id: string; + user_name: string; + user_email: string; + pending: number; + approved: number; + paid: number; + total: number; + entries_count: number; +} + +export interface PeriodEarnings { + period_id: string; + period_name: string; + starts_at: Date; + ends_at: Date; + status: PeriodStatus; + total_amount: number; + entries_count: number; +} + +export interface MyEarnings { + pending: number; + approved: number; + paid: number; + total: number; + current_period_earnings: number; + last_paid_date: Date | null; + entries_count: number; + recent_entries: CommissionEntry[]; +} + +export interface TopEarner { + user_id: string; + user_name: string; + total_earned: number; + entries_count: number; + rank: number; +} + +@Injectable() +export class CommissionsDashboardService { + constructor( + @InjectRepository(CommissionEntry) + private readonly entryRepository: Repository, + @InjectRepository(CommissionPeriod) + private readonly periodRepository: Repository, + @InjectRepository(CommissionScheme) + private readonly schemeRepository: Repository, + @InjectRepository(CommissionAssignment) + private readonly assignmentRepository: Repository, + ) {} + + async getSummary(tenantId: string): Promise { + // Get entry stats by status + const entryStats = await this.entryRepository + .createQueryBuilder('entry') + .select('entry.status', 'status') + .addSelect('COUNT(*)', 'count') + .addSelect('COALESCE(SUM(entry.commission_amount), 0)', 'total') + .where('entry.tenant_id = :tenantId', { tenantId }) + .groupBy('entry.status') + .getRawMany(); + + // Count active schemes + const activeSchemes = await this.schemeRepository.count({ + where: { tenant_id: tenantId, is_active: true, deleted_at: undefined }, + }); + + // Count active assignments + const now = new Date(); + const activeAssignments = await this.assignmentRepository + .createQueryBuilder('assignment') + .where('assignment.tenant_id = :tenantId', { tenantId }) + .andWhere('assignment.is_active = true') + .andWhere('assignment.starts_at <= :now', { now }) + .andWhere('(assignment.ends_at IS NULL OR assignment.ends_at > :now)', { now }) + .getCount(); + + // Count open periods + const openPeriods = await this.periodRepository.count({ + where: { tenant_id: tenantId, status: PeriodStatus.OPEN }, + }); + + const summary: DashboardSummary = { + total_pending: 0, + total_approved: 0, + total_paid: 0, + pending_count: 0, + approved_count: 0, + paid_count: 0, + active_schemes: activeSchemes, + active_assignments: activeAssignments, + open_periods: openPeriods, + currency: 'USD', + }; + + entryStats.forEach((stat) => { + const count = parseInt(stat.count); + const total = parseFloat(stat.total); + + switch (stat.status) { + case EntryStatus.PENDING: + summary.total_pending = total; + summary.pending_count = count; + break; + case EntryStatus.APPROVED: + summary.total_approved = total; + summary.approved_count = count; + break; + case EntryStatus.PAID: + summary.total_paid = total; + summary.paid_count = count; + break; + } + }); + + return summary; + } + + async getEarningsByUser( + tenantId: string, + dateFrom?: Date, + dateTo?: Date, + ): Promise { + const queryBuilder = this.entryRepository + .createQueryBuilder('entry') + .leftJoin('entry.user', 'user') + .select('entry.user_id', 'user_id') + .addSelect("CONCAT(user.first_name, ' ', user.last_name)", 'user_name') + .addSelect('user.email', 'user_email') + .addSelect("COALESCE(SUM(CASE WHEN entry.status = 'pending' THEN entry.commission_amount ELSE 0 END), 0)", 'pending') + .addSelect("COALESCE(SUM(CASE WHEN entry.status = 'approved' THEN entry.commission_amount ELSE 0 END), 0)", 'approved') + .addSelect("COALESCE(SUM(CASE WHEN entry.status = 'paid' THEN entry.commission_amount ELSE 0 END), 0)", 'paid') + .addSelect('COUNT(*)', 'entries_count') + .where('entry.tenant_id = :tenantId', { tenantId }) + .andWhere('entry.status NOT IN (:...excluded)', { excluded: [EntryStatus.REJECTED, EntryStatus.CANCELLED] }); + + if (dateFrom) { + queryBuilder.andWhere('entry.created_at >= :dateFrom', { dateFrom }); + } + + if (dateTo) { + queryBuilder.andWhere('entry.created_at <= :dateTo', { dateTo }); + } + + const results = await queryBuilder + .groupBy('entry.user_id') + .addGroupBy('user.first_name') + .addGroupBy('user.last_name') + .addGroupBy('user.email') + .orderBy('paid', 'DESC') + .getRawMany(); + + return results.map((row) => ({ + user_id: row.user_id, + user_name: row.user_name || 'Unknown', + user_email: row.user_email || '', + pending: parseFloat(row.pending), + approved: parseFloat(row.approved), + paid: parseFloat(row.paid), + total: parseFloat(row.pending) + parseFloat(row.approved) + parseFloat(row.paid), + entries_count: parseInt(row.entries_count), + })); + } + + async getEarningsByPeriod(tenantId: string): Promise { + const periods = await this.periodRepository.find({ + where: { tenant_id: tenantId }, + order: { starts_at: 'DESC' }, + take: 12, // Last 12 periods + }); + + const results: PeriodEarnings[] = []; + + for (const period of periods) { + const stats = await this.entryRepository + .createQueryBuilder('entry') + .select('COUNT(*)', 'count') + .addSelect('COALESCE(SUM(entry.commission_amount), 0)', 'total') + .where('entry.period_id = :periodId', { periodId: period.id }) + .andWhere('entry.status NOT IN (:...excluded)', { excluded: [EntryStatus.REJECTED, EntryStatus.CANCELLED] }) + .getRawOne(); + + results.push({ + period_id: period.id, + period_name: period.name, + starts_at: period.starts_at, + ends_at: period.ends_at, + status: period.status, + total_amount: parseFloat(stats.total) || 0, + entries_count: parseInt(stats.count) || 0, + }); + } + + return results; + } + + async getTopEarners( + tenantId: string, + limit: number = 10, + dateFrom?: Date, + dateTo?: Date, + ): Promise { + const queryBuilder = this.entryRepository + .createQueryBuilder('entry') + .leftJoin('entry.user', 'user') + .select('entry.user_id', 'user_id') + .addSelect("CONCAT(user.first_name, ' ', user.last_name)", 'user_name') + .addSelect('COALESCE(SUM(entry.commission_amount), 0)', 'total_earned') + .addSelect('COUNT(*)', 'entries_count') + .where('entry.tenant_id = :tenantId', { tenantId }) + .andWhere('entry.status NOT IN (:...excluded)', { excluded: [EntryStatus.REJECTED, EntryStatus.CANCELLED] }); + + if (dateFrom) { + queryBuilder.andWhere('entry.created_at >= :dateFrom', { dateFrom }); + } + + if (dateTo) { + queryBuilder.andWhere('entry.created_at <= :dateTo', { dateTo }); + } + + const results = await queryBuilder + .groupBy('entry.user_id') + .addGroupBy('user.first_name') + .addGroupBy('user.last_name') + .orderBy('total_earned', 'DESC') + .limit(limit) + .getRawMany(); + + return results.map((row, index) => ({ + user_id: row.user_id, + user_name: row.user_name || 'Unknown', + total_earned: parseFloat(row.total_earned), + entries_count: parseInt(row.entries_count), + rank: index + 1, + })); + } + + async getMyEarnings(tenantId: string, userId: string): Promise { + // Get totals by status + const totals = await this.entryRepository + .createQueryBuilder('entry') + .select('entry.status', 'status') + .addSelect('COALESCE(SUM(entry.commission_amount), 0)', 'total') + .addSelect('COUNT(*)', 'count') + .where('entry.tenant_id = :tenantId', { tenantId }) + .andWhere('entry.user_id = :userId', { userId }) + .groupBy('entry.status') + .getRawMany(); + + // Get current period earnings + const openPeriod = await this.periodRepository.findOne({ + where: { tenant_id: tenantId, status: PeriodStatus.OPEN }, + }); + + let currentPeriodEarnings = 0; + if (openPeriod) { + const periodStats = await this.entryRepository + .createQueryBuilder('entry') + .select('COALESCE(SUM(entry.commission_amount), 0)', 'total') + .where('entry.period_id = :periodId', { periodId: openPeriod.id }) + .andWhere('entry.user_id = :userId', { userId }) + .andWhere('entry.status NOT IN (:...excluded)', { excluded: [EntryStatus.REJECTED, EntryStatus.CANCELLED] }) + .getRawOne(); + + currentPeriodEarnings = parseFloat(periodStats.total) || 0; + } + + // Get last paid date + const lastPaid = await this.entryRepository.findOne({ + where: { tenant_id: tenantId, user_id: userId, status: EntryStatus.PAID }, + order: { paid_at: 'DESC' }, + }); + + // Get recent entries + const recentEntries = await this.entryRepository.find({ + where: { tenant_id: tenantId, user_id: userId }, + relations: ['scheme'], + order: { created_at: 'DESC' }, + take: 10, + }); + + const result: MyEarnings = { + pending: 0, + approved: 0, + paid: 0, + total: 0, + current_period_earnings: currentPeriodEarnings, + last_paid_date: lastPaid?.paid_at || null, + entries_count: 0, + recent_entries: recentEntries, + }; + + totals.forEach((row) => { + const total = parseFloat(row.total); + const count = parseInt(row.count); + + switch (row.status) { + case EntryStatus.PENDING: + result.pending = total; + result.entries_count += count; + break; + case EntryStatus.APPROVED: + result.approved = total; + result.entries_count += count; + break; + case EntryStatus.PAID: + result.paid = total; + result.entries_count += count; + break; + } + }); + + result.total = result.pending + result.approved + result.paid; + + return result; + } + + async getSchemePerformance( + tenantId: string, + schemeId: string, + ): Promise<{ + total_generated: number; + total_paid: number; + entries_count: number; + avg_commission: number; + active_users: number; + }> { + const stats = await this.entryRepository + .createQueryBuilder('entry') + .select('COUNT(*)', 'count') + .addSelect('COALESCE(SUM(entry.commission_amount), 0)', 'total') + .addSelect("COALESCE(SUM(CASE WHEN entry.status = 'paid' THEN entry.commission_amount ELSE 0 END), 0)", 'paid') + .addSelect('COALESCE(AVG(entry.commission_amount), 0)', 'avg') + .addSelect('COUNT(DISTINCT entry.user_id)', 'users') + .where('entry.tenant_id = :tenantId', { tenantId }) + .andWhere('entry.scheme_id = :schemeId', { schemeId }) + .andWhere('entry.status NOT IN (:...excluded)', { excluded: [EntryStatus.REJECTED, EntryStatus.CANCELLED] }) + .getRawOne(); + + return { + total_generated: parseFloat(stats.total) || 0, + total_paid: parseFloat(stats.paid) || 0, + entries_count: parseInt(stats.count) || 0, + avg_commission: parseFloat(stats.avg) || 0, + active_users: parseInt(stats.users) || 0, + }; + } +} diff --git a/apps/backend/src/modules/commissions/services/entries.service.ts b/apps/backend/src/modules/commissions/services/entries.service.ts new file mode 100644 index 00000000..46504f0b --- /dev/null +++ b/apps/backend/src/modules/commissions/services/entries.service.ts @@ -0,0 +1,357 @@ +import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository, In } from 'typeorm'; +import { CommissionEntry, EntryStatus } from '../entities/commission-entry.entity'; +import { CommissionScheme, SchemeType } from '../entities/commission-scheme.entity'; +import { CommissionAssignment } from '../entities/commission-assignment.entity'; +import { CommissionPeriod, PeriodStatus } from '../entities/commission-period.entity'; +import { AssignmentsService } from './assignments.service'; +import { CalculateCommissionDto, UpdateEntryStatusDto } from '../dto'; + +export interface EntryFilters { + user_id?: string; + scheme_id?: string; + period_id?: string; + status?: EntryStatus; + reference_type?: string; + date_from?: Date; + date_to?: Date; +} + +export interface PaginationOptions { + page?: number; + limit?: number; + sortBy?: string; + sortOrder?: 'ASC' | 'DESC'; +} + +export interface PaginatedResult { + data: T[]; + total: number; + page: number; + limit: number; + totalPages: number; +} + +export interface CalculationResult { + rate_applied: number; + commission_amount: number; + scheme_id: string; + assignment_id: string | null; +} + +@Injectable() +export class EntriesService { + constructor( + @InjectRepository(CommissionEntry) + private readonly entryRepository: Repository, + @InjectRepository(CommissionScheme) + private readonly schemeRepository: Repository, + @InjectRepository(CommissionPeriod) + private readonly periodRepository: Repository, + private readonly assignmentsService: AssignmentsService, + ) {} + + async findAll( + tenantId: string, + filters: EntryFilters = {}, + pagination: PaginationOptions = {}, + ): Promise> { + const { page = 1, limit = 20, sortBy = 'created_at', sortOrder = 'DESC' } = pagination; + const skip = (page - 1) * limit; + + const queryBuilder = this.entryRepository.createQueryBuilder('entry') + .leftJoinAndSelect('entry.user', 'user') + .leftJoinAndSelect('entry.scheme', 'scheme') + .leftJoinAndSelect('entry.period', 'period') + .where('entry.tenant_id = :tenantId', { tenantId }); + + if (filters.user_id) { + queryBuilder.andWhere('entry.user_id = :userId', { userId: filters.user_id }); + } + + if (filters.scheme_id) { + queryBuilder.andWhere('entry.scheme_id = :schemeId', { schemeId: filters.scheme_id }); + } + + if (filters.period_id) { + queryBuilder.andWhere('entry.period_id = :periodId', { periodId: filters.period_id }); + } + + if (filters.status) { + queryBuilder.andWhere('entry.status = :status', { status: filters.status }); + } + + if (filters.reference_type) { + queryBuilder.andWhere('entry.reference_type = :refType', { refType: filters.reference_type }); + } + + if (filters.date_from) { + queryBuilder.andWhere('entry.created_at >= :dateFrom', { dateFrom: filters.date_from }); + } + + if (filters.date_to) { + queryBuilder.andWhere('entry.created_at <= :dateTo', { dateTo: filters.date_to }); + } + + const [data, total] = await queryBuilder + .orderBy(`entry.${sortBy}`, sortOrder) + .skip(skip) + .take(limit) + .getManyAndCount(); + + return { + data, + total, + page, + limit, + totalPages: Math.ceil(total / limit), + }; + } + + async findOne(tenantId: string, id: string): Promise { + const entry = await this.entryRepository.findOne({ + where: { id, tenant_id: tenantId }, + relations: ['user', 'scheme', 'period', 'approver'], + }); + + if (!entry) { + throw new NotFoundException(`Commission entry with ID ${id} not found`); + } + + return entry; + } + + async findByUser( + tenantId: string, + userId: string, + pagination: PaginationOptions = {}, + ): Promise> { + return this.findAll(tenantId, { user_id: userId }, pagination); + } + + async findByPeriod( + tenantId: string, + periodId: string, + pagination: PaginationOptions = {}, + ): Promise> { + return this.findAll(tenantId, { period_id: periodId }, pagination); + } + + async calculate( + tenantId: string, + dto: CalculateCommissionDto, + createEntry: boolean = true, + ): Promise { + // Get user's active assignment + const assignment = await this.assignmentsService.getActiveScheme(tenantId, dto.user_id); + + if (!assignment || !assignment.scheme) { + throw new BadRequestException('User does not have an active commission scheme assigned'); + } + + const scheme = assignment.scheme; + + // Check minimum amount threshold + if (dto.base_amount < Number(scheme.min_amount)) { + throw new BadRequestException(`Amount ${dto.base_amount} is below minimum threshold of ${scheme.min_amount}`); + } + + // Calculate rate and commission + let rate: number; + let commissionAmount: number; + + switch (scheme.type) { + case SchemeType.PERCENTAGE: + rate = assignment.custom_rate !== null ? Number(assignment.custom_rate) : Number(scheme.rate); + commissionAmount = dto.base_amount * (rate / 100); + break; + + case SchemeType.FIXED: + rate = 0; + commissionAmount = Number(scheme.fixed_amount); + break; + + case SchemeType.TIERED: + rate = scheme.getRateForAmount(dto.base_amount); + if (assignment.custom_rate !== null) { + rate = Number(assignment.custom_rate); + } + commissionAmount = dto.base_amount * (rate / 100); + break; + + default: + throw new BadRequestException('Invalid scheme type'); + } + + // Apply maximum cap + if (scheme.max_amount !== null && commissionAmount > Number(scheme.max_amount)) { + commissionAmount = Number(scheme.max_amount); + } + + commissionAmount = Math.round(commissionAmount * 100) / 100; + + if (!createEntry) { + return { + rate_applied: rate, + commission_amount: commissionAmount, + scheme_id: scheme.id, + assignment_id: assignment.id, + }; + } + + // Get current open period + const openPeriod = await this.periodRepository.findOne({ + where: { tenant_id: tenantId, status: PeriodStatus.OPEN }, + order: { starts_at: 'DESC' }, + }); + + // Create entry + const entry = this.entryRepository.create({ + tenant_id: tenantId, + user_id: dto.user_id, + scheme_id: scheme.id, + assignment_id: assignment.id, + reference_type: dto.reference_type, + reference_id: dto.reference_id, + base_amount: dto.base_amount, + rate_applied: rate, + commission_amount: commissionAmount, + currency: dto.currency || 'USD', + status: EntryStatus.PENDING, + period_id: openPeriod?.id || null, + notes: dto.notes, + metadata: dto.metadata || {}, + }); + + return this.entryRepository.save(entry); + } + + async updateStatus( + tenantId: string, + id: string, + dto: UpdateEntryStatusDto, + approvedBy?: string, + ): Promise { + const entry = await this.findOne(tenantId, id); + + if (entry.isFinalized) { + throw new BadRequestException('Cannot modify a finalized commission entry'); + } + + entry.status = dto.status; + + if (dto.status === EntryStatus.APPROVED && approvedBy) { + entry.approved_by = approvedBy; + entry.approved_at = new Date(); + } + + if (dto.notes) { + entry.notes = entry.notes ? `${entry.notes}\n${dto.notes}` : dto.notes; + } + + return this.entryRepository.save(entry); + } + + async bulkApprove( + tenantId: string, + entryIds: string[], + approvedBy: string, + ): Promise<{ approved: number; failed: number }> { + let approved = 0; + let failed = 0; + + for (const id of entryIds) { + try { + const entry = await this.entryRepository.findOne({ + where: { id, tenant_id: tenantId, status: EntryStatus.PENDING }, + }); + + if (entry) { + entry.status = EntryStatus.APPROVED; + entry.approved_by = approvedBy; + entry.approved_at = new Date(); + await this.entryRepository.save(entry); + approved++; + } else { + failed++; + } + } catch { + failed++; + } + } + + return { approved, failed }; + } + + async bulkReject( + tenantId: string, + entryIds: string[], + reason?: string, + ): Promise<{ rejected: number; failed: number }> { + let rejected = 0; + let failed = 0; + + for (const id of entryIds) { + try { + const entry = await this.entryRepository.findOne({ + where: { id, tenant_id: tenantId, status: EntryStatus.PENDING }, + }); + + if (entry) { + entry.status = EntryStatus.REJECTED; + if (reason) { + entry.notes = entry.notes ? `${entry.notes}\n[Rejected] ${reason}` : `[Rejected] ${reason}`; + } + await this.entryRepository.save(entry); + rejected++; + } else { + failed++; + } + } catch { + failed++; + } + } + + return { rejected, failed }; + } + + async findByReference( + tenantId: string, + referenceType: string, + referenceId: string, + ): Promise { + return this.entryRepository.findOne({ + where: { + tenant_id: tenantId, + reference_type: referenceType, + reference_id: referenceId, + }, + relations: ['user', 'scheme'], + }); + } + + async cancelByReference( + tenantId: string, + referenceType: string, + referenceId: string, + reason?: string, + ): Promise { + const entry = await this.findByReference(tenantId, referenceType, referenceId); + + if (!entry) { + return null; + } + + if (entry.status === EntryStatus.PAID) { + throw new BadRequestException('Cannot cancel a paid commission'); + } + + entry.status = EntryStatus.CANCELLED; + if (reason) { + entry.notes = entry.notes ? `${entry.notes}\n[Cancelled] ${reason}` : `[Cancelled] ${reason}`; + } + + return this.entryRepository.save(entry); + } +} diff --git a/apps/backend/src/modules/commissions/services/index.ts b/apps/backend/src/modules/commissions/services/index.ts new file mode 100644 index 00000000..6d48e80f --- /dev/null +++ b/apps/backend/src/modules/commissions/services/index.ts @@ -0,0 +1,5 @@ +export * from './schemes.service'; +export * from './assignments.service'; +export * from './entries.service'; +export * from './periods.service'; +export * from './commissions-dashboard.service'; diff --git a/apps/backend/src/modules/commissions/services/periods.service.ts b/apps/backend/src/modules/commissions/services/periods.service.ts new file mode 100644 index 00000000..e3f3d4db --- /dev/null +++ b/apps/backend/src/modules/commissions/services/periods.service.ts @@ -0,0 +1,288 @@ +import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { CommissionPeriod, PeriodStatus } from '../entities/commission-period.entity'; +import { CommissionEntry, EntryStatus } from '../entities/commission-entry.entity'; +import { CreatePeriodDto } from '../dto'; + +export interface PeriodFilters { + status?: PeriodStatus; + date_from?: Date; + date_to?: Date; +} + +export interface PaginationOptions { + page?: number; + limit?: number; + sortBy?: string; + sortOrder?: 'ASC' | 'DESC'; +} + +export interface PaginatedResult { + data: T[]; + total: number; + page: number; + limit: number; + totalPages: number; +} + +export interface PeriodSummary { + total_entries: number; + total_amount: number; + by_status: { + pending: number; + approved: number; + rejected: number; + paid: number; + cancelled: number; + }; + by_user: Array<{ + user_id: string; + user_name: string; + entries_count: number; + total_amount: number; + }>; +} + +@Injectable() +export class PeriodsService { + constructor( + @InjectRepository(CommissionPeriod) + private readonly periodRepository: Repository, + @InjectRepository(CommissionEntry) + private readonly entryRepository: Repository, + ) {} + + async findAll( + tenantId: string, + filters: PeriodFilters = {}, + pagination: PaginationOptions = {}, + ): Promise> { + const { page = 1, limit = 20, sortBy = 'starts_at', sortOrder = 'DESC' } = pagination; + const skip = (page - 1) * limit; + + const queryBuilder = this.periodRepository.createQueryBuilder('period') + .leftJoinAndSelect('period.creator', 'creator') + .where('period.tenant_id = :tenantId', { tenantId }); + + if (filters.status) { + queryBuilder.andWhere('period.status = :status', { status: filters.status }); + } + + if (filters.date_from) { + queryBuilder.andWhere('period.starts_at >= :dateFrom', { dateFrom: filters.date_from }); + } + + if (filters.date_to) { + queryBuilder.andWhere('period.ends_at <= :dateTo', { dateTo: filters.date_to }); + } + + const [data, total] = await queryBuilder + .orderBy(`period.${sortBy}`, sortOrder) + .skip(skip) + .take(limit) + .getManyAndCount(); + + return { + data, + total, + page, + limit, + totalPages: Math.ceil(total / limit), + }; + } + + async findOne(tenantId: string, id: string): Promise { + const period = await this.periodRepository.findOne({ + where: { id, tenant_id: tenantId }, + relations: ['creator', 'closedByUser', 'paidByUser'], + }); + + if (!period) { + throw new NotFoundException(`Commission period with ID ${id} not found`); + } + + return period; + } + + async getOpenPeriod(tenantId: string): Promise { + return this.periodRepository.findOne({ + where: { tenant_id: tenantId, status: PeriodStatus.OPEN }, + order: { starts_at: 'DESC' }, + }); + } + + async create(tenantId: string, dto: CreatePeriodDto, createdBy?: string): Promise { + // Check for overlapping periods + const overlapping = await this.periodRepository + .createQueryBuilder('period') + .where('period.tenant_id = :tenantId', { tenantId }) + .andWhere( + '(period.starts_at < :endsAt AND period.ends_at > :startsAt)', + { startsAt: dto.starts_at, endsAt: dto.ends_at }, + ) + .getOne(); + + if (overlapping) { + throw new BadRequestException('Period dates overlap with an existing period'); + } + + const period = this.periodRepository.create({ + ...dto, + tenant_id: tenantId, + created_by: createdBy, + status: PeriodStatus.OPEN, + }); + + return this.periodRepository.save(period); + } + + async close(tenantId: string, id: string, closedBy: string): Promise { + const period = await this.findOne(tenantId, id); + + if (!period.isCloseable) { + throw new BadRequestException('Period cannot be closed. Current status: ' + period.status); + } + + // Calculate totals + const stats = await this.entryRepository + .createQueryBuilder('entry') + .select('COUNT(*)', 'count') + .addSelect('COALESCE(SUM(entry.commission_amount), 0)', 'total') + .where('entry.period_id = :periodId', { periodId: id }) + .andWhere('entry.status IN (:...statuses)', { statuses: [EntryStatus.PENDING, EntryStatus.APPROVED] }) + .getRawOne(); + + period.status = PeriodStatus.CLOSED; + period.closed_at = new Date(); + period.closed_by = closedBy; + period.total_entries = parseInt(stats.count) || 0; + period.total_amount = parseFloat(stats.total) || 0; + + return this.periodRepository.save(period); + } + + async reopen(tenantId: string, id: string): Promise { + const period = await this.findOne(tenantId, id); + + if (period.status !== PeriodStatus.CLOSED) { + throw new BadRequestException('Only closed periods can be reopened'); + } + + period.status = PeriodStatus.OPEN; + period.closed_at = null; + period.closed_by = null; + + return this.periodRepository.save(period); + } + + async markAsProcessing(tenantId: string, id: string): Promise { + const period = await this.findOne(tenantId, id); + + if (period.status !== PeriodStatus.CLOSED) { + throw new BadRequestException('Only closed periods can be marked as processing'); + } + + period.status = PeriodStatus.PROCESSING; + + return this.periodRepository.save(period); + } + + async markAsPaid( + tenantId: string, + id: string, + paidBy: string, + paymentReference?: string, + paymentNotes?: string, + ): Promise { + const period = await this.findOne(tenantId, id); + + if (!period.isPayable) { + throw new BadRequestException('Period is not ready for payment. Current status: ' + period.status); + } + + // Mark all approved entries as paid + await this.entryRepository + .createQueryBuilder() + .update(CommissionEntry) + .set({ + status: EntryStatus.PAID, + paid_at: new Date(), + payment_reference: paymentReference, + }) + .where('period_id = :periodId', { periodId: id }) + .andWhere('status = :status', { status: EntryStatus.APPROVED }) + .execute(); + + period.status = PeriodStatus.PAID; + period.paid_at = new Date(); + period.paid_by = paidBy; + period.payment_reference = paymentReference || null; + period.payment_notes = paymentNotes || null; + + return this.periodRepository.save(period); + } + + async getSummary(tenantId: string, id: string): Promise { + await this.findOne(tenantId, id); // Verify period exists + + // Get entries by status + const byStatus = await this.entryRepository + .createQueryBuilder('entry') + .select('entry.status', 'status') + .addSelect('COUNT(*)', 'count') + .addSelect('COALESCE(SUM(entry.commission_amount), 0)', 'total') + .where('entry.period_id = :periodId', { periodId: id }) + .groupBy('entry.status') + .getRawMany(); + + // Get entries by user + const byUser = await this.entryRepository + .createQueryBuilder('entry') + .leftJoin('entry.user', 'user') + .select('entry.user_id', 'user_id') + .addSelect("CONCAT(user.first_name, ' ', user.last_name)", 'user_name') + .addSelect('COUNT(*)', 'entries_count') + .addSelect('COALESCE(SUM(entry.commission_amount), 0)', 'total_amount') + .where('entry.period_id = :periodId', { periodId: id }) + .andWhere('entry.status NOT IN (:...excludedStatuses)', { excludedStatuses: [EntryStatus.REJECTED, EntryStatus.CANCELLED] }) + .groupBy('entry.user_id') + .addGroupBy('user.first_name') + .addGroupBy('user.last_name') + .orderBy('total_amount', 'DESC') + .getRawMany(); + + const statusMap: PeriodSummary['by_status'] = { + pending: 0, + approved: 0, + rejected: 0, + paid: 0, + cancelled: 0, + }; + + let totalEntries = 0; + let totalAmount = 0; + + byStatus.forEach((row) => { + const count = parseInt(row.count); + const total = parseFloat(row.total); + statusMap[row.status as keyof typeof statusMap] = count; + if (row.status !== EntryStatus.REJECTED && row.status !== EntryStatus.CANCELLED) { + totalEntries += count; + totalAmount += total; + } + }); + + return { + total_entries: totalEntries, + total_amount: totalAmount, + by_status: statusMap, + by_user: byUser.map((row) => ({ + user_id: row.user_id, + user_name: row.user_name || 'Unknown', + entries_count: parseInt(row.entries_count), + total_amount: parseFloat(row.total_amount), + })), + }; + } +} diff --git a/apps/backend/src/modules/commissions/services/schemes.service.ts b/apps/backend/src/modules/commissions/services/schemes.service.ts new file mode 100644 index 00000000..0c5ee0cd --- /dev/null +++ b/apps/backend/src/modules/commissions/services/schemes.service.ts @@ -0,0 +1,200 @@ +import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { CommissionScheme, SchemeType, TierConfig } from '../entities/commission-scheme.entity'; +import { CreateSchemeDto, UpdateSchemeDto } from '../dto'; + +export interface SchemeFilters { + type?: SchemeType; + is_active?: boolean; + search?: string; +} + +export interface PaginationOptions { + page?: number; + limit?: number; + sortBy?: string; + sortOrder?: 'ASC' | 'DESC'; +} + +export interface PaginatedResult { + data: T[]; + total: number; + page: number; + limit: number; + totalPages: number; +} + +@Injectable() +export class SchemesService { + constructor( + @InjectRepository(CommissionScheme) + private readonly schemeRepository: Repository, + ) {} + + async findAll( + tenantId: string, + filters: SchemeFilters = {}, + pagination: PaginationOptions = {}, + ): Promise> { + const { page = 1, limit = 20, sortBy = 'created_at', sortOrder = 'DESC' } = pagination; + const skip = (page - 1) * limit; + + const queryBuilder = this.schemeRepository.createQueryBuilder('scheme') + .where('scheme.tenant_id = :tenantId', { tenantId }) + .andWhere('scheme.deleted_at IS NULL'); + + if (filters.type) { + queryBuilder.andWhere('scheme.type = :type', { type: filters.type }); + } + + if (filters.is_active !== undefined) { + queryBuilder.andWhere('scheme.is_active = :isActive', { isActive: filters.is_active }); + } + + if (filters.search) { + queryBuilder.andWhere( + '(scheme.name ILIKE :search OR scheme.description ILIKE :search)', + { search: `%${filters.search}%` }, + ); + } + + const [data, total] = await queryBuilder + .orderBy(`scheme.${sortBy}`, sortOrder) + .skip(skip) + .take(limit) + .getManyAndCount(); + + return { + data, + total, + page, + limit, + totalPages: Math.ceil(total / limit), + }; + } + + async findOne(tenantId: string, id: string): Promise { + const scheme = await this.schemeRepository.findOne({ + where: { id, tenant_id: tenantId, deleted_at: undefined }, + relations: ['creator', 'assignments'], + }); + + if (!scheme) { + throw new NotFoundException(`Commission scheme with ID ${id} not found`); + } + + return scheme; + } + + async findActive(tenantId: string): Promise { + return this.schemeRepository.find({ + where: { + tenant_id: tenantId, + is_active: true, + deleted_at: undefined, + }, + order: { name: 'ASC' }, + }); + } + + async create(tenantId: string, dto: CreateSchemeDto, createdBy?: string): Promise { + // Validate tiers if tiered type + if (dto.type === SchemeType.TIERED) { + this.validateTiers(dto.tiers || []); + } + + const scheme = this.schemeRepository.create({ + ...dto, + tenant_id: tenantId, + created_by: createdBy, + }); + + return this.schemeRepository.save(scheme); + } + + async update(tenantId: string, id: string, dto: UpdateSchemeDto): Promise { + const scheme = await this.findOne(tenantId, id); + + // Validate tiers if updating to tiered type or updating tiers + if (dto.type === SchemeType.TIERED || (scheme.type === SchemeType.TIERED && dto.tiers)) { + this.validateTiers(dto.tiers || scheme.tiers); + } + + Object.assign(scheme, dto); + return this.schemeRepository.save(scheme); + } + + async remove(tenantId: string, id: string): Promise { + const scheme = await this.findOne(tenantId, id); + scheme.deleted_at = new Date(); + scheme.is_active = false; + await this.schemeRepository.save(scheme); + } + + async duplicate(tenantId: string, id: string, newName?: string, createdBy?: string): Promise { + const original = await this.findOne(tenantId, id); + + const duplicate = this.schemeRepository.create({ + tenant_id: tenantId, + name: newName || `${original.name} (Copy)`, + description: original.description, + type: original.type, + rate: original.rate, + fixed_amount: original.fixed_amount, + tiers: original.tiers, + applies_to: original.applies_to, + product_ids: original.product_ids, + category_ids: original.category_ids, + min_amount: original.min_amount, + max_amount: original.max_amount, + is_active: false, // Start as inactive + created_by: createdBy, + }); + + return this.schemeRepository.save(duplicate); + } + + async toggleActive(tenantId: string, id: string): Promise { + const scheme = await this.findOne(tenantId, id); + scheme.is_active = !scheme.is_active; + return this.schemeRepository.save(scheme); + } + + validateTiers(tiers: TierConfig[]): void { + if (!Array.isArray(tiers) || tiers.length === 0) { + throw new BadRequestException('Tiered schemes require at least one tier configuration'); + } + + // Sort tiers by from amount + const sortedTiers = [...tiers].sort((a, b) => a.from - b.from); + + for (let i = 0; i < sortedTiers.length; i++) { + const tier = sortedTiers[i]; + + // Validate tier structure + if (typeof tier.from !== 'number' || tier.from < 0) { + throw new BadRequestException(`Invalid tier 'from' value at index ${i}`); + } + + if (tier.to !== null && (typeof tier.to !== 'number' || tier.to <= tier.from)) { + throw new BadRequestException(`Invalid tier 'to' value at index ${i}: must be greater than 'from'`); + } + + if (typeof tier.rate !== 'number' || tier.rate < 0 || tier.rate > 100) { + throw new BadRequestException(`Invalid tier rate at index ${i}: must be between 0 and 100`); + } + + // Check for gaps or overlaps with next tier + if (i < sortedTiers.length - 1) { + const nextTier = sortedTiers[i + 1]; + if (tier.to === null) { + throw new BadRequestException('Only the last tier can have an unlimited upper bound'); + } + if (tier.to !== nextTier.from) { + throw new BadRequestException(`Gap or overlap between tiers at index ${i} and ${i + 1}`); + } + } + } + } +} diff --git a/apps/frontend/src/components/commissions/CommissionsDashboard.tsx b/apps/frontend/src/components/commissions/CommissionsDashboard.tsx new file mode 100644 index 00000000..acee82df --- /dev/null +++ b/apps/frontend/src/components/commissions/CommissionsDashboard.tsx @@ -0,0 +1,134 @@ +import { DollarSign, Users, Calendar, TrendingUp, Clock, CheckCircle } from 'lucide-react'; +import { DashboardSummary, TopEarner, PeriodEarnings } from '../../services/commissions/dashboard.api'; + +interface CommissionsDashboardProps { + summary: DashboardSummary; + topEarners: TopEarner[]; + periodEarnings: PeriodEarnings[]; +} + +export function CommissionsDashboard({ summary, topEarners, periodEarnings }: CommissionsDashboardProps) { + const formatCurrency = (amount: number) => + new Intl.NumberFormat('en-US', { style: 'currency', currency: summary.currency }).format(amount); + + return ( +
+ {/* Summary Cards */} +
+
+
+
+

Pending Commissions

+

{formatCurrency(summary.total_pending)}

+

{summary.pending_count} entries

+
+
+ +
+
+
+ +
+
+
+

Approved

+

{formatCurrency(summary.total_approved)}

+

{summary.approved_count} entries

+
+
+ +
+
+
+ +
+
+
+

Total Paid

+

{formatCurrency(summary.total_paid)}

+

{summary.paid_count} entries

+
+
+ +
+
+
+ +
+
+
+

Active Schemes

+

{summary.active_schemes}

+

{summary.active_assignments} assignments

+
+
+ +
+
+
+
+ +
+ {/* Top Earners */} +
+

Top Earners

+ {topEarners.length === 0 ? ( +

No earners data

+ ) : ( +
+ {topEarners.map((earner) => ( +
+
+
+ {earner.rank} +
+
+

{earner.user_name}

+

{earner.entries_count} sales

+
+
+

{formatCurrency(earner.total_earned)}

+
+ ))} +
+ )} +
+ + {/* Recent Periods */} +
+

Recent Periods

+ {periodEarnings.length === 0 ? ( +

No periods data

+ ) : ( +
+ {periodEarnings.slice(0, 5).map((period) => ( +
+
+

{period.period_name}

+

+ {new Date(period.starts_at).toLocaleDateString()} - {new Date(period.ends_at).toLocaleDateString()} +

+
+
+

{formatCurrency(period.total_amount)}

+ + {period.status} + +
+
+ ))} +
+ )} +
+
+
+ ); +} diff --git a/apps/frontend/src/components/commissions/EarningsCard.tsx b/apps/frontend/src/components/commissions/EarningsCard.tsx new file mode 100644 index 00000000..aacff2ad --- /dev/null +++ b/apps/frontend/src/components/commissions/EarningsCard.tsx @@ -0,0 +1,42 @@ +import { ReactNode } from 'react'; + +interface EarningsCardProps { + title: string; + value: string; + icon: ReactNode; + color: 'blue' | 'green' | 'yellow' | 'purple' | 'red'; + subtitle?: string; +} + +export function EarningsCard({ title, value, icon, color, subtitle }: EarningsCardProps) { + const colorClasses = { + blue: 'bg-blue-100 text-blue-600', + green: 'bg-green-100 text-green-600', + yellow: 'bg-yellow-100 text-yellow-600', + purple: 'bg-purple-100 text-purple-600', + red: 'bg-red-100 text-red-600', + }; + + const valueColors = { + blue: 'text-blue-600', + green: 'text-green-600', + yellow: 'text-yellow-600', + purple: 'text-purple-600', + red: 'text-red-600', + }; + + return ( +
+
+
+

{title}

+

{value}

+ {subtitle &&

{subtitle}

} +
+
+ {icon} +
+
+
+ ); +} diff --git a/apps/frontend/src/components/commissions/EntriesList.tsx b/apps/frontend/src/components/commissions/EntriesList.tsx new file mode 100644 index 00000000..a2dc479b --- /dev/null +++ b/apps/frontend/src/components/commissions/EntriesList.tsx @@ -0,0 +1,151 @@ +import { ChevronLeft, ChevronRight } from 'lucide-react'; +import { CommissionEntry } from '../../services/commissions/entries.api'; +import { EntryStatusBadge } from './EntryStatusBadge'; + +interface EntriesListProps { + entries: CommissionEntry[]; + total: number; + page: number; + totalPages: number; + selectedEntries: string[]; + onSelectionChange: (ids: string[]) => void; + onPageChange: (page: number) => void; + onRefresh: () => void; +} + +export function EntriesList({ + entries, + total, + page, + totalPages, + selectedEntries, + onSelectionChange, + onPageChange, +}: EntriesListProps) { + const formatCurrency = (amount: number) => + new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' }).format(amount); + + const handleSelectAll = () => { + const pendingIds = entries.filter((e) => e.status === 'pending').map((e) => e.id); + if (pendingIds.every((id) => selectedEntries.includes(id))) { + onSelectionChange(selectedEntries.filter((id) => !pendingIds.includes(id))); + } else { + onSelectionChange([...new Set([...selectedEntries, ...pendingIds])]); + } + }; + + const handleSelectOne = (id: string) => { + if (selectedEntries.includes(id)) { + onSelectionChange(selectedEntries.filter((i) => i !== id)); + } else { + onSelectionChange([...selectedEntries, id]); + } + }; + + const pendingEntries = entries.filter((e) => e.status === 'pending'); + const allPendingSelected = pendingEntries.length > 0 && pendingEntries.every((e) => selectedEntries.includes(e.id)); + + return ( +
+
+ + + + + + + + + + + + + + + {entries.length === 0 ? ( + + + + ) : ( + entries.map((entry) => ( + + + + + + + + + + + )) + )} + +
+ + DateUserReferenceBase AmountRateCommissionStatus
+ No commission entries found +
+ {entry.status === 'pending' && ( + handleSelectOne(entry.id)} + className="h-4 w-4 text-blue-600 rounded" + /> + )} + + {new Date(entry.created_at).toLocaleDateString()} + + {entry.user + ? `${entry.user.first_name} ${entry.user.last_name}` + : entry.user_id.slice(0, 8)} + + {entry.reference_type} + + {formatCurrency(entry.base_amount)} + + {entry.rate_applied}% + + {formatCurrency(entry.commission_amount)} + + +
+
+ + {/* Pagination */} + {totalPages > 1 && ( +
+

+ Showing {entries.length} of {total} entries +

+
+ + + Page {page} of {totalPages} + + +
+
+ )} +
+ ); +} diff --git a/apps/frontend/src/components/commissions/EntryStatusBadge.tsx b/apps/frontend/src/components/commissions/EntryStatusBadge.tsx new file mode 100644 index 00000000..3db198b7 --- /dev/null +++ b/apps/frontend/src/components/commissions/EntryStatusBadge.tsx @@ -0,0 +1,32 @@ +import { EntryStatus } from '../../services/commissions/entries.api'; + +interface EntryStatusBadgeProps { + status: EntryStatus; +} + +export function EntryStatusBadge({ status }: EntryStatusBadgeProps) { + const getStatusConfig = (status: EntryStatus) => { + switch (status) { + case 'pending': + return { bg: 'bg-yellow-100', text: 'text-yellow-700', label: 'Pending' }; + case 'approved': + return { bg: 'bg-green-100', text: 'text-green-700', label: 'Approved' }; + case 'rejected': + return { bg: 'bg-red-100', text: 'text-red-700', label: 'Rejected' }; + case 'paid': + return { bg: 'bg-blue-100', text: 'text-blue-700', label: 'Paid' }; + case 'cancelled': + return { bg: 'bg-gray-100', text: 'text-gray-700', label: 'Cancelled' }; + default: + return { bg: 'bg-gray-100', text: 'text-gray-700', label: status }; + } + }; + + const config = getStatusConfig(status); + + return ( + + {config.label} + + ); +} diff --git a/apps/frontend/src/components/commissions/PeriodManager.tsx b/apps/frontend/src/components/commissions/PeriodManager.tsx new file mode 100644 index 00000000..c43f99d4 --- /dev/null +++ b/apps/frontend/src/components/commissions/PeriodManager.tsx @@ -0,0 +1,205 @@ +import { ChevronLeft, ChevronRight, Lock, Unlock, DollarSign, Clock, CheckCircle } from 'lucide-react'; +import { CommissionPeriod, PeriodStatus } from '../../services/commissions/periods.api'; +import { useClosePeriod, useReopenPeriod, useMarkPeriodPaid } from '../../hooks/commissions'; + +interface PeriodManagerProps { + periods: CommissionPeriod[]; + total: number; + page: number; + totalPages: number; + onPageChange: (page: number) => void; + onRefresh: () => void; +} + +export function PeriodManager({ + periods, + total, + page, + totalPages, + onPageChange, + onRefresh, +}: PeriodManagerProps) { + const closeMutation = useClosePeriod(); + const reopenMutation = useReopenPeriod(); + const payMutation = useMarkPeriodPaid(); + + const formatCurrency = (amount: number) => + new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' }).format(amount); + + const handleClose = async (id: string) => { + if (window.confirm('Close this period? This will finalize the totals.')) { + await closeMutation.mutateAsync(id); + onRefresh(); + } + }; + + const handleReopen = async (id: string) => { + if (window.confirm('Reopen this period?')) { + await reopenMutation.mutateAsync(id); + onRefresh(); + } + }; + + const handlePay = async (id: string) => { + const reference = window.prompt('Enter payment reference (optional):'); + await payMutation.mutateAsync({ id, data: reference ? { payment_reference: reference } : undefined }); + onRefresh(); + }; + + const getStatusIcon = (status: PeriodStatus) => { + switch (status) { + case 'open': + return ; + case 'closed': + return ; + case 'processing': + return ; + case 'paid': + return ; + default: + return null; + } + }; + + const getStatusBadge = (status: PeriodStatus) => { + const configs: Record = { + open: { bg: 'bg-blue-100', text: 'text-blue-700' }, + closed: { bg: 'bg-orange-100', text: 'text-orange-700' }, + processing: { bg: 'bg-yellow-100', text: 'text-yellow-700' }, + paid: { bg: 'bg-green-100', text: 'text-green-700' }, + }; + const config = configs[status]; + return ( + + {status} + + ); + }; + + return ( +
+
+ + + + + + + + + + + + + {periods.length === 0 ? ( + + + + ) : ( + periods.map((period) => ( + + + + + + + + + )) + )} + +
PeriodDate RangeEntriesTotal AmountStatusActions
+ No commission periods found +
+
+ {getStatusIcon(period.status)} + {period.name} +
+
+ {new Date(period.starts_at).toLocaleDateString()} -{' '} + {new Date(period.ends_at).toLocaleDateString()} + + {period.total_entries} + + {formatCurrency(period.total_amount)} + + {getStatusBadge(period.status)} + +
+ {period.status === 'open' && ( + + )} + {period.status === 'closed' && ( + <> + + + + )} + {period.status === 'processing' && ( + + )} + {period.status === 'paid' && period.paid_at && ( + + Paid {new Date(period.paid_at).toLocaleDateString()} + + )} +
+
+
+ + {/* Pagination */} + {totalPages > 1 && ( +
+

+ Showing {periods.length} of {total} periods +

+
+ + + Page {page} of {totalPages} + + +
+
+ )} +
+ ); +} diff --git a/apps/frontend/src/components/commissions/SchemeForm.tsx b/apps/frontend/src/components/commissions/SchemeForm.tsx new file mode 100644 index 00000000..e8504744 --- /dev/null +++ b/apps/frontend/src/components/commissions/SchemeForm.tsx @@ -0,0 +1,266 @@ +import { useState } from 'react'; +import { X, Plus, Trash2 } from 'lucide-react'; +import { useCreateScheme, useUpdateScheme } from '../../hooks/commissions'; +import { CreateSchemeDto, SchemeType, AppliesTo, TierConfig, CommissionScheme } from '../../services/commissions/schemes.api'; + +interface SchemeFormProps { + scheme?: CommissionScheme; + onClose: () => void; + onSuccess: () => void; +} + +export function SchemeForm({ scheme, onClose, onSuccess }: SchemeFormProps) { + const isEditing = !!scheme; + const createMutation = useCreateScheme(); + const updateMutation = useUpdateScheme(); + + const [formData, setFormData] = useState({ + name: scheme?.name || '', + description: scheme?.description || '', + type: scheme?.type || 'percentage', + rate: scheme?.rate || 10, + fixed_amount: scheme?.fixed_amount || 0, + tiers: scheme?.tiers || [{ from: 0, to: 1000, rate: 5 }], + applies_to: scheme?.applies_to || 'all', + min_amount: scheme?.min_amount || 0, + max_amount: scheme?.max_amount || undefined, + is_active: scheme?.is_active ?? true, + }); + + const [tiers, setTiers] = useState(formData.tiers || []); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + + const data: CreateSchemeDto = { + ...formData, + tiers: formData.type === 'tiered' ? tiers : [], + }; + + if (isEditing) { + await updateMutation.mutateAsync({ id: scheme.id, data }); + } else { + await createMutation.mutateAsync(data); + } + onSuccess(); + }; + + const addTier = () => { + const lastTier = tiers[tiers.length - 1]; + const newFrom = lastTier?.to || 0; + setTiers([...tiers, { from: newFrom, to: newFrom + 1000, rate: 5 }]); + }; + + const removeTier = (index: number) => { + setTiers(tiers.filter((_, i) => i !== index)); + }; + + const updateTier = (index: number, field: keyof TierConfig, value: number | null) => { + setTiers(tiers.map((tier, i) => (i === index ? { ...tier, [field]: value } : tier))); + }; + + const isPending = createMutation.isPending || updateMutation.isPending; + + return ( +
+
+
+

+ {isEditing ? 'Edit Scheme' : 'Create Commission Scheme'} +

+ +
+ +
+
+ + setFormData({ ...formData, name: e.target.value })} + className="mt-1 w-full px-3 py-2 border border-gray-300 rounded-lg" + /> +
+ +
+ +