[SAAS-021] feat: Implement MLM module backend
- 6 entities: structure, rank, node, commission, bonus, rank_history - 4 DTOs with validation - 4 services with tree operations (LTREE path queries) - 4 controllers with full CRUD - Commission calculation and rank evaluation logic - My network endpoints for user dashboard Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
6d6241c6cb
commit
c683ab0353
@ -32,6 +32,7 @@ import { SalesModule } from '@modules/sales/sales.module';
|
||||
import { CommissionsModule } from '@modules/commissions/commissions.module';
|
||||
import { PortfolioModule } from '@modules/portfolio/portfolio.module';
|
||||
import { GoalsModule } from '@modules/goals/goals.module';
|
||||
import { MlmModule } from '@modules/mlm/mlm.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
@ -96,6 +97,7 @@ import { GoalsModule } from '@modules/goals/goals.module';
|
||||
CommissionsModule,
|
||||
PortfolioModule,
|
||||
GoalsModule,
|
||||
MlmModule,
|
||||
],
|
||||
})
|
||||
export class AppModule {}
|
||||
|
||||
88
src/modules/mlm/controllers/commissions.controller.ts
Normal file
88
src/modules/mlm/controllers/commissions.controller.ts
Normal file
@ -0,0 +1,88 @@
|
||||
import {
|
||||
Controller,
|
||||
Get,
|
||||
Post,
|
||||
Patch,
|
||||
Body,
|
||||
Param,
|
||||
Query,
|
||||
UseGuards,
|
||||
ParseUUIDPipe,
|
||||
} from '@nestjs/common';
|
||||
import { ApiTags, ApiOperation, ApiBearerAuth, ApiResponse } from '@nestjs/swagger';
|
||||
import { JwtAuthGuard } from '@modules/auth/guards/jwt-auth.guard';
|
||||
import { CurrentUser } from '@modules/auth/decorators/current-user.decorator';
|
||||
import { CommissionsService } from '../services/commissions.service';
|
||||
import {
|
||||
CalculateCommissionsDto,
|
||||
UpdateCommissionStatusDto,
|
||||
CommissionFiltersDto,
|
||||
CommissionResponseDto,
|
||||
CommissionsByLevelDto,
|
||||
} from '../dto/commission.dto';
|
||||
|
||||
interface RequestUser {
|
||||
id: string;
|
||||
tenant_id: string;
|
||||
email: string;
|
||||
role: string;
|
||||
}
|
||||
|
||||
@ApiTags('MLM - Commissions')
|
||||
@ApiBearerAuth()
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Controller('mlm/commissions')
|
||||
export class CommissionsController {
|
||||
constructor(private readonly commissionsService: CommissionsService) {}
|
||||
|
||||
@Get()
|
||||
@ApiOperation({ summary: 'List all MLM commissions' })
|
||||
@ApiResponse({ status: 200 })
|
||||
findAll(
|
||||
@CurrentUser() user: RequestUser,
|
||||
@Query() filters: CommissionFiltersDto,
|
||||
) {
|
||||
return this.commissionsService.findAll(user.tenant_id, filters);
|
||||
}
|
||||
|
||||
@Get('by-level')
|
||||
@ApiOperation({ summary: 'Get commissions breakdown by level' })
|
||||
@ApiResponse({ status: 200, type: [CommissionsByLevelDto] })
|
||||
getByLevel(
|
||||
@CurrentUser() user: RequestUser,
|
||||
@Query('nodeId') nodeId?: string,
|
||||
) {
|
||||
return this.commissionsService.getCommissionsByLevel(user.tenant_id, nodeId);
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
@ApiOperation({ summary: 'Get a commission by ID' })
|
||||
@ApiResponse({ status: 200, type: CommissionResponseDto })
|
||||
findOne(
|
||||
@CurrentUser() user: RequestUser,
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
) {
|
||||
return this.commissionsService.findOne(user.tenant_id, id);
|
||||
}
|
||||
|
||||
@Post('calculate')
|
||||
@ApiOperation({ summary: 'Calculate commissions for a sale/volume' })
|
||||
@ApiResponse({ status: 201, type: [CommissionResponseDto] })
|
||||
calculateCommissions(
|
||||
@CurrentUser() user: RequestUser,
|
||||
@Body() dto: CalculateCommissionsDto,
|
||||
) {
|
||||
return this.commissionsService.calculateCommissions(user.tenant_id, dto);
|
||||
}
|
||||
|
||||
@Patch(':id/status')
|
||||
@ApiOperation({ summary: 'Update commission status' })
|
||||
@ApiResponse({ status: 200, type: CommissionResponseDto })
|
||||
updateStatus(
|
||||
@CurrentUser() user: RequestUser,
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Body() dto: UpdateCommissionStatusDto,
|
||||
) {
|
||||
return this.commissionsService.updateStatus(user.tenant_id, id, dto);
|
||||
}
|
||||
}
|
||||
4
src/modules/mlm/controllers/index.ts
Normal file
4
src/modules/mlm/controllers/index.ts
Normal file
@ -0,0 +1,4 @@
|
||||
export * from './structures.controller';
|
||||
export * from './ranks.controller';
|
||||
export * from './nodes.controller';
|
||||
export * from './commissions.controller';
|
||||
195
src/modules/mlm/controllers/nodes.controller.ts
Normal file
195
src/modules/mlm/controllers/nodes.controller.ts
Normal file
@ -0,0 +1,195 @@
|
||||
import {
|
||||
Controller,
|
||||
Get,
|
||||
Post,
|
||||
Patch,
|
||||
Body,
|
||||
Param,
|
||||
Query,
|
||||
UseGuards,
|
||||
ParseUUIDPipe,
|
||||
} from '@nestjs/common';
|
||||
import { ApiTags, ApiOperation, ApiBearerAuth, ApiResponse } from '@nestjs/swagger';
|
||||
import { JwtAuthGuard } from '@modules/auth/guards/jwt-auth.guard';
|
||||
import { CurrentUser } from '@modules/auth/decorators/current-user.decorator';
|
||||
import { NodesService } from '../services/nodes.service';
|
||||
import { CommissionsService } from '../services/commissions.service';
|
||||
import {
|
||||
CreateNodeDto,
|
||||
UpdateNodeDto,
|
||||
UpdateNodeStatusDto,
|
||||
NodeFiltersDto,
|
||||
NodeResponseDto,
|
||||
TreeNodeDto,
|
||||
InviteLinkDto,
|
||||
MyNetworkSummaryDto,
|
||||
} from '../dto/node.dto';
|
||||
import { EarningsSummaryDto } from '../dto/commission.dto';
|
||||
|
||||
interface RequestUser {
|
||||
id: string;
|
||||
tenant_id: string;
|
||||
email: string;
|
||||
role: string;
|
||||
}
|
||||
|
||||
@ApiTags('MLM - Nodes')
|
||||
@ApiBearerAuth()
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Controller('mlm')
|
||||
export class NodesController {
|
||||
constructor(
|
||||
private readonly nodesService: NodesService,
|
||||
private readonly commissionsService: CommissionsService,
|
||||
) {}
|
||||
|
||||
// ─────────────────────────────────────────────
|
||||
// Nodes CRUD
|
||||
// ─────────────────────────────────────────────
|
||||
|
||||
@Post('nodes')
|
||||
@ApiOperation({ summary: 'Create a new node (register distributor)' })
|
||||
@ApiResponse({ status: 201, type: NodeResponseDto })
|
||||
create(
|
||||
@CurrentUser() user: RequestUser,
|
||||
@Body() dto: CreateNodeDto,
|
||||
) {
|
||||
return this.nodesService.create(user.tenant_id, dto);
|
||||
}
|
||||
|
||||
@Get('nodes')
|
||||
@ApiOperation({ summary: 'List all nodes' })
|
||||
@ApiResponse({ status: 200 })
|
||||
findAll(
|
||||
@CurrentUser() user: RequestUser,
|
||||
@Query() filters: NodeFiltersDto,
|
||||
) {
|
||||
return this.nodesService.findAll(user.tenant_id, filters);
|
||||
}
|
||||
|
||||
@Get('nodes/:id')
|
||||
@ApiOperation({ summary: 'Get a node by ID' })
|
||||
@ApiResponse({ status: 200, type: NodeResponseDto })
|
||||
findOne(
|
||||
@CurrentUser() user: RequestUser,
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
) {
|
||||
return this.nodesService.findOne(user.tenant_id, id);
|
||||
}
|
||||
|
||||
@Patch('nodes/:id')
|
||||
@ApiOperation({ summary: 'Update a node' })
|
||||
@ApiResponse({ status: 200, type: NodeResponseDto })
|
||||
update(
|
||||
@CurrentUser() user: RequestUser,
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Body() dto: UpdateNodeDto,
|
||||
) {
|
||||
return this.nodesService.update(user.tenant_id, id, dto);
|
||||
}
|
||||
|
||||
@Patch('nodes/:id/status')
|
||||
@ApiOperation({ summary: 'Update node status' })
|
||||
@ApiResponse({ status: 200, type: NodeResponseDto })
|
||||
updateStatus(
|
||||
@CurrentUser() user: RequestUser,
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Body() dto: UpdateNodeStatusDto,
|
||||
) {
|
||||
return this.nodesService.updateStatus(user.tenant_id, id, dto);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────
|
||||
// Network Navigation
|
||||
// ─────────────────────────────────────────────
|
||||
|
||||
@Get('nodes/:id/downline')
|
||||
@ApiOperation({ summary: 'Get downline of a node' })
|
||||
@ApiResponse({ status: 200, type: [NodeResponseDto] })
|
||||
getDownline(
|
||||
@CurrentUser() user: RequestUser,
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Query('maxDepth') maxDepth?: number,
|
||||
) {
|
||||
return this.nodesService.getDownline(user.tenant_id, id, maxDepth);
|
||||
}
|
||||
|
||||
@Get('nodes/:id/upline')
|
||||
@ApiOperation({ summary: 'Get upline of a node' })
|
||||
@ApiResponse({ status: 200, type: [NodeResponseDto] })
|
||||
getUpline(
|
||||
@CurrentUser() user: RequestUser,
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
) {
|
||||
return this.nodesService.getUpline(user.tenant_id, id);
|
||||
}
|
||||
|
||||
@Get('nodes/:id/tree')
|
||||
@ApiOperation({ summary: 'Get tree structure from a node' })
|
||||
@ApiResponse({ status: 200, type: TreeNodeDto })
|
||||
getTree(
|
||||
@CurrentUser() user: RequestUser,
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Query('maxDepth') maxDepth?: number,
|
||||
) {
|
||||
return this.nodesService.getTree(user.tenant_id, id, maxDepth || 3);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────
|
||||
// My Network (Current User)
|
||||
// ─────────────────────────────────────────────
|
||||
|
||||
@Get('my/dashboard')
|
||||
@ApiOperation({ summary: 'Get current user network dashboard' })
|
||||
@ApiResponse({ status: 200, type: MyNetworkSummaryDto })
|
||||
getMyDashboard(@CurrentUser() user: RequestUser) {
|
||||
return this.nodesService.getMyNetworkSummary(user.tenant_id, user.id);
|
||||
}
|
||||
|
||||
@Get('my/network')
|
||||
@ApiOperation({ summary: 'Get current user network tree' })
|
||||
@ApiResponse({ status: 200, type: TreeNodeDto })
|
||||
async getMyNetwork(
|
||||
@CurrentUser() user: RequestUser,
|
||||
@Query('maxDepth') maxDepth?: number,
|
||||
) {
|
||||
const myNode = await this.nodesService.findByUserId(user.tenant_id, user.id);
|
||||
if (!myNode) {
|
||||
return null;
|
||||
}
|
||||
return this.nodesService.getTree(user.tenant_id, myNode.id, maxDepth || 3);
|
||||
}
|
||||
|
||||
@Get('my/earnings')
|
||||
@ApiOperation({ summary: 'Get current user earnings summary' })
|
||||
@ApiResponse({ status: 200, type: EarningsSummaryDto })
|
||||
async getMyEarnings(@CurrentUser() user: RequestUser) {
|
||||
const myNode = await this.nodesService.findByUserId(user.tenant_id, user.id);
|
||||
if (!myNode) {
|
||||
return null;
|
||||
}
|
||||
return this.commissionsService.getEarningsSummary(user.tenant_id, myNode.id);
|
||||
}
|
||||
|
||||
@Get('my/rank')
|
||||
@ApiOperation({ summary: 'Get current user rank and progress' })
|
||||
@ApiResponse({ status: 200 })
|
||||
async getMyRank(@CurrentUser() user: RequestUser) {
|
||||
const summary = await this.nodesService.getMyNetworkSummary(user.tenant_id, user.id);
|
||||
return {
|
||||
currentRank: summary.currentRank,
|
||||
nextRank: summary.nextRank,
|
||||
};
|
||||
}
|
||||
|
||||
@Post('my/invite')
|
||||
@ApiOperation({ summary: 'Generate invite link for current user' })
|
||||
@ApiResponse({ status: 200, type: InviteLinkDto })
|
||||
async generateInviteLink(@CurrentUser() user: RequestUser) {
|
||||
const myNode = await this.nodesService.findByUserId(user.tenant_id, user.id);
|
||||
if (!myNode) {
|
||||
return null;
|
||||
}
|
||||
return this.nodesService.generateInviteLink(user.tenant_id, myNode.id);
|
||||
}
|
||||
}
|
||||
98
src/modules/mlm/controllers/ranks.controller.ts
Normal file
98
src/modules/mlm/controllers/ranks.controller.ts
Normal file
@ -0,0 +1,98 @@
|
||||
import {
|
||||
Controller,
|
||||
Get,
|
||||
Post,
|
||||
Patch,
|
||||
Delete,
|
||||
Body,
|
||||
Param,
|
||||
Query,
|
||||
UseGuards,
|
||||
ParseUUIDPipe,
|
||||
} from '@nestjs/common';
|
||||
import { ApiTags, ApiOperation, ApiBearerAuth, ApiResponse } from '@nestjs/swagger';
|
||||
import { JwtAuthGuard } from '@modules/auth/guards/jwt-auth.guard';
|
||||
import { CurrentUser } from '@modules/auth/decorators/current-user.decorator';
|
||||
import { RanksService } from '../services/ranks.service';
|
||||
import {
|
||||
CreateRankDto,
|
||||
UpdateRankDto,
|
||||
RankFiltersDto,
|
||||
RankResponseDto,
|
||||
} from '../dto/rank.dto';
|
||||
|
||||
interface RequestUser {
|
||||
id: string;
|
||||
tenant_id: string;
|
||||
email: string;
|
||||
role: string;
|
||||
}
|
||||
|
||||
@ApiTags('MLM - Ranks')
|
||||
@ApiBearerAuth()
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Controller('mlm/ranks')
|
||||
export class RanksController {
|
||||
constructor(private readonly ranksService: RanksService) {}
|
||||
|
||||
@Post()
|
||||
@ApiOperation({ summary: 'Create a new rank' })
|
||||
@ApiResponse({ status: 201, type: RankResponseDto })
|
||||
create(
|
||||
@CurrentUser() user: RequestUser,
|
||||
@Body() dto: CreateRankDto,
|
||||
) {
|
||||
return this.ranksService.create(user.tenant_id, dto);
|
||||
}
|
||||
|
||||
@Get()
|
||||
@ApiOperation({ summary: 'List all ranks' })
|
||||
@ApiResponse({ status: 200, type: [RankResponseDto] })
|
||||
findAll(
|
||||
@CurrentUser() user: RequestUser,
|
||||
@Query() filters: RankFiltersDto,
|
||||
) {
|
||||
return this.ranksService.findAll(user.tenant_id, filters);
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
@ApiOperation({ summary: 'Get a rank by ID' })
|
||||
@ApiResponse({ status: 200, type: RankResponseDto })
|
||||
findOne(
|
||||
@CurrentUser() user: RequestUser,
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
) {
|
||||
return this.ranksService.findOne(user.tenant_id, id);
|
||||
}
|
||||
|
||||
@Patch(':id')
|
||||
@ApiOperation({ summary: 'Update a rank' })
|
||||
@ApiResponse({ status: 200, type: RankResponseDto })
|
||||
update(
|
||||
@CurrentUser() user: RequestUser,
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Body() dto: UpdateRankDto,
|
||||
) {
|
||||
return this.ranksService.update(user.tenant_id, id, dto);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@ApiOperation({ summary: 'Delete a rank' })
|
||||
@ApiResponse({ status: 204 })
|
||||
remove(
|
||||
@CurrentUser() user: RequestUser,
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
) {
|
||||
return this.ranksService.remove(user.tenant_id, id);
|
||||
}
|
||||
|
||||
@Post('evaluate')
|
||||
@ApiOperation({ summary: 'Evaluate all nodes for rank qualification' })
|
||||
@ApiResponse({ status: 200 })
|
||||
evaluate(
|
||||
@CurrentUser() user: RequestUser,
|
||||
@Query('structureId', ParseUUIDPipe) structureId: string,
|
||||
) {
|
||||
return this.ranksService.evaluateAllNodes(user.tenant_id, structureId);
|
||||
}
|
||||
}
|
||||
88
src/modules/mlm/controllers/structures.controller.ts
Normal file
88
src/modules/mlm/controllers/structures.controller.ts
Normal file
@ -0,0 +1,88 @@
|
||||
import {
|
||||
Controller,
|
||||
Get,
|
||||
Post,
|
||||
Patch,
|
||||
Delete,
|
||||
Body,
|
||||
Param,
|
||||
Query,
|
||||
UseGuards,
|
||||
ParseUUIDPipe,
|
||||
} from '@nestjs/common';
|
||||
import { ApiTags, ApiOperation, ApiBearerAuth, ApiResponse } from '@nestjs/swagger';
|
||||
import { JwtAuthGuard } from '@modules/auth/guards/jwt-auth.guard';
|
||||
import { CurrentUser } from '@modules/auth/decorators/current-user.decorator';
|
||||
import { StructuresService } from '../services/structures.service';
|
||||
import {
|
||||
CreateStructureDto,
|
||||
UpdateStructureDto,
|
||||
StructureFiltersDto,
|
||||
StructureResponseDto,
|
||||
} from '../dto/structure.dto';
|
||||
|
||||
interface RequestUser {
|
||||
id: string;
|
||||
tenant_id: string;
|
||||
email: string;
|
||||
role: string;
|
||||
}
|
||||
|
||||
@ApiTags('MLM - Structures')
|
||||
@ApiBearerAuth()
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Controller('mlm/structures')
|
||||
export class StructuresController {
|
||||
constructor(private readonly structuresService: StructuresService) {}
|
||||
|
||||
@Post()
|
||||
@ApiOperation({ summary: 'Create a new MLM structure' })
|
||||
@ApiResponse({ status: 201, type: StructureResponseDto })
|
||||
create(
|
||||
@CurrentUser() user: RequestUser,
|
||||
@Body() dto: CreateStructureDto,
|
||||
) {
|
||||
return this.structuresService.create(user.tenant_id, user.id, dto);
|
||||
}
|
||||
|
||||
@Get()
|
||||
@ApiOperation({ summary: 'List all MLM structures' })
|
||||
@ApiResponse({ status: 200, type: [StructureResponseDto] })
|
||||
findAll(
|
||||
@CurrentUser() user: RequestUser,
|
||||
@Query() filters: StructureFiltersDto,
|
||||
) {
|
||||
return this.structuresService.findAll(user.tenant_id, filters);
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
@ApiOperation({ summary: 'Get a structure by ID' })
|
||||
@ApiResponse({ status: 200, type: StructureResponseDto })
|
||||
findOne(
|
||||
@CurrentUser() user: RequestUser,
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
) {
|
||||
return this.structuresService.findOne(user.tenant_id, id);
|
||||
}
|
||||
|
||||
@Patch(':id')
|
||||
@ApiOperation({ summary: 'Update a structure' })
|
||||
@ApiResponse({ status: 200, type: StructureResponseDto })
|
||||
update(
|
||||
@CurrentUser() user: RequestUser,
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Body() dto: UpdateStructureDto,
|
||||
) {
|
||||
return this.structuresService.update(user.tenant_id, id, dto);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@ApiOperation({ summary: 'Delete a structure' })
|
||||
@ApiResponse({ status: 204 })
|
||||
remove(
|
||||
@CurrentUser() user: RequestUser,
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
) {
|
||||
return this.structuresService.remove(user.tenant_id, id);
|
||||
}
|
||||
}
|
||||
238
src/modules/mlm/dto/commission.dto.ts
Normal file
238
src/modules/mlm/dto/commission.dto.ts
Normal file
@ -0,0 +1,238 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import {
|
||||
IsUUID,
|
||||
IsNumber,
|
||||
IsEnum,
|
||||
IsOptional,
|
||||
IsString,
|
||||
Min,
|
||||
Max,
|
||||
} from 'class-validator';
|
||||
import { Type } from 'class-transformer';
|
||||
import { CommissionType, CommissionStatus } from '../entities/commission.entity';
|
||||
import { BonusType } from '../entities/bonus.entity';
|
||||
|
||||
export class CalculateCommissionsDto {
|
||||
@ApiProperty({ description: 'Source node ID (who generated the sale)' })
|
||||
@IsUUID()
|
||||
sourceNodeId: string;
|
||||
|
||||
@ApiProperty({ description: 'Amount to calculate commissions on' })
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
amount: number;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Currency', default: 'USD' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
currency?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Source reference (sale ID, etc.)' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
sourceReference?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Period ID' })
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
periodId?: string;
|
||||
}
|
||||
|
||||
export class UpdateCommissionStatusDto {
|
||||
@ApiProperty({ description: 'New status', enum: CommissionStatus })
|
||||
@IsEnum(CommissionStatus)
|
||||
status: CommissionStatus;
|
||||
}
|
||||
|
||||
export class CommissionResponseDto {
|
||||
@ApiProperty()
|
||||
id: string;
|
||||
|
||||
@ApiProperty()
|
||||
tenantId: string;
|
||||
|
||||
@ApiProperty()
|
||||
nodeId: string;
|
||||
|
||||
@ApiProperty()
|
||||
sourceNodeId: string;
|
||||
|
||||
@ApiProperty({ enum: CommissionType })
|
||||
type: CommissionType;
|
||||
|
||||
@ApiProperty()
|
||||
level: number;
|
||||
|
||||
@ApiProperty()
|
||||
sourceAmount: number;
|
||||
|
||||
@ApiProperty()
|
||||
rateApplied: number;
|
||||
|
||||
@ApiProperty()
|
||||
commissionAmount: number;
|
||||
|
||||
@ApiProperty()
|
||||
currency: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
periodId: string | null;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
sourceReference: string | null;
|
||||
|
||||
@ApiProperty({ enum: CommissionStatus })
|
||||
status: CommissionStatus;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
paidAt: Date | null;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
notes: string | null;
|
||||
|
||||
@ApiProperty()
|
||||
createdAt: Date;
|
||||
|
||||
// Populated relations
|
||||
@ApiPropertyOptional()
|
||||
node?: {
|
||||
id: string;
|
||||
userId: string;
|
||||
user?: {
|
||||
email: string;
|
||||
firstName: string | null;
|
||||
lastName: string | null;
|
||||
};
|
||||
};
|
||||
|
||||
@ApiPropertyOptional()
|
||||
sourceNode?: {
|
||||
id: string;
|
||||
userId: string;
|
||||
user?: {
|
||||
email: string;
|
||||
firstName: string | null;
|
||||
lastName: string | null;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
export class CommissionFiltersDto {
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
nodeId?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
sourceNodeId?: string;
|
||||
|
||||
@ApiPropertyOptional({ enum: CommissionType })
|
||||
@IsOptional()
|
||||
@IsEnum(CommissionType)
|
||||
type?: CommissionType;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@Type(() => Number)
|
||||
level?: number;
|
||||
|
||||
@ApiPropertyOptional({ enum: CommissionStatus })
|
||||
@IsOptional()
|
||||
@IsEnum(CommissionStatus)
|
||||
status?: CommissionStatus;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
periodId?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@Type(() => Number)
|
||||
@Min(1)
|
||||
page?: number;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@Type(() => Number)
|
||||
@Min(1)
|
||||
@Max(100)
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
export class BonusResponseDto {
|
||||
@ApiProperty()
|
||||
id: string;
|
||||
|
||||
@ApiProperty()
|
||||
tenantId: string;
|
||||
|
||||
@ApiProperty()
|
||||
nodeId: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
rankId: string | null;
|
||||
|
||||
@ApiProperty({ enum: BonusType })
|
||||
type: BonusType;
|
||||
|
||||
@ApiProperty()
|
||||
amount: number;
|
||||
|
||||
@ApiProperty()
|
||||
currency: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
periodId: string | null;
|
||||
|
||||
@ApiProperty({ enum: CommissionStatus })
|
||||
status: CommissionStatus;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
paidAt: Date | null;
|
||||
|
||||
@ApiProperty()
|
||||
achievedAt: Date;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
notes: string | null;
|
||||
|
||||
@ApiProperty()
|
||||
createdAt: Date;
|
||||
}
|
||||
|
||||
export class CommissionsByLevelDto {
|
||||
@ApiProperty()
|
||||
level: number;
|
||||
|
||||
@ApiProperty()
|
||||
count: number;
|
||||
|
||||
@ApiProperty()
|
||||
totalAmount: number;
|
||||
}
|
||||
|
||||
export class EarningsSummaryDto {
|
||||
@ApiProperty()
|
||||
totalCommissions: number;
|
||||
|
||||
@ApiProperty()
|
||||
totalBonuses: number;
|
||||
|
||||
@ApiProperty()
|
||||
totalEarnings: number;
|
||||
|
||||
@ApiProperty()
|
||||
pendingAmount: number;
|
||||
|
||||
@ApiProperty()
|
||||
paidAmount: number;
|
||||
|
||||
@ApiProperty({ type: [CommissionsByLevelDto] })
|
||||
byLevel: CommissionsByLevelDto[];
|
||||
}
|
||||
4
src/modules/mlm/dto/index.ts
Normal file
4
src/modules/mlm/dto/index.ts
Normal file
@ -0,0 +1,4 @@
|
||||
export * from './structure.dto';
|
||||
export * from './rank.dto';
|
||||
export * from './node.dto';
|
||||
export * from './commission.dto';
|
||||
283
src/modules/mlm/dto/node.dto.ts
Normal file
283
src/modules/mlm/dto/node.dto.ts
Normal file
@ -0,0 +1,283 @@
|
||||
import { ApiProperty, ApiPropertyOptional, PartialType } from '@nestjs/swagger';
|
||||
import {
|
||||
IsString,
|
||||
IsUUID,
|
||||
IsNumber,
|
||||
IsOptional,
|
||||
IsEnum,
|
||||
Min,
|
||||
Max,
|
||||
} from 'class-validator';
|
||||
import { Type } from 'class-transformer';
|
||||
import { NodeStatus } from '../entities/node.entity';
|
||||
|
||||
export class CreateNodeDto {
|
||||
@ApiProperty({ description: 'Structure ID' })
|
||||
@IsUUID()
|
||||
structureId: string;
|
||||
|
||||
@ApiProperty({ description: 'User ID' })
|
||||
@IsUUID()
|
||||
userId: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Parent node ID (placement)' })
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
parentId?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Sponsor node ID (referral)' })
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
sponsorId?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Position (1=left, 2=right for binary)' })
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@Min(1)
|
||||
position?: number;
|
||||
}
|
||||
|
||||
export class UpdateNodeDto {
|
||||
@ApiPropertyOptional({ description: 'Parent node ID' })
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
parentId?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Position' })
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@Min(1)
|
||||
position?: number;
|
||||
}
|
||||
|
||||
export class UpdateNodeStatusDto {
|
||||
@ApiProperty({ description: 'New status', enum: NodeStatus })
|
||||
@IsEnum(NodeStatus)
|
||||
status: NodeStatus;
|
||||
}
|
||||
|
||||
export class NodeResponseDto {
|
||||
@ApiProperty()
|
||||
id: string;
|
||||
|
||||
@ApiProperty()
|
||||
tenantId: string;
|
||||
|
||||
@ApiProperty()
|
||||
structureId: string;
|
||||
|
||||
@ApiProperty()
|
||||
userId: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
parentId: string | null;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
sponsorId: string | null;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
position: number | null;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
path: string | null;
|
||||
|
||||
@ApiProperty()
|
||||
depth: number;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
rankId: string | null;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
highestRankId: string | null;
|
||||
|
||||
@ApiProperty()
|
||||
personalVolume: number;
|
||||
|
||||
@ApiProperty()
|
||||
groupVolume: number;
|
||||
|
||||
@ApiProperty()
|
||||
directReferrals: number;
|
||||
|
||||
@ApiProperty()
|
||||
totalDownline: number;
|
||||
|
||||
@ApiProperty()
|
||||
totalEarnings: number;
|
||||
|
||||
@ApiProperty({ enum: NodeStatus })
|
||||
status: NodeStatus;
|
||||
|
||||
@ApiProperty()
|
||||
joinedAt: Date;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
inviteCode: string | null;
|
||||
|
||||
@ApiProperty()
|
||||
createdAt: Date;
|
||||
|
||||
@ApiProperty()
|
||||
updatedAt: Date;
|
||||
|
||||
// Populated relations
|
||||
@ApiPropertyOptional()
|
||||
user?: {
|
||||
id: string;
|
||||
email: string;
|
||||
firstName: string | null;
|
||||
lastName: string | null;
|
||||
};
|
||||
|
||||
@ApiPropertyOptional()
|
||||
rank?: {
|
||||
id: string;
|
||||
name: string;
|
||||
level: number;
|
||||
color: string | null;
|
||||
};
|
||||
}
|
||||
|
||||
export class NodeFiltersDto {
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
structureId?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
parentId?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
sponsorId?: string;
|
||||
|
||||
@ApiPropertyOptional({ enum: NodeStatus })
|
||||
@IsOptional()
|
||||
@IsEnum(NodeStatus)
|
||||
status?: NodeStatus;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@Type(() => Number)
|
||||
minDepth?: number;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@Type(() => Number)
|
||||
maxDepth?: number;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
search?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@Type(() => Number)
|
||||
@Min(1)
|
||||
page?: number;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@Type(() => Number)
|
||||
@Min(1)
|
||||
@Max(100)
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
export class TreeNodeDto {
|
||||
@ApiProperty()
|
||||
id: string;
|
||||
|
||||
@ApiProperty()
|
||||
userId: string;
|
||||
|
||||
@ApiProperty()
|
||||
depth: number;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
position: number | null;
|
||||
|
||||
@ApiProperty()
|
||||
personalVolume: number;
|
||||
|
||||
@ApiProperty()
|
||||
groupVolume: number;
|
||||
|
||||
@ApiProperty()
|
||||
directReferrals: number;
|
||||
|
||||
@ApiProperty({ enum: NodeStatus })
|
||||
status: NodeStatus;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
user?: {
|
||||
id: string;
|
||||
email: string;
|
||||
firstName: string | null;
|
||||
lastName: string | null;
|
||||
};
|
||||
|
||||
@ApiPropertyOptional()
|
||||
rank?: {
|
||||
id: string;
|
||||
name: string;
|
||||
level: number;
|
||||
color: string | null;
|
||||
badgeUrl: string | null;
|
||||
};
|
||||
|
||||
@ApiProperty({ type: [TreeNodeDto] })
|
||||
children: TreeNodeDto[];
|
||||
}
|
||||
|
||||
export class InviteLinkDto {
|
||||
@ApiProperty()
|
||||
inviteCode: string;
|
||||
|
||||
@ApiProperty()
|
||||
inviteUrl: string;
|
||||
}
|
||||
|
||||
export class MyNetworkSummaryDto {
|
||||
@ApiProperty()
|
||||
totalDownline: number;
|
||||
|
||||
@ApiProperty()
|
||||
directReferrals: number;
|
||||
|
||||
@ApiProperty()
|
||||
activeDownline: number;
|
||||
|
||||
@ApiProperty()
|
||||
personalVolume: number;
|
||||
|
||||
@ApiProperty()
|
||||
groupVolume: number;
|
||||
|
||||
@ApiProperty()
|
||||
totalEarnings: number;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
currentRank: {
|
||||
id: string;
|
||||
name: string;
|
||||
level: number;
|
||||
} | null;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
nextRank: {
|
||||
id: string;
|
||||
name: string;
|
||||
level: number;
|
||||
requirements: any;
|
||||
progress: Record<string, number>;
|
||||
} | null;
|
||||
}
|
||||
114
src/modules/mlm/dto/rank.dto.ts
Normal file
114
src/modules/mlm/dto/rank.dto.ts
Normal file
@ -0,0 +1,114 @@
|
||||
import { ApiProperty, ApiPropertyOptional, PartialType } from '@nestjs/swagger';
|
||||
import {
|
||||
IsString,
|
||||
IsUUID,
|
||||
IsNumber,
|
||||
IsOptional,
|
||||
IsBoolean,
|
||||
IsObject,
|
||||
Min,
|
||||
Max,
|
||||
} from 'class-validator';
|
||||
import { RankRequirements, RankBenefits } from '../entities/rank.entity';
|
||||
|
||||
export class CreateRankDto {
|
||||
@ApiProperty({ description: 'Structure ID' })
|
||||
@IsUUID()
|
||||
structureId: string;
|
||||
|
||||
@ApiProperty({ description: 'Rank name' })
|
||||
@IsString()
|
||||
name: string;
|
||||
|
||||
@ApiProperty({ description: 'Rank level (1=Entry, higher=better)' })
|
||||
@IsNumber()
|
||||
@Min(1)
|
||||
level: number;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Badge URL' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
badgeUrl?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Color hex code' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
color?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Requirements to achieve rank' })
|
||||
@IsOptional()
|
||||
@IsObject()
|
||||
requirements?: RankRequirements;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Bonus rate for this rank' })
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
@Max(1)
|
||||
bonusRate?: number;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Benefits for this rank' })
|
||||
@IsOptional()
|
||||
@IsObject()
|
||||
benefits?: RankBenefits;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Is active', default: true })
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
isActive?: boolean;
|
||||
}
|
||||
|
||||
export class UpdateRankDto extends PartialType(CreateRankDto) {}
|
||||
|
||||
export class RankResponseDto {
|
||||
@ApiProperty()
|
||||
id: string;
|
||||
|
||||
@ApiProperty()
|
||||
tenantId: string;
|
||||
|
||||
@ApiProperty()
|
||||
structureId: string;
|
||||
|
||||
@ApiProperty()
|
||||
name: string;
|
||||
|
||||
@ApiProperty()
|
||||
level: number;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
badgeUrl: string | null;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
color: string | null;
|
||||
|
||||
@ApiProperty()
|
||||
requirements: RankRequirements;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
bonusRate: number | null;
|
||||
|
||||
@ApiProperty()
|
||||
benefits: RankBenefits;
|
||||
|
||||
@ApiProperty()
|
||||
isActive: boolean;
|
||||
|
||||
@ApiProperty()
|
||||
createdAt: Date;
|
||||
|
||||
@ApiProperty()
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
export class RankFiltersDto {
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
structureId?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
isActive?: boolean;
|
||||
}
|
||||
121
src/modules/mlm/dto/structure.dto.ts
Normal file
121
src/modules/mlm/dto/structure.dto.ts
Normal file
@ -0,0 +1,121 @@
|
||||
import { ApiProperty, ApiPropertyOptional, PartialType } from '@nestjs/swagger';
|
||||
import {
|
||||
IsString,
|
||||
IsEnum,
|
||||
IsObject,
|
||||
IsArray,
|
||||
IsOptional,
|
||||
IsBoolean,
|
||||
IsNumber,
|
||||
ValidateNested,
|
||||
Min,
|
||||
Max,
|
||||
} from 'class-validator';
|
||||
import { Type } from 'class-transformer';
|
||||
import { StructureType, LevelRate } from '../entities/structure.entity';
|
||||
|
||||
export class LevelRateDto {
|
||||
@ApiProperty({ description: 'Level number (1=direct, 2=second level, etc.)' })
|
||||
@IsNumber()
|
||||
@Min(1)
|
||||
level: number;
|
||||
|
||||
@ApiProperty({ description: 'Commission rate (0.10 = 10%)' })
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
@Max(1)
|
||||
rate: number;
|
||||
}
|
||||
|
||||
export class CreateStructureDto {
|
||||
@ApiProperty({ description: 'Structure name' })
|
||||
@IsString()
|
||||
name: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Description' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
description?: string;
|
||||
|
||||
@ApiProperty({ description: 'Structure type', enum: StructureType })
|
||||
@IsEnum(StructureType)
|
||||
type: StructureType;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Type-specific configuration' })
|
||||
@IsOptional()
|
||||
@IsObject()
|
||||
config?: Record<string, unknown>;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Commission rates by level', type: [LevelRateDto] })
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => LevelRateDto)
|
||||
levelRates?: LevelRateDto[];
|
||||
|
||||
@ApiPropertyOptional({ description: 'Matching bonus rates', type: [LevelRateDto] })
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => LevelRateDto)
|
||||
matchingRates?: LevelRateDto[];
|
||||
|
||||
@ApiPropertyOptional({ description: 'Is active', default: true })
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
isActive?: boolean;
|
||||
}
|
||||
|
||||
export class UpdateStructureDto extends PartialType(CreateStructureDto) {}
|
||||
|
||||
export class StructureResponseDto {
|
||||
@ApiProperty()
|
||||
id: string;
|
||||
|
||||
@ApiProperty()
|
||||
tenantId: string;
|
||||
|
||||
@ApiProperty()
|
||||
name: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
description: string | null;
|
||||
|
||||
@ApiProperty({ enum: StructureType })
|
||||
type: StructureType;
|
||||
|
||||
@ApiProperty()
|
||||
config: Record<string, unknown>;
|
||||
|
||||
@ApiProperty({ type: [LevelRateDto] })
|
||||
levelRates: LevelRate[];
|
||||
|
||||
@ApiProperty({ type: [LevelRateDto] })
|
||||
matchingRates: LevelRate[];
|
||||
|
||||
@ApiProperty()
|
||||
isActive: boolean;
|
||||
|
||||
@ApiProperty()
|
||||
createdAt: Date;
|
||||
|
||||
@ApiProperty()
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
export class StructureFiltersDto {
|
||||
@ApiPropertyOptional({ enum: StructureType })
|
||||
@IsOptional()
|
||||
@IsEnum(StructureType)
|
||||
type?: StructureType;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
isActive?: boolean;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
search?: string;
|
||||
}
|
||||
76
src/modules/mlm/entities/bonus.entity.ts
Normal file
76
src/modules/mlm/entities/bonus.entity.ts
Normal file
@ -0,0 +1,76 @@
|
||||
import {
|
||||
Entity,
|
||||
PrimaryGeneratedColumn,
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
ManyToOne,
|
||||
JoinColumn,
|
||||
} from 'typeorm';
|
||||
import { NodeEntity } from './node.entity';
|
||||
import { RankEntity } from './rank.entity';
|
||||
import { CommissionStatus } from './commission.entity';
|
||||
|
||||
export enum BonusType {
|
||||
RANK_ACHIEVEMENT = 'rank_achievement',
|
||||
RANK_MAINTENANCE = 'rank_maintenance',
|
||||
FAST_START = 'fast_start',
|
||||
POOL_SHARE = 'pool_share',
|
||||
}
|
||||
|
||||
@Entity({ schema: 'mlm', name: 'bonuses' })
|
||||
export class BonusEntity {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id: string;
|
||||
|
||||
@Column({ name: 'tenant_id', type: 'uuid' })
|
||||
tenantId: string;
|
||||
|
||||
@Column({ name: 'node_id', type: 'uuid' })
|
||||
nodeId: string;
|
||||
|
||||
@Column({ name: 'rank_id', type: 'uuid', nullable: true })
|
||||
rankId: string | null;
|
||||
|
||||
@Column({
|
||||
type: 'enum',
|
||||
enum: BonusType,
|
||||
})
|
||||
type: BonusType;
|
||||
|
||||
@Column({ type: 'decimal', precision: 15, scale: 2 })
|
||||
amount: number;
|
||||
|
||||
@Column({ type: 'varchar', length: 3, default: 'USD' })
|
||||
currency: string;
|
||||
|
||||
@Column({ name: 'period_id', type: 'uuid', nullable: true })
|
||||
periodId: string | null;
|
||||
|
||||
@Column({
|
||||
type: 'enum',
|
||||
enum: CommissionStatus,
|
||||
default: CommissionStatus.PENDING,
|
||||
})
|
||||
status: CommissionStatus;
|
||||
|
||||
@Column({ name: 'paid_at', type: 'timestamptz', nullable: true })
|
||||
paidAt: Date | null;
|
||||
|
||||
@Column({ name: 'achieved_at', type: 'timestamptz', default: () => 'NOW()' })
|
||||
achievedAt: Date;
|
||||
|
||||
@Column({ type: 'text', nullable: true })
|
||||
notes: string | null;
|
||||
|
||||
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
|
||||
createdAt: Date;
|
||||
|
||||
// Relations
|
||||
@ManyToOne(() => NodeEntity, (node) => node.bonuses, { onDelete: 'CASCADE' })
|
||||
@JoinColumn({ name: 'node_id' })
|
||||
node: NodeEntity;
|
||||
|
||||
@ManyToOne(() => RankEntity, { onDelete: 'SET NULL' })
|
||||
@JoinColumn({ name: 'rank_id' })
|
||||
rank: RankEntity | null;
|
||||
}
|
||||
91
src/modules/mlm/entities/commission.entity.ts
Normal file
91
src/modules/mlm/entities/commission.entity.ts
Normal file
@ -0,0 +1,91 @@
|
||||
import {
|
||||
Entity,
|
||||
PrimaryGeneratedColumn,
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
ManyToOne,
|
||||
JoinColumn,
|
||||
} from 'typeorm';
|
||||
import { NodeEntity } from './node.entity';
|
||||
|
||||
export enum CommissionType {
|
||||
LEVEL = 'level',
|
||||
MATCHING = 'matching',
|
||||
INFINITY = 'infinity',
|
||||
LEADERSHIP = 'leadership',
|
||||
POOL = 'pool',
|
||||
}
|
||||
|
||||
export enum CommissionStatus {
|
||||
PENDING = 'pending',
|
||||
APPROVED = 'approved',
|
||||
PAID = 'paid',
|
||||
CANCELLED = 'cancelled',
|
||||
}
|
||||
|
||||
@Entity({ schema: 'mlm', name: 'commissions' })
|
||||
export class CommissionEntity {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id: string;
|
||||
|
||||
@Column({ name: 'tenant_id', type: 'uuid' })
|
||||
tenantId: string;
|
||||
|
||||
@Column({ name: 'node_id', type: 'uuid' })
|
||||
nodeId: string;
|
||||
|
||||
@Column({ name: 'source_node_id', type: 'uuid' })
|
||||
sourceNodeId: string;
|
||||
|
||||
@Column({
|
||||
type: 'enum',
|
||||
enum: CommissionType,
|
||||
})
|
||||
type: CommissionType;
|
||||
|
||||
@Column({ type: 'integer' })
|
||||
level: number;
|
||||
|
||||
@Column({ name: 'source_amount', type: 'decimal', precision: 15, scale: 2 })
|
||||
sourceAmount: number;
|
||||
|
||||
@Column({ name: 'rate_applied', type: 'decimal', precision: 10, scale: 4 })
|
||||
rateApplied: number;
|
||||
|
||||
@Column({ name: 'commission_amount', type: 'decimal', precision: 15, scale: 2 })
|
||||
commissionAmount: number;
|
||||
|
||||
@Column({ type: 'varchar', length: 3, default: 'USD' })
|
||||
currency: string;
|
||||
|
||||
@Column({ name: 'period_id', type: 'uuid', nullable: true })
|
||||
periodId: string | null;
|
||||
|
||||
@Column({ name: 'source_reference', type: 'varchar', length: 200, nullable: true })
|
||||
sourceReference: string | null;
|
||||
|
||||
@Column({
|
||||
type: 'enum',
|
||||
enum: CommissionStatus,
|
||||
default: CommissionStatus.PENDING,
|
||||
})
|
||||
status: CommissionStatus;
|
||||
|
||||
@Column({ name: 'paid_at', type: 'timestamptz', nullable: true })
|
||||
paidAt: Date | null;
|
||||
|
||||
@Column({ type: 'text', nullable: true })
|
||||
notes: string | null;
|
||||
|
||||
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
|
||||
createdAt: Date;
|
||||
|
||||
// Relations
|
||||
@ManyToOne(() => NodeEntity, (node) => node.commissionsReceived, { onDelete: 'CASCADE' })
|
||||
@JoinColumn({ name: 'node_id' })
|
||||
node: NodeEntity;
|
||||
|
||||
@ManyToOne(() => NodeEntity, (node) => node.commissionsGenerated, { onDelete: 'CASCADE' })
|
||||
@JoinColumn({ name: 'source_node_id' })
|
||||
sourceNode: NodeEntity;
|
||||
}
|
||||
6
src/modules/mlm/entities/index.ts
Normal file
6
src/modules/mlm/entities/index.ts
Normal file
@ -0,0 +1,6 @@
|
||||
export * from './structure.entity';
|
||||
export * from './rank.entity';
|
||||
export * from './node.entity';
|
||||
export * from './commission.entity';
|
||||
export * from './bonus.entity';
|
||||
export * from './rank-history.entity';
|
||||
132
src/modules/mlm/entities/node.entity.ts
Normal file
132
src/modules/mlm/entities/node.entity.ts
Normal file
@ -0,0 +1,132 @@
|
||||
import {
|
||||
Entity,
|
||||
PrimaryGeneratedColumn,
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
UpdateDateColumn,
|
||||
ManyToOne,
|
||||
OneToMany,
|
||||
JoinColumn,
|
||||
} from 'typeorm';
|
||||
import { StructureEntity } from './structure.entity';
|
||||
import { RankEntity } from './rank.entity';
|
||||
import { CommissionEntity } from './commission.entity';
|
||||
import { BonusEntity } from './bonus.entity';
|
||||
import { RankHistoryEntity } from './rank-history.entity';
|
||||
|
||||
export enum NodeStatus {
|
||||
PENDING = 'pending',
|
||||
ACTIVE = 'active',
|
||||
INACTIVE = 'inactive',
|
||||
SUSPENDED = 'suspended',
|
||||
}
|
||||
|
||||
@Entity({ schema: 'mlm', name: 'nodes' })
|
||||
export class NodeEntity {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id: string;
|
||||
|
||||
@Column({ name: 'tenant_id', type: 'uuid' })
|
||||
tenantId: string;
|
||||
|
||||
@Column({ name: 'structure_id', type: 'uuid' })
|
||||
structureId: string;
|
||||
|
||||
@Column({ name: 'user_id', type: 'uuid' })
|
||||
userId: string;
|
||||
|
||||
@Column({ name: 'parent_id', type: 'uuid', nullable: true })
|
||||
parentId: string | null;
|
||||
|
||||
@Column({ name: 'sponsor_id', type: 'uuid', nullable: true })
|
||||
sponsorId: string | null;
|
||||
|
||||
@Column({ type: 'integer', nullable: true })
|
||||
position: number | null;
|
||||
|
||||
// LTREE path - stored as string, handled specially in queries
|
||||
@Column({ type: 'text', nullable: true })
|
||||
path: string | null;
|
||||
|
||||
@Column({ type: 'integer', default: 0 })
|
||||
depth: number;
|
||||
|
||||
@Column({ name: 'rank_id', type: 'uuid', nullable: true })
|
||||
rankId: string | null;
|
||||
|
||||
@Column({ name: 'highest_rank_id', type: 'uuid', nullable: true })
|
||||
highestRankId: string | null;
|
||||
|
||||
@Column({ name: 'personal_volume', type: 'decimal', precision: 15, scale: 2, default: 0 })
|
||||
personalVolume: number;
|
||||
|
||||
@Column({ name: 'group_volume', type: 'decimal', precision: 15, scale: 2, default: 0 })
|
||||
groupVolume: number;
|
||||
|
||||
@Column({ name: 'direct_referrals', type: 'integer', default: 0 })
|
||||
directReferrals: number;
|
||||
|
||||
@Column({ name: 'total_downline', type: 'integer', default: 0 })
|
||||
totalDownline: number;
|
||||
|
||||
@Column({ name: 'total_earnings', type: 'decimal', precision: 15, scale: 2, default: 0 })
|
||||
totalEarnings: number;
|
||||
|
||||
@Column({
|
||||
type: 'enum',
|
||||
enum: NodeStatus,
|
||||
default: NodeStatus.ACTIVE,
|
||||
})
|
||||
status: NodeStatus;
|
||||
|
||||
@Column({ name: 'joined_at', type: 'timestamptz', default: () => 'NOW()' })
|
||||
joinedAt: Date;
|
||||
|
||||
@Column({ name: 'invite_code', type: 'varchar', length: 20, nullable: true, unique: true })
|
||||
inviteCode: string | null;
|
||||
|
||||
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
|
||||
createdAt: Date;
|
||||
|
||||
@UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' })
|
||||
updatedAt: Date;
|
||||
|
||||
// Relations
|
||||
@ManyToOne(() => StructureEntity, (structure) => structure.nodes, { onDelete: 'CASCADE' })
|
||||
@JoinColumn({ name: 'structure_id' })
|
||||
structure: StructureEntity;
|
||||
|
||||
@ManyToOne(() => NodeEntity, { onDelete: 'SET NULL' })
|
||||
@JoinColumn({ name: 'parent_id' })
|
||||
parent: NodeEntity | null;
|
||||
|
||||
@ManyToOne(() => NodeEntity, { onDelete: 'SET NULL' })
|
||||
@JoinColumn({ name: 'sponsor_id' })
|
||||
sponsor: NodeEntity | null;
|
||||
|
||||
@OneToMany(() => NodeEntity, (node) => node.parent)
|
||||
children: NodeEntity[];
|
||||
|
||||
@OneToMany(() => NodeEntity, (node) => node.sponsor)
|
||||
referrals: NodeEntity[];
|
||||
|
||||
@ManyToOne(() => RankEntity, (rank) => rank.currentNodes, { onDelete: 'SET NULL' })
|
||||
@JoinColumn({ name: 'rank_id' })
|
||||
rank: RankEntity | null;
|
||||
|
||||
@ManyToOne(() => RankEntity, (rank) => rank.highestRankNodes, { onDelete: 'SET NULL' })
|
||||
@JoinColumn({ name: 'highest_rank_id' })
|
||||
highestRank: RankEntity | null;
|
||||
|
||||
@OneToMany(() => CommissionEntity, (commission) => commission.node)
|
||||
commissionsReceived: CommissionEntity[];
|
||||
|
||||
@OneToMany(() => CommissionEntity, (commission) => commission.sourceNode)
|
||||
commissionsGenerated: CommissionEntity[];
|
||||
|
||||
@OneToMany(() => BonusEntity, (bonus) => bonus.node)
|
||||
bonuses: BonusEntity[];
|
||||
|
||||
@OneToMany(() => RankHistoryEntity, (history) => history.node)
|
||||
rankHistory: RankHistoryEntity[];
|
||||
}
|
||||
53
src/modules/mlm/entities/rank-history.entity.ts
Normal file
53
src/modules/mlm/entities/rank-history.entity.ts
Normal file
@ -0,0 +1,53 @@
|
||||
import {
|
||||
Entity,
|
||||
PrimaryGeneratedColumn,
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
ManyToOne,
|
||||
JoinColumn,
|
||||
} from 'typeorm';
|
||||
import { NodeEntity } from './node.entity';
|
||||
import { RankEntity } from './rank.entity';
|
||||
|
||||
@Entity({ schema: 'mlm', name: 'rank_history' })
|
||||
export class RankHistoryEntity {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id: string;
|
||||
|
||||
@Column({ name: 'tenant_id', type: 'uuid' })
|
||||
tenantId: string;
|
||||
|
||||
@Column({ name: 'node_id', type: 'uuid' })
|
||||
nodeId: string;
|
||||
|
||||
@Column({ name: 'rank_id', type: 'uuid' })
|
||||
rankId: string;
|
||||
|
||||
@Column({ name: 'previous_rank_id', type: 'uuid', nullable: true })
|
||||
previousRankId: string | null;
|
||||
|
||||
@Column({ name: 'personal_volume_at', type: 'decimal', precision: 15, scale: 2, nullable: true })
|
||||
personalVolumeAt: number | null;
|
||||
|
||||
@Column({ name: 'group_volume_at', type: 'decimal', precision: 15, scale: 2, nullable: true })
|
||||
groupVolumeAt: number | null;
|
||||
|
||||
@Column({ name: 'direct_referrals_at', type: 'integer', nullable: true })
|
||||
directReferralsAt: number | null;
|
||||
|
||||
@CreateDateColumn({ name: 'achieved_at', type: 'timestamptz' })
|
||||
achievedAt: Date;
|
||||
|
||||
// Relations
|
||||
@ManyToOne(() => NodeEntity, (node) => node.rankHistory, { onDelete: 'CASCADE' })
|
||||
@JoinColumn({ name: 'node_id' })
|
||||
node: NodeEntity;
|
||||
|
||||
@ManyToOne(() => RankEntity, { onDelete: 'CASCADE' })
|
||||
@JoinColumn({ name: 'rank_id' })
|
||||
rank: RankEntity;
|
||||
|
||||
@ManyToOne(() => RankEntity, { onDelete: 'SET NULL' })
|
||||
@JoinColumn({ name: 'previous_rank_id' })
|
||||
previousRank: RankEntity | null;
|
||||
}
|
||||
82
src/modules/mlm/entities/rank.entity.ts
Normal file
82
src/modules/mlm/entities/rank.entity.ts
Normal file
@ -0,0 +1,82 @@
|
||||
import {
|
||||
Entity,
|
||||
PrimaryGeneratedColumn,
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
UpdateDateColumn,
|
||||
ManyToOne,
|
||||
OneToMany,
|
||||
JoinColumn,
|
||||
} from 'typeorm';
|
||||
import { StructureEntity } from './structure.entity';
|
||||
import { NodeEntity } from './node.entity';
|
||||
|
||||
export interface RankRequirements {
|
||||
personalVolume?: number;
|
||||
groupVolume?: number;
|
||||
directReferrals?: number;
|
||||
activeLegs?: number;
|
||||
rankInLegs?: {
|
||||
rankLevel: number;
|
||||
count: number;
|
||||
};
|
||||
}
|
||||
|
||||
export interface RankBenefits {
|
||||
discount?: number;
|
||||
access?: string[];
|
||||
features?: string[];
|
||||
}
|
||||
|
||||
@Entity({ schema: 'mlm', name: 'ranks' })
|
||||
export class RankEntity {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id: string;
|
||||
|
||||
@Column({ name: 'tenant_id', type: 'uuid' })
|
||||
tenantId: string;
|
||||
|
||||
@Column({ name: 'structure_id', type: 'uuid' })
|
||||
structureId: string;
|
||||
|
||||
@Column({ type: 'varchar', length: 100 })
|
||||
name: string;
|
||||
|
||||
@Column({ type: 'integer' })
|
||||
level: number;
|
||||
|
||||
@Column({ name: 'badge_url', type: 'varchar', length: 500, nullable: true })
|
||||
badgeUrl: string | null;
|
||||
|
||||
@Column({ type: 'varchar', length: 7, nullable: true })
|
||||
color: string | null;
|
||||
|
||||
@Column({ type: 'jsonb', default: {} })
|
||||
requirements: RankRequirements;
|
||||
|
||||
@Column({ name: 'bonus_rate', type: 'decimal', precision: 10, scale: 4, nullable: true })
|
||||
bonusRate: number | null;
|
||||
|
||||
@Column({ type: 'jsonb', default: {} })
|
||||
benefits: RankBenefits;
|
||||
|
||||
@Column({ name: 'is_active', default: true })
|
||||
isActive: boolean;
|
||||
|
||||
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
|
||||
createdAt: Date;
|
||||
|
||||
@UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' })
|
||||
updatedAt: Date;
|
||||
|
||||
// Relations
|
||||
@ManyToOne(() => StructureEntity, (structure) => structure.ranks, { onDelete: 'CASCADE' })
|
||||
@JoinColumn({ name: 'structure_id' })
|
||||
structure: StructureEntity;
|
||||
|
||||
@OneToMany(() => NodeEntity, (node) => node.rank)
|
||||
currentNodes: NodeEntity[];
|
||||
|
||||
@OneToMany(() => NodeEntity, (node) => node.highestRank)
|
||||
highestRankNodes: NodeEntity[];
|
||||
}
|
||||
87
src/modules/mlm/entities/structure.entity.ts
Normal file
87
src/modules/mlm/entities/structure.entity.ts
Normal file
@ -0,0 +1,87 @@
|
||||
import {
|
||||
Entity,
|
||||
PrimaryGeneratedColumn,
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
UpdateDateColumn,
|
||||
OneToMany,
|
||||
} from 'typeorm';
|
||||
import { NodeEntity } from './node.entity';
|
||||
import { RankEntity } from './rank.entity';
|
||||
|
||||
export enum StructureType {
|
||||
UNILEVEL = 'unilevel',
|
||||
BINARY = 'binary',
|
||||
MATRIX = 'matrix',
|
||||
HYBRID = 'hybrid',
|
||||
}
|
||||
|
||||
export interface UnilevelConfig {
|
||||
maxWidth?: number | null;
|
||||
maxDepth: number;
|
||||
}
|
||||
|
||||
export interface BinaryConfig {
|
||||
spillover: 'left_first' | 'weak_leg' | 'balanced';
|
||||
}
|
||||
|
||||
export interface MatrixConfig {
|
||||
width: number;
|
||||
depth: number;
|
||||
}
|
||||
|
||||
export type StructureConfig = UnilevelConfig | BinaryConfig | MatrixConfig;
|
||||
|
||||
export interface LevelRate {
|
||||
level: number;
|
||||
rate: number;
|
||||
}
|
||||
|
||||
@Entity({ schema: 'mlm', name: 'structures' })
|
||||
export class StructureEntity {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id: string;
|
||||
|
||||
@Column({ name: 'tenant_id', type: 'uuid' })
|
||||
tenantId: string;
|
||||
|
||||
@Column({ type: 'varchar', length: 100 })
|
||||
name: string;
|
||||
|
||||
@Column({ type: 'text', nullable: true })
|
||||
description: string | null;
|
||||
|
||||
@Column({
|
||||
type: 'enum',
|
||||
enum: StructureType,
|
||||
})
|
||||
type: StructureType;
|
||||
|
||||
@Column({ type: 'jsonb', default: {} })
|
||||
config: StructureConfig;
|
||||
|
||||
@Column({ name: 'level_rates', type: 'jsonb', default: [] })
|
||||
levelRates: LevelRate[];
|
||||
|
||||
@Column({ name: 'matching_rates', type: 'jsonb', default: [] })
|
||||
matchingRates: LevelRate[];
|
||||
|
||||
@Column({ name: 'is_active', default: true })
|
||||
isActive: boolean;
|
||||
|
||||
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
|
||||
createdAt: Date;
|
||||
|
||||
@UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' })
|
||||
updatedAt: Date;
|
||||
|
||||
@Column({ name: 'created_by', type: 'uuid', nullable: true })
|
||||
createdBy: string | null;
|
||||
|
||||
// Relations
|
||||
@OneToMany(() => NodeEntity, (node) => node.structure)
|
||||
nodes: NodeEntity[];
|
||||
|
||||
@OneToMany(() => RankEntity, (rank) => rank.structure)
|
||||
ranks: RankEntity[];
|
||||
}
|
||||
5
src/modules/mlm/index.ts
Normal file
5
src/modules/mlm/index.ts
Normal file
@ -0,0 +1,5 @@
|
||||
export * from './mlm.module';
|
||||
export * from './entities';
|
||||
export * from './dto';
|
||||
export * from './services';
|
||||
export * from './controllers';
|
||||
54
src/modules/mlm/mlm.module.ts
Normal file
54
src/modules/mlm/mlm.module.ts
Normal file
@ -0,0 +1,54 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import {
|
||||
StructureEntity,
|
||||
RankEntity,
|
||||
NodeEntity,
|
||||
CommissionEntity,
|
||||
BonusEntity,
|
||||
RankHistoryEntity,
|
||||
} from './entities';
|
||||
import {
|
||||
StructuresService,
|
||||
RanksService,
|
||||
NodesService,
|
||||
CommissionsService,
|
||||
} from './services';
|
||||
import {
|
||||
StructuresController,
|
||||
RanksController,
|
||||
NodesController,
|
||||
CommissionsController,
|
||||
} from './controllers';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
TypeOrmModule.forFeature([
|
||||
StructureEntity,
|
||||
RankEntity,
|
||||
NodeEntity,
|
||||
CommissionEntity,
|
||||
BonusEntity,
|
||||
RankHistoryEntity,
|
||||
]),
|
||||
],
|
||||
controllers: [
|
||||
StructuresController,
|
||||
RanksController,
|
||||
NodesController,
|
||||
CommissionsController,
|
||||
],
|
||||
providers: [
|
||||
StructuresService,
|
||||
RanksService,
|
||||
NodesService,
|
||||
CommissionsService,
|
||||
],
|
||||
exports: [
|
||||
StructuresService,
|
||||
RanksService,
|
||||
NodesService,
|
||||
CommissionsService,
|
||||
],
|
||||
})
|
||||
export class MlmModule {}
|
||||
275
src/modules/mlm/services/commissions.service.ts
Normal file
275
src/modules/mlm/services/commissions.service.ts
Normal file
@ -0,0 +1,275 @@
|
||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { CommissionEntity, CommissionType, CommissionStatus } from '../entities/commission.entity';
|
||||
import { BonusEntity, BonusType } from '../entities/bonus.entity';
|
||||
import { NodeEntity } from '../entities/node.entity';
|
||||
import { StructureEntity } from '../entities/structure.entity';
|
||||
import {
|
||||
CalculateCommissionsDto,
|
||||
UpdateCommissionStatusDto,
|
||||
CommissionFiltersDto,
|
||||
CommissionsByLevelDto,
|
||||
EarningsSummaryDto,
|
||||
} from '../dto/commission.dto';
|
||||
|
||||
@Injectable()
|
||||
export class CommissionsService {
|
||||
constructor(
|
||||
@InjectRepository(CommissionEntity)
|
||||
private readonly commissionRepository: Repository<CommissionEntity>,
|
||||
@InjectRepository(BonusEntity)
|
||||
private readonly bonusRepository: Repository<BonusEntity>,
|
||||
@InjectRepository(NodeEntity)
|
||||
private readonly nodeRepository: Repository<NodeEntity>,
|
||||
@InjectRepository(StructureEntity)
|
||||
private readonly structureRepository: Repository<StructureEntity>,
|
||||
) {}
|
||||
|
||||
async findAll(
|
||||
tenantId: string,
|
||||
filters?: CommissionFiltersDto,
|
||||
): Promise<{ items: CommissionEntity[]; total: number }> {
|
||||
const where: any = { tenantId };
|
||||
|
||||
if (filters?.nodeId) {
|
||||
where.nodeId = filters.nodeId;
|
||||
}
|
||||
if (filters?.sourceNodeId) {
|
||||
where.sourceNodeId = filters.sourceNodeId;
|
||||
}
|
||||
if (filters?.type) {
|
||||
where.type = filters.type;
|
||||
}
|
||||
if (filters?.level) {
|
||||
where.level = filters.level;
|
||||
}
|
||||
if (filters?.status) {
|
||||
where.status = filters.status;
|
||||
}
|
||||
if (filters?.periodId) {
|
||||
where.periodId = filters.periodId;
|
||||
}
|
||||
|
||||
const page = filters?.page || 1;
|
||||
const limit = filters?.limit || 50;
|
||||
const skip = (page - 1) * limit;
|
||||
|
||||
const [items, total] = await this.commissionRepository.findAndCount({
|
||||
where,
|
||||
skip,
|
||||
take: limit,
|
||||
order: { createdAt: 'DESC' },
|
||||
});
|
||||
|
||||
return { items, total };
|
||||
}
|
||||
|
||||
async findOne(tenantId: string, id: string): Promise<CommissionEntity> {
|
||||
const commission = await this.commissionRepository.findOne({
|
||||
where: { id, tenantId },
|
||||
});
|
||||
|
||||
if (!commission) {
|
||||
throw new NotFoundException(`Commission with ID ${id} not found`);
|
||||
}
|
||||
|
||||
return commission;
|
||||
}
|
||||
|
||||
async calculateCommissions(
|
||||
tenantId: string,
|
||||
dto: CalculateCommissionsDto,
|
||||
): Promise<CommissionEntity[]> {
|
||||
// Get source node and its upline
|
||||
const sourceNode = await this.nodeRepository.findOne({
|
||||
where: { id: dto.sourceNodeId, tenantId },
|
||||
});
|
||||
|
||||
if (!sourceNode) {
|
||||
throw new NotFoundException(`Source node with ID ${dto.sourceNodeId} not found`);
|
||||
}
|
||||
|
||||
// Get structure for commission rates
|
||||
const structure = await this.structureRepository.findOne({
|
||||
where: { id: sourceNode.structureId, tenantId },
|
||||
});
|
||||
|
||||
if (!structure) {
|
||||
throw new NotFoundException('Structure not found');
|
||||
}
|
||||
|
||||
// Get upline (ancestors)
|
||||
const upline = await this.getUpline(tenantId, dto.sourceNodeId);
|
||||
|
||||
const commissions: CommissionEntity[] = [];
|
||||
|
||||
// Calculate level commissions for each ancestor
|
||||
for (let i = 0; i < upline.length; i++) {
|
||||
const level = i + 1;
|
||||
const levelRate = structure.levelRates.find((lr) => lr.level === level);
|
||||
|
||||
if (!levelRate) {
|
||||
continue; // No rate defined for this level
|
||||
}
|
||||
|
||||
const commissionAmount = dto.amount * levelRate.rate;
|
||||
|
||||
const commission = this.commissionRepository.create({
|
||||
tenantId,
|
||||
nodeId: upline[i].id,
|
||||
sourceNodeId: dto.sourceNodeId,
|
||||
type: CommissionType.LEVEL,
|
||||
level,
|
||||
sourceAmount: dto.amount,
|
||||
rateApplied: levelRate.rate,
|
||||
commissionAmount,
|
||||
currency: dto.currency || 'USD',
|
||||
periodId: dto.periodId,
|
||||
sourceReference: dto.sourceReference,
|
||||
status: CommissionStatus.PENDING,
|
||||
});
|
||||
|
||||
commissions.push(commission);
|
||||
}
|
||||
|
||||
// Save all commissions
|
||||
if (commissions.length > 0) {
|
||||
await this.commissionRepository.save(commissions);
|
||||
|
||||
// Update total earnings for each beneficiary
|
||||
for (const commission of commissions) {
|
||||
await this.nodeRepository.increment(
|
||||
{ id: commission.nodeId },
|
||||
'totalEarnings',
|
||||
commission.commissionAmount,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return commissions;
|
||||
}
|
||||
|
||||
async updateStatus(
|
||||
tenantId: string,
|
||||
id: string,
|
||||
dto: UpdateCommissionStatusDto,
|
||||
): Promise<CommissionEntity> {
|
||||
const commission = await this.findOne(tenantId, id);
|
||||
|
||||
commission.status = dto.status;
|
||||
if (dto.status === CommissionStatus.PAID) {
|
||||
commission.paidAt = new Date();
|
||||
}
|
||||
|
||||
return this.commissionRepository.save(commission);
|
||||
}
|
||||
|
||||
async getCommissionsByLevel(tenantId: string, nodeId?: string): Promise<CommissionsByLevelDto[]> {
|
||||
const query = this.commissionRepository
|
||||
.createQueryBuilder('c')
|
||||
.select('c.level', 'level')
|
||||
.addSelect('COUNT(*)', 'count')
|
||||
.addSelect('SUM(c.commission_amount)', 'totalAmount')
|
||||
.where('c.tenant_id = :tenantId', { tenantId });
|
||||
|
||||
if (nodeId) {
|
||||
query.andWhere('c.node_id = :nodeId', { nodeId });
|
||||
}
|
||||
|
||||
const result = await query.groupBy('c.level').orderBy('c.level', 'ASC').getRawMany();
|
||||
|
||||
return result.map((r) => ({
|
||||
level: parseInt(r.level),
|
||||
count: parseInt(r.count),
|
||||
totalAmount: parseFloat(r.totalAmount) || 0,
|
||||
}));
|
||||
}
|
||||
|
||||
async getEarningsSummary(tenantId: string, nodeId: string): Promise<EarningsSummaryDto> {
|
||||
// Get commission totals
|
||||
const commissionsResult = await this.commissionRepository
|
||||
.createQueryBuilder('c')
|
||||
.select('SUM(c.commission_amount)', 'total')
|
||||
.addSelect("SUM(CASE WHEN c.status = 'pending' THEN c.commission_amount ELSE 0 END)", 'pending')
|
||||
.addSelect("SUM(CASE WHEN c.status = 'paid' THEN c.commission_amount ELSE 0 END)", 'paid')
|
||||
.where('c.tenant_id = :tenantId', { tenantId })
|
||||
.andWhere('c.node_id = :nodeId', { nodeId })
|
||||
.getRawOne();
|
||||
|
||||
// Get bonus totals
|
||||
const bonusesResult = await this.bonusRepository
|
||||
.createQueryBuilder('b')
|
||||
.select('SUM(b.amount)', 'total')
|
||||
.where('b.tenant_id = :tenantId', { tenantId })
|
||||
.andWhere('b.node_id = :nodeId', { nodeId })
|
||||
.getRawOne();
|
||||
|
||||
// Get by level breakdown
|
||||
const byLevel = await this.getCommissionsByLevel(tenantId, nodeId);
|
||||
|
||||
const totalCommissions = parseFloat(commissionsResult?.total) || 0;
|
||||
const totalBonuses = parseFloat(bonusesResult?.total) || 0;
|
||||
|
||||
return {
|
||||
totalCommissions,
|
||||
totalBonuses,
|
||||
totalEarnings: totalCommissions + totalBonuses,
|
||||
pendingAmount: parseFloat(commissionsResult?.pending) || 0,
|
||||
paidAmount: parseFloat(commissionsResult?.paid) || 0,
|
||||
byLevel,
|
||||
};
|
||||
}
|
||||
|
||||
async createBonus(
|
||||
tenantId: string,
|
||||
nodeId: string,
|
||||
type: BonusType,
|
||||
amount: number,
|
||||
rankId?: string,
|
||||
periodId?: string,
|
||||
): Promise<BonusEntity> {
|
||||
const bonus = this.bonusRepository.create({
|
||||
tenantId,
|
||||
nodeId,
|
||||
rankId,
|
||||
type,
|
||||
amount,
|
||||
currency: 'USD',
|
||||
periodId,
|
||||
status: CommissionStatus.PENDING,
|
||||
});
|
||||
|
||||
await this.bonusRepository.save(bonus);
|
||||
|
||||
// Update node's total earnings
|
||||
await this.nodeRepository.increment({ id: nodeId }, 'totalEarnings', amount);
|
||||
|
||||
return bonus;
|
||||
}
|
||||
|
||||
private async getUpline(tenantId: string, nodeId: string): Promise<NodeEntity[]> {
|
||||
const node = await this.nodeRepository.findOne({
|
||||
where: { id: nodeId, tenantId },
|
||||
});
|
||||
|
||||
if (!node || !node.path) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// Get ancestor IDs from path
|
||||
const pathParts = node.path.split('.').slice(0, -1); // Exclude self
|
||||
const ancestorPaths = pathParts.map((_, i) => pathParts.slice(0, i + 1).join('.'));
|
||||
|
||||
if (ancestorPaths.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return this.nodeRepository
|
||||
.createQueryBuilder('node')
|
||||
.where('node.tenant_id = :tenantId', { tenantId })
|
||||
.andWhere('node.path IN (:...paths)', { paths: ancestorPaths })
|
||||
.orderBy('node.depth', 'DESC') // Closest first
|
||||
.getMany();
|
||||
}
|
||||
}
|
||||
4
src/modules/mlm/services/index.ts
Normal file
4
src/modules/mlm/services/index.ts
Normal file
@ -0,0 +1,4 @@
|
||||
export * from './structures.service';
|
||||
export * from './ranks.service';
|
||||
export * from './nodes.service';
|
||||
export * from './commissions.service';
|
||||
395
src/modules/mlm/services/nodes.service.ts
Normal file
395
src/modules/mlm/services/nodes.service.ts
Normal file
@ -0,0 +1,395 @@
|
||||
import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository, ILike } from 'typeorm';
|
||||
import { randomBytes } from 'crypto';
|
||||
import { NodeEntity, NodeStatus } from '../entities/node.entity';
|
||||
import { StructureEntity, StructureType } from '../entities/structure.entity';
|
||||
import { RankEntity } from '../entities/rank.entity';
|
||||
import {
|
||||
CreateNodeDto,
|
||||
UpdateNodeDto,
|
||||
UpdateNodeStatusDto,
|
||||
NodeFiltersDto,
|
||||
TreeNodeDto,
|
||||
MyNetworkSummaryDto,
|
||||
} from '../dto/node.dto';
|
||||
|
||||
@Injectable()
|
||||
export class NodesService {
|
||||
constructor(
|
||||
@InjectRepository(NodeEntity)
|
||||
private readonly nodeRepository: Repository<NodeEntity>,
|
||||
@InjectRepository(StructureEntity)
|
||||
private readonly structureRepository: Repository<StructureEntity>,
|
||||
@InjectRepository(RankEntity)
|
||||
private readonly rankRepository: Repository<RankEntity>,
|
||||
) {}
|
||||
|
||||
async findAll(
|
||||
tenantId: string,
|
||||
filters?: NodeFiltersDto,
|
||||
): Promise<{ items: NodeEntity[]; total: number }> {
|
||||
const where: any = { tenantId };
|
||||
|
||||
if (filters?.structureId) {
|
||||
where.structureId = filters.structureId;
|
||||
}
|
||||
if (filters?.parentId) {
|
||||
where.parentId = filters.parentId;
|
||||
}
|
||||
if (filters?.sponsorId) {
|
||||
where.sponsorId = filters.sponsorId;
|
||||
}
|
||||
if (filters?.status) {
|
||||
where.status = filters.status;
|
||||
}
|
||||
|
||||
const page = filters?.page || 1;
|
||||
const limit = filters?.limit || 50;
|
||||
const skip = (page - 1) * limit;
|
||||
|
||||
const [items, total] = await this.nodeRepository.findAndCount({
|
||||
where,
|
||||
skip,
|
||||
take: limit,
|
||||
order: { createdAt: 'DESC' },
|
||||
});
|
||||
|
||||
return { items, total };
|
||||
}
|
||||
|
||||
async findOne(tenantId: string, id: string): Promise<NodeEntity> {
|
||||
const node = await this.nodeRepository.findOne({
|
||||
where: { id, tenantId },
|
||||
relations: ['rank', 'highestRank'],
|
||||
});
|
||||
|
||||
if (!node) {
|
||||
throw new NotFoundException(`Node with ID ${id} not found`);
|
||||
}
|
||||
|
||||
return node;
|
||||
}
|
||||
|
||||
async findByUserId(tenantId: string, userId: string, structureId?: string): Promise<NodeEntity | null> {
|
||||
const where: any = { tenantId, userId };
|
||||
if (structureId) {
|
||||
where.structureId = structureId;
|
||||
}
|
||||
|
||||
return this.nodeRepository.findOne({ where });
|
||||
}
|
||||
|
||||
async create(tenantId: string, dto: CreateNodeDto): Promise<NodeEntity> {
|
||||
// Verify structure exists
|
||||
const structure = await this.structureRepository.findOne({
|
||||
where: { id: dto.structureId, tenantId },
|
||||
});
|
||||
|
||||
if (!structure) {
|
||||
throw new NotFoundException(`Structure with ID ${dto.structureId} not found`);
|
||||
}
|
||||
|
||||
// Check if user already exists in this structure
|
||||
const existingNode = await this.nodeRepository.findOne({
|
||||
where: { structureId: dto.structureId, userId: dto.userId },
|
||||
});
|
||||
|
||||
if (existingNode) {
|
||||
throw new BadRequestException('User already exists in this structure');
|
||||
}
|
||||
|
||||
// Calculate depth and path
|
||||
let depth = 0;
|
||||
let path = '';
|
||||
let parentNode: NodeEntity | null = null;
|
||||
|
||||
if (dto.parentId) {
|
||||
parentNode = await this.nodeRepository.findOne({
|
||||
where: { id: dto.parentId, tenantId },
|
||||
});
|
||||
|
||||
if (!parentNode) {
|
||||
throw new NotFoundException(`Parent node with ID ${dto.parentId} not found`);
|
||||
}
|
||||
|
||||
depth = parentNode.depth + 1;
|
||||
path = parentNode.path ? `${parentNode.path}.` : '';
|
||||
}
|
||||
|
||||
// Validate position for binary structure
|
||||
if (structure.type === StructureType.BINARY) {
|
||||
if (dto.position && (dto.position < 1 || dto.position > 2)) {
|
||||
throw new BadRequestException('Position must be 1 (left) or 2 (right) for binary structure');
|
||||
}
|
||||
}
|
||||
|
||||
// Generate invite code
|
||||
const inviteCode = randomBytes(8).toString('hex').toUpperCase();
|
||||
|
||||
// Create node (we'll update path after we have the ID)
|
||||
const node = this.nodeRepository.create({
|
||||
...dto,
|
||||
tenantId,
|
||||
depth,
|
||||
inviteCode,
|
||||
});
|
||||
|
||||
const savedNode = await this.nodeRepository.save(node);
|
||||
|
||||
// Update path with the node's own ID
|
||||
savedNode.path = path + savedNode.id.replace(/-/g, '_');
|
||||
await this.nodeRepository.save(savedNode);
|
||||
|
||||
// Update parent's metrics
|
||||
if (parentNode) {
|
||||
await this.updateParentMetrics(parentNode.id);
|
||||
}
|
||||
|
||||
// Update sponsor's metrics
|
||||
if (dto.sponsorId && dto.sponsorId !== dto.parentId) {
|
||||
await this.updateSponsorMetrics(dto.sponsorId);
|
||||
}
|
||||
|
||||
return savedNode;
|
||||
}
|
||||
|
||||
async update(tenantId: string, id: string, dto: UpdateNodeDto): Promise<NodeEntity> {
|
||||
const node = await this.findOne(tenantId, id);
|
||||
|
||||
if (dto.parentId) {
|
||||
const newParent = await this.nodeRepository.findOne({
|
||||
where: { id: dto.parentId, tenantId },
|
||||
});
|
||||
|
||||
if (!newParent) {
|
||||
throw new NotFoundException(`Parent node with ID ${dto.parentId} not found`);
|
||||
}
|
||||
|
||||
// Update depth and path
|
||||
node.parentId = dto.parentId;
|
||||
node.depth = newParent.depth + 1;
|
||||
node.path = newParent.path ? `${newParent.path}.${node.id.replace(/-/g, '_')}` : node.id.replace(/-/g, '_');
|
||||
}
|
||||
|
||||
if (dto.position !== undefined) {
|
||||
node.position = dto.position;
|
||||
}
|
||||
|
||||
return this.nodeRepository.save(node);
|
||||
}
|
||||
|
||||
async updateStatus(tenantId: string, id: string, dto: UpdateNodeStatusDto): Promise<NodeEntity> {
|
||||
const node = await this.findOne(tenantId, id);
|
||||
node.status = dto.status;
|
||||
return this.nodeRepository.save(node);
|
||||
}
|
||||
|
||||
async getDownline(tenantId: string, nodeId: string, maxDepth?: number): Promise<NodeEntity[]> {
|
||||
const node = await this.findOne(tenantId, nodeId);
|
||||
|
||||
if (!node.path) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// Use LTREE query to find all descendants
|
||||
const query = this.nodeRepository
|
||||
.createQueryBuilder('node')
|
||||
.where('node.tenant_id = :tenantId', { tenantId })
|
||||
.andWhere('node.path <@ :path', { path: node.path })
|
||||
.andWhere('node.id != :nodeId', { nodeId });
|
||||
|
||||
if (maxDepth) {
|
||||
query.andWhere('node.depth <= :maxDepth', { maxDepth: node.depth + maxDepth });
|
||||
}
|
||||
|
||||
return query.orderBy('node.depth', 'ASC').getMany();
|
||||
}
|
||||
|
||||
async getUpline(tenantId: string, nodeId: string): Promise<NodeEntity[]> {
|
||||
const node = await this.findOne(tenantId, nodeId);
|
||||
|
||||
if (!node.path) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// Get ancestor IDs from path
|
||||
const pathParts = node.path.split('.').slice(0, -1); // Exclude self
|
||||
const ancestorPaths = pathParts.map((_, i) => pathParts.slice(0, i + 1).join('.'));
|
||||
|
||||
if (ancestorPaths.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return this.nodeRepository
|
||||
.createQueryBuilder('node')
|
||||
.where('node.tenant_id = :tenantId', { tenantId })
|
||||
.andWhere('node.path IN (:...paths)', { paths: ancestorPaths })
|
||||
.orderBy('node.depth', 'DESC')
|
||||
.getMany();
|
||||
}
|
||||
|
||||
async getTree(tenantId: string, nodeId: string, maxDepth: number = 3): Promise<TreeNodeDto> {
|
||||
const rootNode = await this.findOne(tenantId, nodeId);
|
||||
|
||||
const descendants = await this.getDownline(tenantId, nodeId, maxDepth);
|
||||
|
||||
return this.buildTree(rootNode, descendants);
|
||||
}
|
||||
|
||||
private buildTree(root: NodeEntity, descendants: NodeEntity[]): TreeNodeDto {
|
||||
const childrenMap = new Map<string, NodeEntity[]>();
|
||||
|
||||
for (const node of descendants) {
|
||||
if (node.parentId) {
|
||||
const children = childrenMap.get(node.parentId) || [];
|
||||
children.push(node);
|
||||
childrenMap.set(node.parentId, children);
|
||||
}
|
||||
}
|
||||
|
||||
const buildNode = (node: NodeEntity): TreeNodeDto => {
|
||||
const children = childrenMap.get(node.id) || [];
|
||||
return {
|
||||
id: node.id,
|
||||
userId: node.userId,
|
||||
depth: node.depth,
|
||||
position: node.position,
|
||||
personalVolume: node.personalVolume,
|
||||
groupVolume: node.groupVolume,
|
||||
directReferrals: node.directReferrals,
|
||||
status: node.status,
|
||||
children: children.map(buildNode),
|
||||
};
|
||||
};
|
||||
|
||||
return buildNode(root);
|
||||
}
|
||||
|
||||
async generateInviteLink(tenantId: string, nodeId: string): Promise<{ inviteCode: string; inviteUrl: string }> {
|
||||
const node = await this.findOne(tenantId, nodeId);
|
||||
|
||||
if (!node.inviteCode) {
|
||||
node.inviteCode = randomBytes(8).toString('hex').toUpperCase();
|
||||
await this.nodeRepository.save(node);
|
||||
}
|
||||
|
||||
// TODO: Get base URL from config
|
||||
const baseUrl = process.env.FRONTEND_URL || 'https://app.example.com';
|
||||
const inviteUrl = `${baseUrl}/register?ref=${node.inviteCode}`;
|
||||
|
||||
return {
|
||||
inviteCode: node.inviteCode,
|
||||
inviteUrl,
|
||||
};
|
||||
}
|
||||
|
||||
async getMyNetworkSummary(tenantId: string, userId: string): Promise<MyNetworkSummaryDto> {
|
||||
const node = await this.findByUserId(tenantId, userId);
|
||||
|
||||
if (!node) {
|
||||
throw new NotFoundException('Node not found for current user');
|
||||
}
|
||||
|
||||
const downline = await this.getDownline(tenantId, node.id);
|
||||
const activeDownline = downline.filter((n) => n.status === NodeStatus.ACTIVE).length;
|
||||
|
||||
// Get current rank
|
||||
let currentRank = null;
|
||||
if (node.rankId) {
|
||||
const rank = await this.rankRepository.findOne({ where: { id: node.rankId } });
|
||||
if (rank) {
|
||||
currentRank = { id: rank.id, name: rank.name, level: rank.level };
|
||||
}
|
||||
}
|
||||
|
||||
// Get next rank
|
||||
let nextRank = null;
|
||||
if (node.rankId) {
|
||||
const ranks = await this.rankRepository.find({
|
||||
where: { structureId: node.structureId, isActive: true },
|
||||
order: { level: 'ASC' },
|
||||
});
|
||||
|
||||
const currentRankIndex = ranks.findIndex((r) => r.id === node.rankId);
|
||||
if (currentRankIndex >= 0 && currentRankIndex < ranks.length - 1) {
|
||||
const next = ranks[currentRankIndex + 1];
|
||||
nextRank = {
|
||||
id: next.id,
|
||||
name: next.name,
|
||||
level: next.level,
|
||||
requirements: next.requirements,
|
||||
progress: this.calculateRankProgress(node, next),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
totalDownline: node.totalDownline,
|
||||
directReferrals: node.directReferrals,
|
||||
activeDownline,
|
||||
personalVolume: node.personalVolume,
|
||||
groupVolume: node.groupVolume,
|
||||
totalEarnings: node.totalEarnings,
|
||||
currentRank,
|
||||
nextRank,
|
||||
};
|
||||
}
|
||||
|
||||
private calculateRankProgress(node: NodeEntity, rank: RankEntity): Record<string, number> {
|
||||
const progress: Record<string, number> = {};
|
||||
const req = rank.requirements;
|
||||
|
||||
if (req.personalVolume) {
|
||||
progress.personalVolume = Math.min(100, (node.personalVolume / req.personalVolume) * 100);
|
||||
}
|
||||
if (req.groupVolume) {
|
||||
progress.groupVolume = Math.min(100, (node.groupVolume / req.groupVolume) * 100);
|
||||
}
|
||||
if (req.directReferrals) {
|
||||
progress.directReferrals = Math.min(100, (node.directReferrals / req.directReferrals) * 100);
|
||||
}
|
||||
|
||||
return progress;
|
||||
}
|
||||
|
||||
private async updateParentMetrics(parentId: string): Promise<void> {
|
||||
const parent = await this.nodeRepository.findOne({ where: { id: parentId } });
|
||||
if (!parent) return;
|
||||
|
||||
// Count direct children
|
||||
const directChildren = await this.nodeRepository.count({
|
||||
where: { parentId, status: NodeStatus.ACTIVE },
|
||||
});
|
||||
|
||||
// Get all descendants for total downline count
|
||||
const descendants = await this.getDownline(parent.tenantId, parentId);
|
||||
|
||||
parent.directReferrals = directChildren;
|
||||
parent.totalDownline = descendants.length;
|
||||
|
||||
// Calculate group volume
|
||||
const groupVolume = descendants.reduce((sum, d) => sum + Number(d.personalVolume), 0);
|
||||
parent.groupVolume = groupVolume;
|
||||
|
||||
await this.nodeRepository.save(parent);
|
||||
|
||||
// Recursively update ancestors
|
||||
if (parent.parentId) {
|
||||
await this.updateParentMetrics(parent.parentId);
|
||||
}
|
||||
}
|
||||
|
||||
private async updateSponsorMetrics(sponsorId: string): Promise<void> {
|
||||
const sponsor = await this.nodeRepository.findOne({ where: { id: sponsorId } });
|
||||
if (!sponsor) return;
|
||||
|
||||
// Count direct referrals
|
||||
const directReferrals = await this.nodeRepository.count({
|
||||
where: { sponsorId, status: NodeStatus.ACTIVE },
|
||||
});
|
||||
|
||||
sponsor.directReferrals = directReferrals;
|
||||
await this.nodeRepository.save(sponsor);
|
||||
}
|
||||
}
|
||||
148
src/modules/mlm/services/ranks.service.ts
Normal file
148
src/modules/mlm/services/ranks.service.ts
Normal file
@ -0,0 +1,148 @@
|
||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { RankEntity } from '../entities/rank.entity';
|
||||
import { NodeEntity } from '../entities/node.entity';
|
||||
import {
|
||||
CreateRankDto,
|
||||
UpdateRankDto,
|
||||
RankFiltersDto,
|
||||
} from '../dto/rank.dto';
|
||||
|
||||
@Injectable()
|
||||
export class RanksService {
|
||||
constructor(
|
||||
@InjectRepository(RankEntity)
|
||||
private readonly rankRepository: Repository<RankEntity>,
|
||||
@InjectRepository(NodeEntity)
|
||||
private readonly nodeRepository: Repository<NodeEntity>,
|
||||
) {}
|
||||
|
||||
async findAll(tenantId: string, filters?: RankFiltersDto): Promise<RankEntity[]> {
|
||||
const where: any = { tenantId };
|
||||
|
||||
if (filters?.structureId) {
|
||||
where.structureId = filters.structureId;
|
||||
}
|
||||
if (filters?.isActive !== undefined) {
|
||||
where.isActive = filters.isActive;
|
||||
}
|
||||
|
||||
return this.rankRepository.find({
|
||||
where,
|
||||
order: { level: 'ASC' },
|
||||
});
|
||||
}
|
||||
|
||||
async findOne(tenantId: string, id: string): Promise<RankEntity> {
|
||||
const rank = await this.rankRepository.findOne({
|
||||
where: { id, tenantId },
|
||||
});
|
||||
|
||||
if (!rank) {
|
||||
throw new NotFoundException(`Rank with ID ${id} not found`);
|
||||
}
|
||||
|
||||
return rank;
|
||||
}
|
||||
|
||||
async create(tenantId: string, dto: CreateRankDto): Promise<RankEntity> {
|
||||
const rank = this.rankRepository.create({
|
||||
...dto,
|
||||
tenantId,
|
||||
});
|
||||
|
||||
return this.rankRepository.save(rank);
|
||||
}
|
||||
|
||||
async update(tenantId: string, id: string, dto: UpdateRankDto): Promise<RankEntity> {
|
||||
const rank = await this.findOne(tenantId, id);
|
||||
|
||||
Object.assign(rank, dto);
|
||||
|
||||
return this.rankRepository.save(rank);
|
||||
}
|
||||
|
||||
async remove(tenantId: string, id: string): Promise<void> {
|
||||
const rank = await this.findOne(tenantId, id);
|
||||
await this.rankRepository.remove(rank);
|
||||
}
|
||||
|
||||
async evaluateRank(tenantId: string, nodeId: string): Promise<RankEntity | null> {
|
||||
const node = await this.nodeRepository.findOne({
|
||||
where: { id: nodeId, tenantId },
|
||||
});
|
||||
|
||||
if (!node) {
|
||||
throw new NotFoundException(`Node with ID ${nodeId} not found`);
|
||||
}
|
||||
|
||||
// Get all ranks for the structure, ordered by level descending
|
||||
const ranks = await this.rankRepository.find({
|
||||
where: { structureId: node.structureId, isActive: true },
|
||||
order: { level: 'DESC' },
|
||||
});
|
||||
|
||||
// Find the highest rank the node qualifies for
|
||||
for (const rank of ranks) {
|
||||
if (this.meetsRequirements(node, rank)) {
|
||||
return rank;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private meetsRequirements(node: NodeEntity, rank: RankEntity): boolean {
|
||||
const req = rank.requirements;
|
||||
|
||||
if (req.personalVolume && node.personalVolume < req.personalVolume) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (req.groupVolume && node.groupVolume < req.groupVolume) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (req.directReferrals && node.directReferrals < req.directReferrals) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// TODO: Add more complex requirements (activeLegs, rankInLegs)
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
async evaluateAllNodes(tenantId: string, structureId: string): Promise<number> {
|
||||
const nodes = await this.nodeRepository.find({
|
||||
where: { tenantId, structureId },
|
||||
});
|
||||
|
||||
let updatedCount = 0;
|
||||
|
||||
for (const node of nodes) {
|
||||
const qualifiedRank = await this.evaluateRank(tenantId, node.id);
|
||||
|
||||
if (qualifiedRank && qualifiedRank.id !== node.rankId) {
|
||||
node.rankId = qualifiedRank.id;
|
||||
|
||||
// Update highest rank if this is higher
|
||||
if (!node.highestRankId) {
|
||||
node.highestRankId = qualifiedRank.id;
|
||||
} else {
|
||||
const currentHighest = await this.rankRepository.findOne({
|
||||
where: { id: node.highestRankId },
|
||||
});
|
||||
if (currentHighest && qualifiedRank.level > currentHighest.level) {
|
||||
node.highestRankId = qualifiedRank.id;
|
||||
}
|
||||
}
|
||||
|
||||
await this.nodeRepository.save(node);
|
||||
updatedCount++;
|
||||
}
|
||||
}
|
||||
|
||||
return updatedCount;
|
||||
}
|
||||
}
|
||||
79
src/modules/mlm/services/structures.service.ts
Normal file
79
src/modules/mlm/services/structures.service.ts
Normal file
@ -0,0 +1,79 @@
|
||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository, ILike } from 'typeorm';
|
||||
import { StructureEntity } from '../entities/structure.entity';
|
||||
import {
|
||||
CreateStructureDto,
|
||||
UpdateStructureDto,
|
||||
StructureFiltersDto,
|
||||
} from '../dto/structure.dto';
|
||||
|
||||
@Injectable()
|
||||
export class StructuresService {
|
||||
constructor(
|
||||
@InjectRepository(StructureEntity)
|
||||
private readonly structureRepository: Repository<StructureEntity>,
|
||||
) {}
|
||||
|
||||
async findAll(tenantId: string, filters?: StructureFiltersDto): Promise<StructureEntity[]> {
|
||||
const where: any = { tenantId };
|
||||
|
||||
if (filters?.type) {
|
||||
where.type = filters.type;
|
||||
}
|
||||
if (filters?.isActive !== undefined) {
|
||||
where.isActive = filters.isActive;
|
||||
}
|
||||
if (filters?.search) {
|
||||
where.name = ILike(`%${filters.search}%`);
|
||||
}
|
||||
|
||||
return this.structureRepository.find({
|
||||
where,
|
||||
order: { createdAt: 'DESC' },
|
||||
});
|
||||
}
|
||||
|
||||
async findOne(tenantId: string, id: string): Promise<StructureEntity> {
|
||||
const structure = await this.structureRepository.findOne({
|
||||
where: { id, tenantId },
|
||||
});
|
||||
|
||||
if (!structure) {
|
||||
throw new NotFoundException(`Structure with ID ${id} not found`);
|
||||
}
|
||||
|
||||
return structure;
|
||||
}
|
||||
|
||||
async create(
|
||||
tenantId: string,
|
||||
userId: string,
|
||||
dto: CreateStructureDto,
|
||||
): Promise<StructureEntity> {
|
||||
const structure = this.structureRepository.create({
|
||||
...dto,
|
||||
tenantId,
|
||||
createdBy: userId,
|
||||
});
|
||||
|
||||
return this.structureRepository.save(structure);
|
||||
}
|
||||
|
||||
async update(
|
||||
tenantId: string,
|
||||
id: string,
|
||||
dto: UpdateStructureDto,
|
||||
): Promise<StructureEntity> {
|
||||
const structure = await this.findOne(tenantId, id);
|
||||
|
||||
Object.assign(structure, dto);
|
||||
|
||||
return this.structureRepository.save(structure);
|
||||
}
|
||||
|
||||
async remove(tenantId: string, id: string): Promise<void> {
|
||||
const structure = await this.findOne(tenantId, id);
|
||||
await this.structureRepository.remove(structure);
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user