[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:
Adrian Flores Cortes 2026-01-25 06:48:44 -06:00
parent 6d6241c6cb
commit c683ab0353
25 changed files with 2722 additions and 0 deletions

View File

@ -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 {}

View 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);
}
}

View File

@ -0,0 +1,4 @@
export * from './structures.controller';
export * from './ranks.controller';
export * from './nodes.controller';
export * from './commissions.controller';

View 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);
}
}

View 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);
}
}

View 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);
}
}

View 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[];
}

View File

@ -0,0 +1,4 @@
export * from './structure.dto';
export * from './rank.dto';
export * from './node.dto';
export * from './commission.dto';

View 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;
}

View 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;
}

View 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;
}

View 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;
}

View 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;
}

View 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';

View 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[];
}

View 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;
}

View 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[];
}

View 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
View File

@ -0,0 +1,5 @@
export * from './mlm.module';
export * from './entities';
export * from './dto';
export * from './services';
export * from './controllers';

View 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 {}

View 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();
}
}

View File

@ -0,0 +1,4 @@
export * from './structures.service';
export * from './ranks.service';
export * from './nodes.service';
export * from './commissions.service';

View 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);
}
}

View 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;
}
}

View 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);
}
}