Commit fcbd2e22 authored by Administrator's avatar Administrator

Update 30 files via Son of Anton

parent 56d9d3ef
......@@ -25,6 +25,14 @@ import { CommentsModule } from './modules/comments/comments.module';
import { ChecklistsModule } from './modules/checklists/checklists.module';
import { AttachmentsModule } from './modules/attachments/attachments.module';
// ─── Phase 1D: Financial Core ───────────────────────────────
import { SalaryModule } from './modules/salary/salary.module';
import { HudModule } from './modules/hud/hud.module';
import { DeductionsModule } from './modules/deductions/deductions.module';
import { BountiesModule } from './modules/bounties/bounties.module';
import { AdjustmentsModule } from './modules/adjustments/adjustments.module';
import { PayrollModule } from './modules/payroll/payroll.module';
import { JwtAuthGuard } from './common/guards/jwt-auth.guard';
import { RolesGuard } from './common/guards/roles.guard';
import { TransformInterceptor } from './common/interceptors/transform.interceptor';
......@@ -54,6 +62,13 @@ import { RateLimitMiddleware } from './common/middleware/rate-limit.middleware';
CommentsModule,
ChecklistsModule,
AttachmentsModule,
// Phase 1D
SalaryModule,
HudModule,
DeductionsModule,
BountiesModule,
AdjustmentsModule,
PayrollModule,
],
providers: [
{ provide: APP_GUARD, useClass: JwtAuthGuard },
......
import {
Controller,
Get,
Post,
Put,
Delete,
Body,
Param,
Query,
HttpCode,
HttpStatus,
} from '@nestjs/common';
import { AdjustmentsService } from './adjustments.service';
import { CreateAdjustmentDto } from './dto/create-adjustment.dto';
import { AdjustmentFilterDto } from './dto/adjustment-filter.dto';
import { CurrentUser, RequestUser } from '../../common/decorators/current-user.decorator';
import { Roles } from '../../common/decorators/roles.decorator';
@Controller('adjustments')
export class AdjustmentsController {
constructor(private readonly adjustmentsService: AdjustmentsService) {}
@Post()
@Roles('SUPER_ADMIN', 'ADMIN')
async create(@Body() dto: CreateAdjustmentDto, @CurrentUser() user: RequestUser) {
return this.adjustmentsService.create(dto, user);
}
@Get()
async findAll(@Query() filter: AdjustmentFilterDto, @CurrentUser() user: RequestUser) {
return this.adjustmentsService.findAll(filter, user);
}
@Get(':id')
async findById(@Param('id') id: string, @CurrentUser() user: RequestUser) {
return this.adjustmentsService.findById(id, user);
}
@Put(':id/review')
@Roles('SUPER_ADMIN')
async review(
@Param('id') id: string,
@Body() body: { decision: 'APPROVED' | 'REJECTED'; reason?: string },
@CurrentUser() user: RequestUser,
) {
return this.adjustmentsService.review(id, body.decision, body.reason, user);
}
@Put(':id')
@Roles('SUPER_ADMIN')
async update(@Param('id') id: string, @Body() data: any, @CurrentUser() user: RequestUser) {
return this.adjustmentsService.update(id, data, user);
}
@Delete(':id')
@Roles('SUPER_ADMIN')
@HttpCode(HttpStatus.OK)
async delete(@Param('id') id: string, @CurrentUser() user: RequestUser) {
await this.adjustmentsService.delete(id, user);
return { message: 'Adjustment deleted' };
}
}
\ No newline at end of file
import { Module } from '@nestjs/common';
import { AdjustmentsController } from './adjustments.controller';
import { AdjustmentsService } from './adjustments.service';
import { HudModule } from '../hud/hud.module';
@Module({
imports: [HudModule],
controllers: [AdjustmentsController],
providers: [AdjustmentsService],
exports: [AdjustmentsService],
})
export class AdjustmentsModule {}
\ No newline at end of file
import {
Injectable,
NotFoundException,
ForbiddenException,
BadRequestException,
Logger,
} from '@nestjs/common';
import { PrismaService } from '../../prisma/prisma.service';
import { HudService } from '../hud/hud.service';
import { CreateAdjustmentDto } from './dto/create-adjustment.dto';
import { AdjustmentFilterDto } from './dto/adjustment-filter.dto';
import { RequestUser } from '../../common/decorators/current-user.decorator';
import { getSkip, buildPaginatedResponse, PaginatedResult } from '../../common/utils/pagination.util';
@Injectable()
export class AdjustmentsService {
private readonly logger = new Logger(AdjustmentsService.name);
constructor(
private readonly prisma: PrismaService,
private readonly hudService: HudService,
) {}
async create(dto: CreateAdjustmentDto, currentUser: RequestUser): Promise<any> {
if (currentUser.role !== 'SUPER_ADMIN' && currentUser.role !== 'ADMIN') {
throw new ForbiddenException('Only Super Admin and Admin can create adjustments');
}
if (!['POSITIVE', 'NEGATIVE'].includes(dto.type)) {
throw new BadRequestException('Type must be POSITIVE or NEGATIVE');
}
const validCategories = ['ADVANCE', 'REIMBURSEMENT', 'BONUS', 'CORRECTION', 'LOAN', 'OTHER'];
if (!validCategories.includes(dto.category)) {
throw new BadRequestException(`Category must be one of: ${validCategories.join(', ')}`);
}
const contractor = await this.prisma.user.findFirst({
where: { id: dto.userId, deletedAt: null },
});
if (!contractor) throw new NotFoundException('Contractor not found');
const now = new Date();
const effectiveMonth = dto.effectiveMonth || now.getMonth() + 1;
const effectiveYear = dto.effectiveYear || now.getFullYear();
// SA creates are auto-approved; Admin creates need SA approval
const status = currentUser.role === 'SUPER_ADMIN' ? 'APPROVED' : 'PENDING_APPROVAL';
const adjustment = await this.prisma.adjustment.create({
data: {
userId: dto.userId,
type: dto.type,
category: dto.category,
amountPiasters: dto.amountPiasters,
description: dto.description,
effectiveMonth,
effectiveYear,
status,
createdById: currentUser.id,
approvedById: status === 'APPROVED' ? currentUser.id : null,
approvedAt: status === 'APPROVED' ? new Date() : null,
payrollMonth: effectiveMonth,
payrollYear: effectiveYear,
},
include: {
user: { select: { id: true, firstName: true, lastName: true } },
createdBy: { select: { id: true, firstName: true, lastName: true } },
},
});
// If auto-approved, push HUD update immediately
if (status === 'APPROVED') {
try {
await this.hudService.pushAdjustmentApplied(
dto.userId,
dto.type,
dto.category,
dto.amountPiasters,
);
} catch (err) {
this.logger.warn(`Failed to push HUD for adjustment: ${err.message}`);
}
}
this.logger.log(
`Adjustment created: ${dto.type} ${dto.category} ${dto.amountPiasters} piasters for ${contractor.firstName} by ${currentUser.email} (${status})`,
);
return adjustment;
}
async findAll(filter: AdjustmentFilterDto, currentUser: RequestUser): Promise<PaginatedResult<any>> {
const page = filter.page || 1;
const limit = filter.limit || 20;
const where: any = {};
if (currentUser.role === 'CONTRACTOR') {
where.userId = currentUser.id;
where.status = 'APPROVED'; // Contractors only see approved adjustments
}
if (filter.userId) where.userId = filter.userId;
if (filter.type) where.type = filter.type;
if (filter.category) where.category = filter.category;
if (filter.status && currentUser.role !== 'CONTRACTOR') where.status = filter.status;
const [data, total] = await Promise.all([
this.prisma.adjustment.findMany({
where,
skip: getSkip(page, limit),
take: limit,
orderBy: { createdAt: filter.sortOrder || 'desc' },
include: {
user: { select: { id: true, firstName: true, lastName: true, avatar: true } },
createdBy: { select: { id: true, firstName: true, lastName: true } },
approvedBy: { select: { id: true, firstName: true, lastName: true } },
},
}),
this.prisma.adjustment.count({ where }),
]);
return buildPaginatedResponse(data, total, { page, limit, sortOrder: filter.sortOrder || 'desc' });
}
async findById(id: string, currentUser: RequestUser): Promise<any> {
const adjustment = await this.prisma.adjustment.findUnique({
where: { id },
include: {
user: { select: { id: true, firstName: true, lastName: true, avatar: true } },
createdBy: { select: { id: true, firstName: true, lastName: true } },
approvedBy: { select: { id: true, firstName: true, lastName: true } },
},
});
if (!adjustment) throw new NotFoundException('Adjustment not found');
if (currentUser.role === 'CONTRACTOR' && adjustment.userId !== currentUser.id) {
throw new ForbiddenException('You can only view your own adjustments');
}
return adjustment;
}
async review(id: string, decision: 'APPROVED' | 'REJECTED', reason: string | undefined, currentUser: RequestUser): Promise<any> {
if (currentUser.role !== 'SUPER_ADMIN') {
throw new ForbiddenException('Only Super Admin can approve/reject adjustments');
}
const adjustment = await this.prisma.adjustment.findUnique({ where: { id } });
if (!adjustment) throw new NotFoundException('Adjustment not found');
if (adjustment.status !== 'PENDING_APPROVAL') {
throw new BadRequestException('This adjustment is not pending approval');
}
const updated = await this.prisma.adjustment.update({
where: { id },
data: {
status: decision,
approvedById: decision === 'APPROVED' ? currentUser.id : null,
approvedAt: decision === 'APPROVED' ? new Date() : null,
rejectionReason: decision === 'REJECTED' ? reason || 'Rejected by Super Admin' : null,
},
});
if (decision === 'APPROVED') {
try {
await this.hudService.pushAdjustmentApplied(
adjustment.userId,
adjustment.type,
adjustment.category,
adjustment.amountPiasters,
);
} catch (err) {
this.logger.warn(`Failed to push HUD for approved adjustment: ${err.message}`);
}
}
this.logger.log(`Adjustment ${id} ${decision} by ${currentUser.email}`);
return updated;
}
async update(id: string, data: any, currentUser: RequestUser): Promise<any> {
if (currentUser.role !== 'SUPER_ADMIN') {
throw new ForbiddenException('Only Super Admin can edit adjustments');
}
const adjustment = await this.prisma.adjustment.findUnique({ where: { id } });
if (!adjustment) throw new NotFoundException('Adjustment not found');
const updateData: any = {};
if (data.amountPiasters !== undefined) updateData.amountPiasters = data.amountPiasters;
if (data.description !== undefined) updateData.description = data.description;
if (data.type !== undefined) updateData.type = data.type;
if (data.category !== undefined) updateData.category = data.category;
const updated = await this.prisma.adjustment.update({ where: { id }, data: updateData });
if (adjustment.status === 'APPROVED') {
try {
await this.hudService.pushHudUpdate(adjustment.userId);
} catch (err) {
this.logger.warn(`Failed to push HUD after adjustment edit: ${err.message}`);
}
}
return updated;
}
async delete(id: string, currentUser: RequestUser): Promise<void> {
if (currentUser.role !== 'SUPER_ADMIN') {
throw new ForbiddenException('Only Super Admin can delete adjustments');
}
const adjustment = await this.prisma.adjustment.findUnique({ where: { id } });
if (!adjustment) throw new NotFoundException('Adjustment not found');
await this.prisma.adjustment.delete({ where: { id } });
if (adjustment.status === 'APPROVED') {
try {
await this.hudService.pushHudUpdate(adjustment.userId);
} catch (err) {
this.logger.warn(`Failed to push HUD after adjustment deletion: ${err.message}`);
}
}
this.logger.log(`Adjustment ${id} deleted by ${currentUser.email}`);
}
}
\ No newline at end of file
import { IsOptional, IsString } from 'class-validator';
import { PaginationDto } from '../../../common/dto/pagination.dto';
export class AdjustmentFilterDto extends PaginationDto {
@IsOptional()
@IsString()
userId?: string;
@IsOptional()
@IsString()
type?: string;
@IsOptional()
@IsString()
category?: string;
@IsOptional()
@IsString()
status?: string;
}
\ No newline at end of file
import { IsString, IsInt, IsOptional, Min, MinLength } from 'class-validator';
export class CreateAdjustmentDto {
@IsString()
userId: string;
@IsString()
type: string; // POSITIVE, NEGATIVE
@IsString()
category: string; // ADVANCE, REIMBURSEMENT, BONUS, CORRECTION, LOAN, OTHER
@IsInt()
@Min(1)
amountPiasters: number;
@IsString()
@MinLength(50, { message: 'Description must be at least 50 characters' })
description: string;
@IsOptional()
@IsInt()
effectiveMonth?: number;
@IsOptional()
@IsInt()
effectiveYear?: number;
}
\ No newline at end of file
import { Controller, Get, Param, Query } from '@nestjs/common';
import { BountiesService } from './bounties.service';
import { CurrentUser, RequestUser } from '../../common/decorators/current-user.decorator';
import { Roles } from '../../common/decorators/roles.decorator';
@Controller('bounties')
export class BountiesController {
constructor(private readonly bountiesService: BountiesService) {}
@Get('dashboard')
@Roles('SUPER_ADMIN', 'ADMIN')
async getDashboard(@CurrentUser() user: RequestUser) {
return this.bountiesService.getDashboard(user);
}
@Get('my')
async getMyBounties(
@CurrentUser() user: RequestUser,
@Query('month') month?: string,
@Query('year') year?: string,
) {
return this.bountiesService.getPayoutsForUser(
user.id,
month ? parseInt(month, 10) : undefined,
year ? parseInt(year, 10) : undefined,
);
}
@Get('user/:userId')
@Roles('SUPER_ADMIN', 'ADMIN')
async getUserBounties(
@Param('userId') userId: string,
@Query('month') month?: string,
@Query('year') year?: string,
) {
return this.bountiesService.getPayoutsForUser(
userId,
month ? parseInt(month, 10) : undefined,
year ? parseInt(year, 10) : undefined,
);
}
}
\ No newline at end of file
import { Module } from '@nestjs/common';
import { BountiesController } from './bounties.controller';
import { BountiesService } from './bounties.service';
import { HudModule } from '../hud/hud.module';
@Module({
imports: [HudModule],
controllers: [BountiesController],
providers: [BountiesService],
exports: [BountiesService],
})
export class BountiesModule {}
\ No newline at end of file
import {
Injectable,
NotFoundException,
BadRequestException,
ForbiddenException,
Logger,
} from '@nestjs/common';
import { PrismaService } from '../../prisma/prisma.service';
import { HudService } from '../hud/hud.service';
import { RequestUser } from '../../common/decorators/current-user.decorator';
@Injectable()
export class BountiesService {
private readonly logger = new Logger(BountiesService.name);
constructor(
private readonly prisma: PrismaService,
private readonly hudService: HudService,
) {}
async payoutBounty(cardId: string, movedBy: RequestUser): Promise<any[]> {
const card = await this.prisma.card.findFirst({
where: { id: cardId, deletedAt: null },
include: {
assignees: { select: { id: true, firstName: true, lastName: true } },
column: { select: { boardId: true, board: { select: { key: true, name: true } } } },
},
});
if (!card) throw new NotFoundException('Card not found');
if (!card.bountyPiasters || card.bountyPiasters <= 0) return [];
const assignees = card.assignees;
if (assignees.length === 0) {
this.logger.warn(`Card ${cardId} has bounty but no assignees. Bounty not paid.`);
return [];
}
// Parse bounty split
let splits: Record<string, number> = {};
if (card.bountySplit && typeof card.bountySplit === 'object') {
splits = card.bountySplit as Record<string, number>;
}
// Default: equal split
if (Object.keys(splits).length === 0) {
const equalShare = 100 / assignees.length;
for (const a of assignees) {
splits[a.id] = equalShare;
}
}
const now = new Date();
const payouts: any[] = [];
for (const assignee of assignees) {
const percentage = splits[assignee.id] || (100 / assignees.length);
const amount = Math.round((card.bountyPiasters * percentage) / 100);
if (amount <= 0) continue;
const payout = await this.prisma.bountyPayout.create({
data: {
cardId: card.id,
userId: assignee.id,
amountPiasters: amount,
splitPercentage: percentage,
boardId: card.column.boardId,
cardNumber: card.cardNumber,
cardTitle: card.title,
payrollMonth: now.getMonth() + 1,
payrollYear: now.getFullYear(),
},
});
payouts.push(payout);
// Push HUD update for each assignee
try {
await this.hudService.pushBountyEarned(assignee.id, card.title, amount);
} catch (err) {
this.logger.warn(`Failed to push bounty HUD for ${assignee.id}: ${err.message}`);
}
this.logger.log(
`Bounty payout: ${amount} piasters to ${assignee.firstName} ${assignee.lastName} for card ${card.cardNumber}`,
);
}
return payouts;
}
async getDashboard(currentUser: RequestUser): Promise<any> {
if (currentUser.role !== 'SUPER_ADMIN' && currentUser.role !== 'ADMIN') {
throw new ForbiddenException('Only Super Admin and Admin can view bounty dashboard');
}
const now = new Date();
const month = now.getMonth() + 1;
const year = now.getFullYear();
// Total bounties paid this month
const paidThisMonth = await this.prisma.bountyPayout.aggregate({
where: { payrollMonth: month, payrollYear: year, revokedAt: null },
_sum: { amountPiasters: true },
_count: true,
});
// Pending bounties (cards with bounty not yet in Done)
const pendingBounties = await this.prisma.card.aggregate({
where: {
bountyPiasters: { gt: 0 },
completedAt: null,
deletedAt: null,
isArchived: false,
},
_sum: { bountyPiasters: true },
_count: true,
});
// Top earners this month
const topEarners = await this.prisma.bountyPayout.groupBy({
by: ['userId'],
where: { payrollMonth: month, payrollYear: year, revokedAt: null },
_sum: { amountPiasters: true },
_count: true,
orderBy: { _sum: { amountPiasters: 'desc' } },
take: 10,
});
// Enrich top earners with user data
const enrichedEarners = [];
for (const earner of topEarners) {
const user = await this.prisma.user.findUnique({
where: { id: earner.userId },
select: { id: true, firstName: true, lastName: true, avatar: true, actualSalaryPiasters: true },
});
enrichedEarners.push({
user,
totalPiasters: earner._sum.amountPiasters,
count: earner._count,
salaryRatio: user?.actualSalaryPiasters
? Math.round(((earner._sum.amountPiasters || 0) / user.actualSalaryPiasters) * 100)
: 0,
});
}
// Admin budget tracking
let adminBudgetCap = 5000000; // 50,000 EGP default
try {
const setting = await this.prisma.setting.findUnique({
where: { key: 'adminMonthlyBountyBudgetPiasters' },
});
if (setting) adminBudgetCap = setting.value as number;
} catch { /* settings may not exist */ }
// Admin-assigned bounties this month (exclude SA)
const adminBounties = await this.prisma.bountyPayout.aggregate({
where: {
payrollMonth: month,
payrollYear: year,
revokedAt: null,
// Would need to track who set the bounty — simplified for now
},
_sum: { amountPiasters: true },
});
return {
thisMonth: {
paidCount: paidThisMonth._count,
paidTotalPiasters: paidThisMonth._sum.amountPiasters || 0,
},
pending: {
count: pendingBounties._count,
totalPiasters: pendingBounties._sum.bountyPiasters || 0,
},
topEarners: enrichedEarners,
adminBudget: {
capPiasters: adminBudgetCap,
usedPiasters: adminBounties._sum.amountPiasters || 0,
remainingPiasters: adminBudgetCap - (adminBounties._sum.amountPiasters || 0),
},
};
}
async getPayoutsForUser(userId: string, month?: number, year?: number): Promise<any[]> {
const now = new Date();
const m = month || now.getMonth() + 1;
const y = year || now.getFullYear();
return this.prisma.bountyPayout.findMany({
where: {
userId,
payrollMonth: m,
payrollYear: y,
revokedAt: null,
},
orderBy: { paidAt: 'desc' },
});
}
}
\ No newline at end of file
......@@ -5,6 +5,8 @@ import {
BadRequestException,
ConflictException,
Logger,
Inject,
forwardRef,
} from '@nestjs/common';
import { PrismaService } from '../../prisma/prisma.service';
import { MoveCardDto } from './dto/move-card.dto';
......@@ -121,6 +123,11 @@ export class CardMovementService {
data: updateData,
});
// ─── BOUNTY PAYOUT ON DONE ───────────────────────────────────
if (targetColumn.type === 'DONE' && card.bountyPiasters && card.bountyPiasters > 0) {
await this.triggerBountyPayout(card);
}
// ─── LOG ACTIVITY ────────────────────────────────────────────
try {
await this.prisma.cardActivity.create({
......@@ -148,6 +155,65 @@ export class CardMovementService {
return updated;
}
private async triggerBountyPayout(card: any): Promise<void> {
try {
const assignees = await this.prisma.card.findUnique({
where: { id: card.id },
select: {
assignees: { select: { id: true, firstName: true, lastName: true } },
bountyPiasters: true,
bountySplit: true,
title: true,
cardNumber: true,
column: { select: { boardId: true } },
},
});
if (!assignees || !assignees.bountyPiasters || assignees.assignees.length === 0) return;
let splits: Record<string, number> = {};
if (assignees.bountySplit && typeof assignees.bountySplit === 'object') {
splits = assignees.bountySplit as Record<string, number>;
}
if (Object.keys(splits).length === 0) {
const equalShare = 100 / assignees.assignees.length;
for (const a of assignees.assignees) {
splits[a.id] = equalShare;
}
}
const now = new Date();
for (const assignee of assignees.assignees) {
const percentage = splits[assignee.id] || (100 / assignees.assignees.length);
const amount = Math.round((assignees.bountyPiasters * percentage) / 100);
if (amount <= 0) continue;
await this.prisma.bountyPayout.create({
data: {
cardId: card.id,
userId: assignee.id,
amountPiasters: amount,
splitPercentage: percentage,
boardId: assignees.column.boardId,
cardNumber: assignees.cardNumber,
cardTitle: assignees.title,
payrollMonth: now.getMonth() + 1,
payrollYear: now.getFullYear(),
},
});
this.logger.log(
`Bounty payout: ${amount} piasters to ${assignee.firstName} ${assignee.lastName} for ${assignees.cardNumber}`,
);
}
} catch (err) {
this.logger.error(`Failed to process bounty payout for card ${card.id}: ${err.message}`);
}
}
private enforceMovementPermissions(
currentUser: RequestUser,
sourceColumn: any,
......
import { Injectable, Logger } from '@nestjs/common';
import { SalaryService } from '../salary/salary.service';
@Injectable()
export class DeductionCalculatorService {
private readonly logger = new Logger(DeductionCalculatorService.name);
constructor(private readonly salaryService: SalaryService) {}
async calculate(
userId: string,
category: string,
subCategory: string,
context: {
daysLate?: number;
month: number;
year: number;
estimatedTaskValuePiasters?: number;
},
): Promise<number> {
const dailyRate = await this.salaryService.getDailyRate(userId, context.month, context.year);
// Get monthly salary for percentage-based calculations
const liveSalary = await this.salaryService.calculateLiveSalary(userId, context.month, context.year);
const monthlySalary = liveSalary.actualSalaryPiasters;
switch (subCategory) {
// Category A — Deadline Violations
case 'A1': // Slight Delay (1-3 days)
return Math.round(dailyRate * 0.05 * (context.daysLate || 1));
case 'A2': // Moderate Delay (4-7 days)
return Math.round(dailyRate * 0.10 * (context.daysLate || 4));
case 'A3': // Severe Delay (8-14 days)
return Math.round(dailyRate * 0.15 * (context.daysLate || 8));
case 'A4': // Critical Delay (15+ days)
return Math.round(monthlySalary * 0.25);
case 'A5': // Complete Failure
return Math.round((context.estimatedTaskValuePiasters || monthlySalary) * 0.50);
// Category B — Reporting Violations
case 'B1': // Late Report (3rd+ occurrence)
return Math.round(dailyRate * 0.02);
case 'B2': // Unreported Day
return dailyRate;
case 'B3': // Vague Report (3+ in a month)
return Math.round(monthlySalary * 0.05);
case 'B4': // Falsified Report
return Math.round(monthlySalary * 0.25);
// Category C — Quality Violations
case 'C1': // Minor Quality Issues
return Math.round((context.estimatedTaskValuePiasters || dailyRate) * 0.03);
case 'C2': // Significant Quality Issues
return Math.round((context.estimatedTaskValuePiasters || dailyRate) * 0.10);
case 'C3': // Critical Quality Issues
return Math.round((context.estimatedTaskValuePiasters || dailyRate) * 0.25);
case 'C4': // Regression
return Math.round(monthlySalary * 0.15);
// Category D — Communication Violations
case 'D1': // Slow Response (2nd+ occurrence)
return Math.round(dailyRate * 0.02);
case 'D2': // No-Show Meeting
return Math.round(dailyRate * 0.05);
case 'D3': // Disappeared (3+ days)
return Math.round(monthlySalary * 0.15);
case 'D4': // Unprofessional Conduct
return Math.round(monthlySalary * 0.10); // Default 10%, can be adjusted up to 25%
default:
this.logger.warn(`Unknown deduction sub-category: ${subCategory}`);
return 0;
}
}
getSubCategoryName(subCategory: string): string {
const names: Record<string, string> = {
A1: 'Slight Delay (1-3 days)',
A2: 'Moderate Delay (4-7 days)',
A3: 'Severe Delay (8-14 days)',
A4: 'Critical Delay (15+ days)',
A5: 'Complete Failure',
B1: 'Late Report',
B2: 'Unreported Day',
B3: 'Vague/Useless Report',
B4: 'Falsified Report',
C1: 'Minor Quality Issues',
C2: 'Significant Quality Issues',
C3: 'Critical Quality Issues',
C4: 'Regression',
D1: 'Slow Response',
D2: 'No-Show Meeting',
D3: 'Disappeared',
D4: 'Unprofessional Conduct',
};
return names[subCategory] || subCategory;
}
}
\ No newline at end of file
import {
Injectable,
NotFoundException,
ForbiddenException,
ConflictException,
Logger,
} from '@nestjs/common';
import { PrismaService } from '../../prisma/prisma.service';
import { RequestUser } from '../../common/decorators/current-user.decorator';
@Injectable()
export class DeductionPresetService {
private readonly logger = new Logger(DeductionPresetService.name);
constructor(private readonly prisma: PrismaService) {}
async findAll(): Promise<any[]> {
return this.prisma.deductionPreset.findMany({
where: { isActive: true },
orderBy: [{ category: 'asc' }, { subCategory: 'asc' }],
include: {
createdBy: { select: { id: true, firstName: true, lastName: true } },
},
});
}
async create(data: {
name: string;
category: string;
subCategory: string;
description: string;
calculationFormula: string;
}, currentUser: RequestUser): Promise<any> {
if (currentUser.role !== 'SUPER_ADMIN' && currentUser.role !== 'ADMIN') {
throw new ForbiddenException('Only Super Admin and Admin can manage deduction presets');
}
const existing = await this.prisma.deductionPreset.findUnique({
where: { name: data.name },
});
if (existing) {
throw new ConflictException(`Preset "${data.name}" already exists`);
}
return this.prisma.deductionPreset.create({
data: {
...data,
createdById: currentUser.id,
},
});
}
async update(id: string, data: Partial<{
name: string;
description: string;
calculationFormula: string;
isActive: boolean;
}>, currentUser: RequestUser): Promise<any> {
if (currentUser.role !== 'SUPER_ADMIN' && currentUser.role !== 'ADMIN') {
throw new ForbiddenException('Only Super Admin and Admin can manage deduction presets');
}
const preset = await this.prisma.deductionPreset.findUnique({ where: { id } });
if (!preset) throw new NotFoundException('Preset not found');
return this.prisma.deductionPreset.update({ where: { id }, data });
}
async delete(id: string, currentUser: RequestUser): Promise<void> {
if (currentUser.role !== 'SUPER_ADMIN') {
throw new ForbiddenException('Only Super Admin can delete deduction presets');
}
const preset = await this.prisma.deductionPreset.findUnique({ where: { id } });
if (!preset) throw new NotFoundException('Preset not found');
await this.prisma.deductionPreset.delete({ where: { id } });
}
}
\ No newline at end of file
import {
Controller,
Get,
Post,
Put,
Delete,
Body,
Param,
Query,
HttpCode,
HttpStatus,
} from '@nestjs/common';
import { DeductionsService } from './deductions.service';
import { DeductionPresetService } from './deduction-preset.service';
import { CreateDeductionDto } from './dto/create-deduction.dto';
import { RespondDeductionDto } from './dto/respond-deduction.dto';
import { ReviewDeductionDto } from './dto/review-deduction.dto';
import { DeductionFilterDto } from './dto/deduction-filter.dto';
import { CurrentUser, RequestUser } from '../../common/decorators/current-user.decorator';
import { Roles } from '../../common/decorators/roles.decorator';
@Controller('deductions')
export class DeductionsController {
constructor(
private readonly deductionsService: DeductionsService,
private readonly presetService: DeductionPresetService,
) {}
@Post()
@Roles('SUPER_ADMIN', 'ADMIN', 'TEAM_LEAD')
async create(@Body() dto: CreateDeductionDto, @CurrentUser() user: RequestUser) {
return this.deductionsService.create(dto, user);
}
@Get()
async findAll(@Query() filter: DeductionFilterDto, @CurrentUser() user: RequestUser) {
return this.deductionsService.findAll(filter, user);
}
@Get('presets')
@Roles('SUPER_ADMIN', 'ADMIN')
async getPresets() {
return this.presetService.findAll();
}
@Get(':id')
async findById(@Param('id') id: string, @CurrentUser() user: RequestUser) {
return this.deductionsService.findById(id, user);
}
@Post(':id/acknowledge')
@HttpCode(HttpStatus.OK)
async acknowledge(@Param('id') id: string, @CurrentUser() user: RequestUser) {
return this.deductionsService.acknowledge(id, user);
}
@Post(':id/respond')
@HttpCode(HttpStatus.OK)
async respond(
@Param('id') id: string,
@Body() dto: RespondDeductionDto,
@CurrentUser() user: RequestUser,
) {
return this.deductionsService.respond(id, dto, user);
}
@Put(':id/admin-review')
@Roles('SUPER_ADMIN', 'ADMIN')
async reviewDraft(
@Param('id') id: string,
@Body('decision') decision: string,
@CurrentUser() user: RequestUser,
) {
return this.deductionsService.reviewAdminDraft(id, decision, user);
}
@Put(':id/review')
@Roles('SUPER_ADMIN', 'ADMIN')
async review(
@Param('id') id: string,
@Body() dto: ReviewDeductionDto,
@CurrentUser() user: RequestUser,
) {
return this.deductionsService.review(id, dto, user);
}
@Put(':id')
@Roles('SUPER_ADMIN')
async update(
@Param('id') id: string,
@Body() data: any,
@CurrentUser() user: RequestUser,
) {
return this.deductionsService.update(id, data, user);
}
@Delete(':id')
@Roles('SUPER_ADMIN')
@HttpCode(HttpStatus.OK)
async delete(@Param('id') id: string, @CurrentUser() user: RequestUser) {
await this.deductionsService.delete(id, user);
return { message: 'Deduction deleted' };
}
// ─── PRESETS ───────────────────────────────────────────
@Post('presets')
@Roles('SUPER_ADMIN', 'ADMIN')
async createPreset(@Body() data: any, @CurrentUser() user: RequestUser) {
return this.presetService.create(data, user);
}
@Put('presets/:id')
@Roles('SUPER_ADMIN', 'ADMIN')
async updatePreset(@Param('id') id: string, @Body() data: any, @CurrentUser() user: RequestUser) {
return this.presetService.update(id, data, user);
}
@Delete('presets/:id')
@Roles('SUPER_ADMIN')
@HttpCode(HttpStatus.OK)
async deletePreset(@Param('id') id: string, @CurrentUser() user: RequestUser) {
await this.presetService.delete(id, user);
return { message: 'Preset deleted' };
}
}
\ No newline at end of file
import { Module } from '@nestjs/common';
import { DeductionsController } from './deductions.controller';
import { DeductionsService } from './deductions.service';
import { DeductionCalculatorService } from './deduction-calculator.service';
import { DeductionPresetService } from './deduction-preset.service';
import { SalaryModule } from '../salary/salary.module';
import { HudModule } from '../hud/hud.module';
@Module({
imports: [SalaryModule, HudModule],
controllers: [DeductionsController],
providers: [DeductionsService, DeductionCalculatorService, DeductionPresetService],
exports: [DeductionsService, DeductionCalculatorService],
})
export class DeductionsModule {}
\ No newline at end of file
import {
Injectable,
NotFoundException,
ForbiddenException,
BadRequestException,
Logger,
} from '@nestjs/common';
import { PrismaService } from '../../prisma/prisma.service';
import { DeductionCalculatorService } from './deduction-calculator.service';
import { HudService } from '../hud/hud.service';
import { CreateDeductionDto } from './dto/create-deduction.dto';
import { RespondDeductionDto } from './dto/respond-deduction.dto';
import { ReviewDeductionDto } from './dto/review-deduction.dto';
import { DeductionFilterDto } from './dto/deduction-filter.dto';
import { RequestUser } from '../../common/decorators/current-user.decorator';
import { getSkip, buildPaginatedResponse, PaginatedResult } from '../../common/utils/pagination.util';
@Injectable()
export class DeductionsService {
private readonly logger = new Logger(DeductionsService.name);
constructor(
private readonly prisma: PrismaService,
private readonly calculator: DeductionCalculatorService,
private readonly hudService: HudService,
) {}
async create(dto: CreateDeductionDto, currentUser: RequestUser): Promise<any> {
// Verify contractor exists
const contractor = await this.prisma.user.findFirst({
where: { id: dto.userId, deletedAt: null },
});
if (!contractor) throw new NotFoundException('Contractor not found');
// Validate category/subCategory
const validSubs: Record<string, string[]> = {
A: ['A1', 'A2', 'A3', 'A4', 'A5'],
B: ['B1', 'B2', 'B3', 'B4'],
C: ['C1', 'C2', 'C3', 'C4'],
D: ['D1', 'D2', 'D3', 'D4'],
};
if (!validSubs[dto.category] || !validSubs[dto.category].includes(dto.subCategory)) {
throw new BadRequestException(`Invalid sub-category "${dto.subCategory}" for category "${dto.category}"`);
}
// Calculate amount if not provided
const now = new Date();
let amountPiasters = dto.amountPiasters || 0;
if (!dto.amountPiasters) {
amountPiasters = await this.calculator.calculate(dto.userId, dto.category, dto.subCategory, {
month: now.getMonth() + 1,
year: now.getFullYear(),
});
}
// Determine initial status based on initiator role
let status: string;
if (currentUser.role === 'TEAM_LEAD') {
status = 'PENDING_ADMIN_REVIEW';
} else {
status = 'PENDING_ACKNOWLEDGMENT';
}
const deduction = await this.prisma.deduction.create({
data: {
userId: dto.userId,
category: dto.category,
subCategory: dto.subCategory,
cardId: dto.cardId || null,
reportId: dto.reportId || null,
violationDate: new Date(dto.violationDate),
description: dto.description,
evidence: dto.evidence || null,
amountPiasters,
originalAmountPiasters: amountPiasters,
calculationBasis: `Auto-calculated: ${this.calculator.getSubCategoryName(dto.subCategory)}`,
status,
initiatedById: currentUser.id,
initiatedByRole: currentUser.role,
presetId: dto.presetId || null,
payrollMonth: now.getMonth() + 1,
payrollYear: now.getFullYear(),
},
include: {
user: { select: { id: true, firstName: true, lastName: true } },
initiatedBy: { select: { id: true, firstName: true, lastName: true } },
},
});
this.logger.log(
`Deduction ${deduction.id} (${dto.subCategory}) created for ${contractor.firstName} ${contractor.lastName} by ${currentUser.email}: ${amountPiasters} piasters`,
);
return deduction;
}
async findAll(filter: DeductionFilterDto, currentUser: RequestUser): Promise<PaginatedResult<any>> {
const page = filter.page || 1;
const limit = filter.limit || 20;
const where: any = {};
// Permission filtering
if (currentUser.role === 'CONTRACTOR') {
where.userId = currentUser.id;
} else if (currentUser.role === 'TEAM_LEAD') {
// PLs see deductions they initiated or for their team (counts only enforced at response level)
where.OR = [
{ initiatedById: currentUser.id },
{ user: { assignedProjectLeaderId: currentUser.id } },
];
}
if (filter.userId) where.userId = filter.userId;
if (filter.category) where.category = filter.category;
if (filter.subCategory) where.subCategory = filter.subCategory;
if (filter.status) where.status = filter.status;
if (filter.cardId) where.cardId = filter.cardId;
if (filter.dateFrom || filter.dateTo) {
where.violationDate = {};
if (filter.dateFrom) where.violationDate.gte = new Date(filter.dateFrom);
if (filter.dateTo) where.violationDate.lte = new Date(filter.dateTo);
}
const [data, total] = await Promise.all([
this.prisma.deduction.findMany({
where,
skip: getSkip(page, limit),
take: limit,
orderBy: { createdAt: filter.sortOrder || 'desc' },
include: {
user: { select: { id: true, firstName: true, lastName: true, avatar: true } },
initiatedBy: { select: { id: true, firstName: true, lastName: true } },
reviewedBy: { select: { id: true, firstName: true, lastName: true } },
preset: { select: { id: true, name: true } },
},
}),
this.prisma.deduction.count({ where }),
]);
// For PLs, strip financial amounts — they only see counts
const sanitized = data.map((d: any) => {
if (currentUser.role === 'TEAM_LEAD') {
return {
id: d.id,
userId: d.userId,
user: d.user,
category: d.category,
subCategory: d.subCategory,
status: d.status,
violationDate: d.violationDate,
description: d.description,
createdAt: d.createdAt,
// No amount fields for PL
};
}
return d;
});
return buildPaginatedResponse(sanitized, total, { page, limit, sortOrder: filter.sortOrder || 'desc' });
}
async findById(id: string, currentUser: RequestUser): Promise<any> {
const deduction = await this.prisma.deduction.findUnique({
where: { id },
include: {
user: { select: { id: true, firstName: true, lastName: true, avatar: true } },
initiatedBy: { select: { id: true, firstName: true, lastName: true } },
reviewedBy: { select: { id: true, firstName: true, lastName: true } },
card: { select: { id: true, cardNumber: true, title: true } },
preset: { select: { id: true, name: true } },
},
});
if (!deduction) throw new NotFoundException('Deduction not found');
// Contractors can only see their own
if (currentUser.role === 'CONTRACTOR' && deduction.userId !== currentUser.id) {
throw new ForbiddenException('You can only view your own deductions');
}
return deduction;
}
async acknowledge(id: string, currentUser: RequestUser): Promise<any> {
const deduction = await this.prisma.deduction.findUnique({ where: { id } });
if (!deduction) throw new NotFoundException('Deduction not found');
if (deduction.userId !== currentUser.id) {
throw new ForbiddenException('You can only acknowledge your own deductions');
}
if (deduction.status !== 'PENDING_ACKNOWLEDGMENT') {
throw new BadRequestException('This deduction is not pending acknowledgment');
}
const updated = await this.prisma.deduction.update({
where: { id },
data: {
acknowledgedAt: new Date(),
acknowledgedById: currentUser.id,
status: 'PENDING_RESPONSE',
},
});
this.logger.log(`Deduction ${id} acknowledged by contractor ${currentUser.id}`);
return updated;
}
async respond(id: string, dto: RespondDeductionDto, currentUser: RequestUser): Promise<any> {
const deduction = await this.prisma.deduction.findUnique({ where: { id } });
if (!deduction) throw new NotFoundException('Deduction not found');
if (deduction.userId !== currentUser.id) {
throw new ForbiddenException('You can only respond to your own deductions');
}
if (deduction.status !== 'PENDING_RESPONSE') {
throw new BadRequestException('This deduction is not pending response');
}
if (dto.responseType === 'DISPUTE' && (!dto.responseText || dto.responseText.length < 100)) {
throw new BadRequestException('Dispute explanation must be at least 100 characters');
}
if (dto.responseType === 'ACCEPT') {
// Auto-apply the deduction
const updated = await this.prisma.deduction.update({
where: { id },
data: {
responseType: 'ACCEPT',
respondedAt: new Date(),
status: 'UPHELD',
appliedAmountPiasters: deduction.amountPiasters,
appliedAt: new Date(),
reviewDecision: 'UPHELD',
reviewNotes: 'Contractor accepted the deduction',
},
});
// Push HUD update
try {
await this.hudService.pushDeductionApplied(
deduction.userId,
deduction.subCategory,
deduction.amountPiasters,
);
} catch (err) {
this.logger.warn(`Failed to push HUD update: ${err.message}`);
}
// Check 40% threshold
await this.checkDeductionThreshold(deduction.userId);
return updated;
}
// DISPUTE — goes to admin for review
return this.prisma.deduction.update({
where: { id },
data: {
responseType: 'DISPUTE',
responseText: dto.responseText,
responseEvidence: dto.responseEvidence || null,
respondedAt: new Date(),
// Status stays PENDING_RESPONSE — admin needs to review
},
});
}
async reviewAdminDraft(id: string, decision: string, currentUser: RequestUser): Promise<any> {
if (currentUser.role !== 'SUPER_ADMIN' && currentUser.role !== 'ADMIN') {
throw new ForbiddenException('Only Super Admin and Admin can review deduction drafts');
}
const deduction = await this.prisma.deduction.findUnique({ where: { id } });
if (!deduction) throw new NotFoundException('Deduction not found');
if (deduction.status !== 'PENDING_ADMIN_REVIEW') {
throw new BadRequestException('This deduction is not pending admin review');
}
if (decision === 'APPROVE') {
return this.prisma.deduction.update({
where: { id },
data: { status: 'PENDING_ACKNOWLEDGMENT' },
});
}
if (decision === 'REJECT') {
return this.prisma.deduction.update({
where: { id },
data: { status: 'CANCELLED', reviewNotes: 'Rejected by admin during draft review' },
});
}
throw new BadRequestException('Decision must be APPROVE or REJECT');
}
async review(id: string, dto: ReviewDeductionDto, currentUser: RequestUser): Promise<any> {
if (currentUser.role !== 'SUPER_ADMIN' && currentUser.role !== 'ADMIN') {
throw new ForbiddenException('Only Super Admin and Admin can review deductions');
}
const deduction = await this.prisma.deduction.findUnique({ where: { id } });
if (!deduction) throw new NotFoundException('Deduction not found');
const validStatuses = ['PENDING_RESPONSE', 'PENDING_ACKNOWLEDGMENT'];
if (!validStatuses.includes(deduction.status) && deduction.responseType !== 'DISPUTE') {
throw new BadRequestException('This deduction cannot be reviewed in its current state');
}
let appliedAmount = 0;
let newStatus = '';
switch (dto.decision) {
case 'UPHELD':
appliedAmount = deduction.amountPiasters;
newStatus = 'UPHELD';
break;
case 'REDUCED':
if (!dto.reducedAmountPiasters || dto.reducedAmountPiasters <= 0) {
throw new BadRequestException('Reduced amount must be provided and greater than 0');
}
if (dto.reducedAmountPiasters >= deduction.amountPiasters) {
throw new BadRequestException('Reduced amount must be less than original amount');
}
appliedAmount = dto.reducedAmountPiasters;
newStatus = 'REDUCED';
break;
case 'DISMISSED':
appliedAmount = 0;
newStatus = 'DISMISSED';
break;
default:
throw new BadRequestException('Decision must be UPHELD, REDUCED, or DISMISSED');
}
const updated = await this.prisma.deduction.update({
where: { id },
data: {
reviewDecision: dto.decision,
reviewNotes: dto.reviewNotes || null,
reviewedById: currentUser.id,
reviewedAt: new Date(),
reducedAmountPiasters: dto.decision === 'REDUCED' ? dto.reducedAmountPiasters : null,
status: newStatus,
appliedAmountPiasters: appliedAmount > 0 ? appliedAmount : null,
appliedAt: appliedAmount > 0 ? new Date() : null,
},
});
// Push HUD update if deduction was applied
if (appliedAmount > 0) {
try {
await this.hudService.pushDeductionApplied(
deduction.userId,
deduction.subCategory,
appliedAmount,
);
} catch (err) {
this.logger.warn(`Failed to push HUD update: ${err.message}`);
}
await this.checkDeductionThreshold(deduction.userId);
}
this.logger.log(
`Deduction ${id} reviewed: ${dto.decision} (${appliedAmount} piasters) by ${currentUser.email}`,
);
return updated;
}
async update(id: string, data: any, currentUser: RequestUser): Promise<any> {
if (currentUser.role !== 'SUPER_ADMIN') {
throw new ForbiddenException('Only Super Admin can edit deductions after decision');
}
const deduction = await this.prisma.deduction.findUnique({ where: { id } });
if (!deduction) throw new NotFoundException('Deduction not found');
const updateData: any = {};
if (data.amountPiasters !== undefined) updateData.amountPiasters = data.amountPiasters;
if (data.appliedAmountPiasters !== undefined) updateData.appliedAmountPiasters = data.appliedAmountPiasters;
if (data.description !== undefined) updateData.description = data.description;
if (data.category !== undefined) updateData.category = data.category;
if (data.subCategory !== undefined) updateData.subCategory = data.subCategory;
if (data.status !== undefined) updateData.status = data.status;
const updated = await this.prisma.deduction.update({
where: { id },
data: updateData,
});
// If amount changed and was applied, push HUD update
if (data.appliedAmountPiasters !== undefined) {
try {
await this.hudService.pushHudUpdate(deduction.userId);
} catch (err) {
this.logger.warn(`Failed to push HUD update: ${err.message}`);
}
}
return updated;
}
async delete(id: string, currentUser: RequestUser): Promise<void> {
if (currentUser.role !== 'SUPER_ADMIN') {
throw new ForbiddenException('Only Super Admin can delete deductions');
}
const deduction = await this.prisma.deduction.findUnique({ where: { id } });
if (!deduction) throw new NotFoundException('Deduction not found');
const wasApplied = deduction.appliedAmountPiasters && deduction.appliedAmountPiasters > 0;
await this.prisma.deduction.delete({ where: { id } });
// If the deduction was applied, push HUD update to restore salary
if (wasApplied) {
try {
await this.hudService.pushHudUpdate(deduction.userId);
} catch (err) {
this.logger.warn(`Failed to push HUD update after deduction deletion: ${err.message}`);
}
}
this.logger.log(`Deduction ${id} deleted by ${currentUser.email}`);
}
async autoApplyExpired(deductionId: string): Promise<void> {
const deduction = await this.prisma.deduction.findUnique({ where: { id: deductionId } });
if (!deduction) return;
if (deduction.status !== 'PENDING_RESPONSE') return;
if (deduction.respondedAt) return; // Already responded
await this.prisma.deduction.update({
where: { id: deductionId },
data: {
status: 'AUTO_APPLIED',
appliedAmountPiasters: deduction.amountPiasters,
appliedAt: new Date(),
autoAppliedAt: new Date(),
reviewDecision: 'UPHELD',
reviewNotes: 'Auto-applied: contractor did not respond within the response window',
},
});
try {
await this.hudService.pushDeductionApplied(
deduction.userId,
deduction.subCategory,
deduction.amountPiasters,
);
} catch (err) {
this.logger.warn(`Failed to push HUD for auto-applied deduction: ${err.message}`);
}
await this.checkDeductionThreshold(deduction.userId);
this.logger.log(`Deduction ${deductionId} auto-applied for contractor ${deduction.userId}`);
}
private async checkDeductionThreshold(userId: string): Promise<void> {
const now = new Date();
const month = now.getMonth() + 1;
const year = now.getFullYear();
try {
const liveSalary = await this.prisma.user.findUnique({
where: { id: userId },
select: { actualSalaryPiasters: true, baseSalaryPiasters: true },
});
if (!liveSalary) return;
const actualSalary = liveSalary.actualSalaryPiasters || liveSalary.baseSalaryPiasters || 0;
if (actualSalary <= 0) return;
const totalDeductions = await this.prisma.deduction.aggregate({
where: {
userId,
payrollMonth: month,
payrollYear: year,
status: { in: ['UPHELD', 'REDUCED', 'AUTO_APPLIED'] },
appliedAmountPiasters: { not: null },
},
_sum: { appliedAmountPiasters: true },
});
const totalAmount = totalDeductions._sum.appliedAmountPiasters || 0;
const percentage = (totalAmount / actualSalary) * 100;
if (percentage >= 40) {
this.logger.warn(
`⚠️ THRESHOLD ALERT: Contractor ${userId} has reached ${percentage.toFixed(1)}% deduction threshold (${totalAmount} / ${actualSalary} piasters)`,
);
// TODO: When Notifications module is built (Phase 1E):
// - Send blocking notification to contractor
// - Send notification to Super Admin
// - Auto-recommend PIP
}
} catch (err) {
this.logger.error(`Failed to check deduction threshold: ${err.message}`);
}
}
}
\ No newline at end of file
import { IsString, IsOptional, IsInt, IsDateString, Min, MinLength, IsArray } from 'class-validator';
export class CreateDeductionDto {
@IsString()
userId: string;
@IsString()
category: string; // A, B, C, D
@IsString()
subCategory: string; // A1-A5, B1-B4, C1-C4, D1-D4
@IsOptional()
@IsString()
cardId?: string;
@IsOptional()
@IsString()
reportId?: string;
@IsDateString()
violationDate: string;
@IsString()
@MinLength(100, { message: 'Description must be at least 100 characters' })
description: string;
@IsOptional()
evidence?: any; // JSON array
@IsOptional()
@IsInt()
@Min(0)
amountPiasters?: number; // If not provided, auto-calculated
@IsOptional()
@IsString()
presetId?: string;
}
\ No newline at end of file
import { IsOptional, IsString, IsDateString } from 'class-validator';
import { PaginationDto } from '../../../common/dto/pagination.dto';
export class DeductionFilterDto extends PaginationDto {
@IsOptional()
@IsString()
userId?: string;
@IsOptional()
@IsString()
category?: string;
@IsOptional()
@IsString()
subCategory?: string;
@IsOptional()
@IsString()
status?: string;
@IsOptional()
@IsDateString()
dateFrom?: string;
@IsOptional()
@IsDateString()
dateTo?: string;
@IsOptional()
@IsString()
cardId?: string;
}
\ No newline at end of file
import { IsString, IsOptional, MinLength } from 'class-validator';
export class RespondDeductionDto {
@IsString()
responseType: string; // ACCEPT, DISPUTE
@IsOptional()
@IsString()
@MinLength(100, { message: 'Dispute explanation must be at least 100 characters' })
responseText?: string;
@IsOptional()
responseEvidence?: any;
}
\ No newline at end of file
import { IsString, IsOptional, IsInt, Min } from 'class-validator';
export class ReviewDeductionDto {
@IsString()
decision: string; // UPHELD, REDUCED, DISMISSED
@IsOptional()
@IsString()
reviewNotes?: string;
@IsOptional()
@IsInt()
@Min(0)
reducedAmountPiasters?: number;
}
\ No newline at end of file
import {
WebSocketGateway,
WebSocketServer,
SubscribeMessage,
OnGatewayConnection,
OnGatewayDisconnect,
ConnectedSocket,
MessageBody,
} from '@nestjs/websockets';
import { Server, Socket } from 'socket.io';
import { Logger, UseGuards } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import { ConfigService } from '@nestjs/config';
import { PrismaService } from '../../prisma/prisma.service';
@WebSocketGateway({
namespace: '/hud',
cors: { origin: '*', credentials: true },
})
export class HudGateway implements OnGatewayConnection, OnGatewayDisconnect {
@WebSocketServer()
server: Server;
private readonly logger = new Logger(HudGateway.name);
constructor(
private readonly jwtService: JwtService,
private readonly configService: ConfigService,
private readonly prisma: PrismaService,
) {}
async handleConnection(client: Socket): Promise<void> {
try {
const token =
client.handshake.auth?.token ||
client.handshake.headers?.authorization?.replace('Bearer ', '');
if (!token) {
client.disconnect();
return;
}
const payload = this.jwtService.verify(token, {
secret: this.configService.get<string>('jwt.secret'),
});
const user = await this.prisma.user.findUnique({
where: { id: payload.sub },
select: { id: true, role: true, status: true },
});
if (!user || user.status === 'OFFBOARDED') {
client.disconnect();
return;
}
(client as any).userId = user.id;
(client as any).userRole = user.role;
// Auto-join the user's HUD room
client.join(`hud:${user.id}`);
this.logger.log(`HUD client connected: ${user.id}`);
} catch (err) {
this.logger.warn(`HUD connection failed: ${err.message}`);
client.disconnect();
}
}
handleDisconnect(client: Socket): void {
const userId = (client as any).userId;
if (userId) {
this.logger.log(`HUD client disconnected: ${userId}`);
}
}
@SubscribeMessage('hud:subscribe')
handleSubscribe(
@ConnectedSocket() client: Socket,
@MessageBody() data: { contractorId: string },
): void {
const userId = (client as any).userId;
const role = (client as any).userRole;
// Contractors can only subscribe to their own HUD
if (role === 'CONTRACTOR' && data.contractorId !== userId) {
client.emit('error', { message: 'Cannot subscribe to another contractor\'s HUD' });
return;
}
client.join(`hud:${data.contractorId}`);
}
@SubscribeMessage('hud:unsubscribe')
handleUnsubscribe(
@ConnectedSocket() client: Socket,
@MessageBody() data: { contractorId: string },
): void {
client.leave(`hud:${data.contractorId}`);
}
sendHudUpdate(contractorId: string, data: any): void {
this.server.to(`hud:${contractorId}`).emit('hud:update', data);
}
sendBountyEarned(contractorId: string, data: any): void {
this.server.to(`hud:${contractorId}`).emit('hud:bounty_earned', data);
}
sendDeductionApplied(contractorId: string, data: any): void {
this.server.to(`hud:${contractorId}`).emit('hud:deduction_applied', data);
}
sendAdjustmentApplied(contractorId: string, data: any): void {
this.server.to(`hud:${contractorId}`).emit('hud:adjustment_applied', data);
}
sendSalaryChanged(contractorId: string, data: any): void {
this.server.to(`hud:${contractorId}`).emit('hud:salary_changed', data);
}
}
\ No newline at end of file
import { Module } from '@nestjs/common';
import { JwtModule } from '@nestjs/jwt';
import { ConfigModule } from '@nestjs/config';
import { HudGateway } from './hud.gateway';
import { HudService } from './hud.service';
import { SalaryModule } from '../salary/salary.module';
@Module({
imports: [SalaryModule, JwtModule, ConfigModule],
providers: [HudGateway, HudService],
exports: [HudService],
})
export class HudModule {}
\ No newline at end of file
import { Injectable, Logger } from '@nestjs/common';
import { SalaryService, LiveSalaryData } from '../salary/salary.service';
import { HudGateway } from './hud.gateway';
@Injectable()
export class HudService {
private readonly logger = new Logger(HudService.name);
constructor(
private readonly salaryService: SalaryService,
private readonly hudGateway: HudGateway,
) {}
async pushHudUpdate(contractorId: string): Promise<void> {
try {
const now = new Date();
const liveSalary = await this.salaryService.calculateLiveSalary(
contractorId,
now.getMonth() + 1,
now.getFullYear(),
);
const streak = await this.salaryService.getStreakData(contractorId);
const health = await this.salaryService.getHealthStatus(
contractorId,
now.getMonth() + 1,
now.getFullYear(),
);
this.hudGateway.sendHudUpdate(contractorId, {
salary: liveSalary,
streak,
health,
timestamp: new Date().toISOString(),
});
this.logger.debug(`HUD update pushed to contractor ${contractorId}`);
} catch (err) {
this.logger.error(`Failed to push HUD update for ${contractorId}: ${err.message}`);
}
}
async pushBountyEarned(
contractorId: string,
cardTitle: string,
amountPiasters: number,
): Promise<void> {
await this.pushHudUpdate(contractorId);
this.hudGateway.sendBountyEarned(contractorId, {
cardTitle,
amountPiasters,
timestamp: new Date().toISOString(),
});
}
async pushDeductionApplied(
contractorId: string,
category: string,
amountPiasters: number,
): Promise<void> {
await this.pushHudUpdate(contractorId);
this.hudGateway.sendDeductionApplied(contractorId, {
category,
amountPiasters,
timestamp: new Date().toISOString(),
});
}
async pushAdjustmentApplied(
contractorId: string,
type: string,
category: string,
amountPiasters: number,
): Promise<void> {
await this.pushHudUpdate(contractorId);
this.hudGateway.sendAdjustmentApplied(contractorId, {
type,
category,
amountPiasters,
timestamp: new Date().toISOString(),
});
}
async pushSalaryChanged(contractorId: string): Promise<void> {
await this.pushHudUpdate(contractorId);
this.hudGateway.sendSalaryChanged(contractorId, {
timestamp: new Date().toISOString(),
});
}
}
\ No newline at end of file
import { IsOptional, IsString, IsInt } from 'class-validator';
import { Type } from 'class-transformer';
import { PaginationDto } from '../../../common/dto/pagination.dto';
export class PayrollFilterDto extends PaginationDto {
@IsOptional()
@Type(() => Number)
@IsInt()
month?: number;
@IsOptional()
@Type(() => Number)
@IsInt()
year?: number;
@IsOptional()
@IsString()
status?: string;
}
\ No newline at end of file
import {
Controller,
Get,
Post,
Put,
Param,
Query,
Body,
HttpCode,
HttpStatus,
} from '@nestjs/common';
import { PayrollService } from './payroll.service';
import { PayrollFilterDto } from './dto/payroll-filter.dto';
import { CurrentUser, RequestUser } from '../../common/decorators/current-user.decorator';
import { Roles } from '../../common/decorators/roles.decorator';
@Controller('payroll')
export class PayrollController {
constructor(private readonly payrollService: PayrollService) {}
@Post('calculate')
@Roles('SUPER_ADMIN', 'ADMIN')
async calculate(
@Body() body: { month: number; year: number },
@CurrentUser() user: RequestUser,
) {
return this.payrollService.calculate(body.month, body.year, user);
}
@Get()
@Roles('SUPER_ADMIN', 'ADMIN')
async findAll(@Query() filter: PayrollFilterDto, @CurrentUser() user: RequestUser) {
return this.payrollService.findAll(filter, user);
}
@Get('my')
async getMyPayroll(@CurrentUser() user: RequestUser) {
return this.payrollService.getMyPayroll(user.id);
}
@Get(':id')
@Roles('SUPER_ADMIN', 'ADMIN')
async findById(@Param('id') id: string, @CurrentUser() user: RequestUser) {
return this.payrollService.findById(id, user);
}
@Post(':id/submit')
@Roles('SUPER_ADMIN', 'ADMIN')
@HttpCode(HttpStatus.OK)
async submit(@Param('id') id: string, @CurrentUser() user: RequestUser) {
return this.payrollService.submit(id, user);
}
@Post(':id/approve')
@Roles('SUPER_ADMIN')
@HttpCode(HttpStatus.OK)
async approve(
@Param('id') id: string,
@Body('notes') notes: string,
@CurrentUser() user: RequestUser,
) {
return this.payrollService.approve(id, notes, user);
}
@Post(':id/reject')
@Roles('SUPER_ADMIN')
@HttpCode(HttpStatus.OK)
async reject(
@Param('id') id: string,
@Body('reason') reason: string,
@CurrentUser() user: RequestUser,
) {
return this.payrollService.reject(id, reason, user);
}
@Post(':id/processing')
@Roles('SUPER_ADMIN', 'ADMIN')
@HttpCode(HttpStatus.OK)
async markProcessing(@Param('id') id: string, @CurrentUser() user: RequestUser) {
return this.payrollService.markProcessing(id, user);
}
@Post(':id/paid')
@Roles('SUPER_ADMIN', 'ADMIN')
@HttpCode(HttpStatus.OK)
async markPaid(@Param('id') id: string, @CurrentUser() user: RequestUser) {
return this.payrollService.markPaid(id, user);
}
}
\ No newline at end of file
import { Module } from '@nestjs/common';
import { PayrollController } from './payroll.controller';
import { PayrollService } from './payroll.service';
import { SalaryModule } from '../salary/salary.module';
@Module({
imports: [SalaryModule],
controllers: [PayrollController],
providers: [PayrollService],
exports: [PayrollService],
})
export class PayrollModule {}
\ No newline at end of file
import {
Injectable,
NotFoundException,
ForbiddenException,
BadRequestException,
ConflictException,
Logger,
} from '@nestjs/common';
import { PrismaService } from '../../prisma/prisma.service';
import { SalaryService } from '../salary/salary.service';
import { RequestUser } from '../../common/decorators/current-user.decorator';
import {
getSkip,
buildPaginatedResponse,
PaginatedResult,
} from '../../common/utils/pagination.util';
import { getWorkingDaysInMonth, getScheduledDaysOfWeek } from '../../common/utils/date.util';
import { calculateDailyRatePiasters } from '../../common/utils/salary.util';
@Injectable()
export class PayrollService {
private readonly logger = new Logger(PayrollService.name);
constructor(
private readonly prisma: PrismaService,
private readonly salaryService: SalaryService,
) {}
async calculate(month: number, year: number, currentUser: RequestUser): Promise<any> {
if (currentUser.role !== 'SUPER_ADMIN' && currentUser.role !== 'ADMIN') {
throw new ForbiddenException('Only Super Admin and Admin can calculate payroll');
}
// Check if payroll already exists for this month
let payroll = await this.prisma.payroll.findUnique({
where: { month_year: { month, year } },
});
if (payroll && !['PENDING_CALCULATION', 'CALCULATED', 'REJECTED'].includes(payroll.status)) {
throw new ConflictException(
`Payroll for ${month}/${year} is already in status "${payroll.status}". Cannot recalculate.`,
);
}
// Create or update payroll record
if (!payroll) {
payroll = await this.prisma.payroll.create({
data: { month, year, status: 'CALCULATED' },
});
}
// Delete existing items if recalculating
await this.prisma.payrollItem.deleteMany({ where: { payrollId: payroll.id } });
// Get all active contractors
const contractors = await this.prisma.user.findMany({
where: {
role: 'CONTRACTOR',
status: { in: ['ACTIVE', 'ON_PIP', 'SUSPENDED'] },
deletedAt: null,
},
select: {
id: true,
actualSalaryPiasters: true,
baseSalaryPiasters: true,
weeklySchedule: true,
contractorType: true,
},
});
let totalGross = 0;
let totalDeductions = 0;
let totalBounties = 0;
let totalAdjustments = 0;
let totalNet = 0;
for (const contractor of contractors) {
const actualSalary = contractor.actualSalaryPiasters || contractor.baseSalaryPiasters || 0;
// Get schedule data
const schedule = (contractor.weeklySchedule as Record<string, string>) || {};
const scheduledDays = getScheduledDaysOfWeek(schedule);
const expectedWorkingDays = getWorkingDaysInMonth(year, month, scheduledDays);
const dailyRate = calculateDailyRatePiasters(actualSalary, expectedWorkingDays);
// Sum bounties
const bounties = await this.prisma.bountyPayout.aggregate({
where: {
userId: contractor.id,
payrollMonth: month,
payrollYear: year,
revokedAt: null,
},
_sum: { amountPiasters: true },
});
const bountyTotal = bounties._sum.amountPiasters || 0;
// Sum deductions by category
const deductionsByCategory = await this.getDeductionsByCategory(contractor.id, month, year);
const totalCatA = deductionsByCategory.A || 0;
const totalCatB = deductionsByCategory.B || 0;
const totalCatC = deductionsByCategory.C || 0;
const totalCatD = deductionsByCategory.D || 0;
const deductionTotal = totalCatA + totalCatB + totalCatC + totalCatD;
// Sum adjustments
const positiveAdj = await this.prisma.adjustment.aggregate({
where: {
userId: contractor.id,
effectiveMonth: month,
effectiveYear: year,
status: 'APPROVED',
type: 'POSITIVE',
},
_sum: { amountPiasters: true },
});
const negativeAdj = await this.prisma.adjustment.aggregate({
where: {
userId: contractor.id,
effectiveMonth: month,
effectiveYear: year,
status: 'APPROVED',
type: 'NEGATIVE',
},
_sum: { amountPiasters: true },
});
const positiveAdjTotal = positiveAdj._sum.amountPiasters || 0;
const negativeAdjTotal = negativeAdj._sum.amountPiasters || 0;
const netPayable = actualSalary + bountyTotal + positiveAdjTotal - deductionTotal - negativeAdjTotal;
await this.prisma.payrollItem.create({
data: {
payrollId: payroll.id,
userId: contractor.id,
actualSalaryPiasters: actualSalary,
totalBountiesPiasters: bountyTotal,
totalPositiveAdjPiasters: positiveAdjTotal,
totalNegativeAdjPiasters: negativeAdjTotal,
totalCatADeductions: totalCatA,
totalCatBDeductions: totalCatB,
totalCatCDeductions: totalCatC,
totalCatDDeductions: totalCatD,
totalDeductionsPiasters: deductionTotal,
netPayablePiasters: netPayable,
expectedWorkingDays,
dailyRatePiasters: dailyRate,
},
});
totalGross += actualSalary;
totalDeductions += deductionTotal;
totalBounties += bountyTotal;
totalAdjustments += positiveAdjTotal - negativeAdjTotal;
totalNet += netPayable;
}
// Update payroll totals
const updated = await this.prisma.payroll.update({
where: { id: payroll.id },
data: {
status: 'CALCULATED',
totalGrossPiasters: totalGross,
totalDeductionsPiasters: totalDeductions,
totalBountiesPiasters: totalBounties,
totalAdjustmentsPiasters: totalAdjustments,
totalNetPiasters: totalNet,
contractorCount: contractors.length,
calculatedAt: new Date(),
calculatedById: currentUser.id,
},
include: {
items: {
include: {
user: { select: { id: true, firstName: true, lastName: true } },
},
},
},
});
this.logger.log(
`Payroll for ${month}/${year} calculated: ${contractors.length} contractors, ${totalNet} piasters net by ${currentUser.email}`,
);
return updated;
}
async findAll(filter: any, currentUser: RequestUser): Promise<PaginatedResult<any>> {
const page = filter.page || 1;
const limit = filter.limit || 20;
const where: any = {};
if (filter.month) where.month = filter.month;
if (filter.year) where.year = filter.year;
if (filter.status) where.status = filter.status;
const [data, total] = await Promise.all([
this.prisma.payroll.findMany({
where,
skip: getSkip(page, limit),
take: limit,
orderBy: [{ year: 'desc' }, { month: 'desc' }],
}),
this.prisma.payroll.count({ where }),
]);
return buildPaginatedResponse(data, total, { page, limit, sortOrder: 'desc' });
}
async findById(id: string, currentUser: RequestUser): Promise<any> {
const payroll = await this.prisma.payroll.findUnique({
where: { id },
include: {
items: {
include: {
user: { select: { id: true, firstName: true, lastName: true, avatar: true, contractorType: true } },
},
orderBy: { netPayablePiasters: 'desc' },
},
},
});
if (!payroll) throw new NotFoundException('Payroll record not found');
return payroll;
}
async getMyPayroll(userId: string, month?: number, year?: number): Promise<any[]> {
const where: any = { userId };
if (month) where.payroll = { ...where.payroll, month };
if (year) where.payroll = { ...where.payroll, year };
return this.prisma.payrollItem.findMany({
where,
include: {
payroll: { select: { month: true, year: true, status: true, paidAt: true } },
},
orderBy: { createdAt: 'desc' },
take: 24,
});
}
async submit(id: string, currentUser: RequestUser): Promise<any> {
if (currentUser.role !== 'SUPER_ADMIN' && currentUser.role !== 'ADMIN') {
throw new ForbiddenException('Only Super Admin and Admin can submit payroll');
}
const payroll = await this.prisma.payroll.findUnique({ where: { id } });
if (!payroll) throw new NotFoundException('Payroll not found');
if (!['CALCULATED', 'UNDER_REVIEW'].includes(payroll.status)) {
throw new BadRequestException(`Cannot submit payroll in status "${payroll.status}"`);
}
return this.prisma.payroll.update({
where: { id },
data: {
status: 'SUBMITTED',
submittedById: currentUser.id,
submittedAt: new Date(),
},
});
}
async approve(id: string, notes: string | undefined, currentUser: RequestUser): Promise<any> {
if (currentUser.role !== 'SUPER_ADMIN') {
throw new ForbiddenException('Only Super Admin can approve payroll');
}
const payroll = await this.prisma.payroll.findUnique({ where: { id } });
if (!payroll) throw new NotFoundException('Payroll not found');
if (payroll.status !== 'SUBMITTED') {
throw new BadRequestException('Payroll must be submitted before approval');
}
return this.prisma.payroll.update({
where: { id },
data: {
status: 'APPROVED',
approvedById: currentUser.id,
approvedAt: new Date(),
approvalNotes: notes || null,
},
});
}
async reject(id: string, reason: string, currentUser: RequestUser): Promise<any> {
if (currentUser.role !== 'SUPER_ADMIN') {
throw new ForbiddenException('Only Super Admin can reject payroll');
}
const payroll = await this.prisma.payroll.findUnique({ where: { id } });
if (!payroll) throw new NotFoundException('Payroll not found');
if (payroll.status !== 'SUBMITTED') {
throw new BadRequestException('Payroll must be submitted to reject');
}
return this.prisma.payroll.update({
where: { id },
data: {
status: 'REJECTED',
rejectedById: currentUser.id,
rejectedAt: new Date(),
rejectionReason: reason,
},
});
}
async markProcessing(id: string, currentUser: RequestUser): Promise<any> {
if (currentUser.role !== 'SUPER_ADMIN' && currentUser.role !== 'ADMIN') {
throw new ForbiddenException('Only Super Admin and Admin can mark payroll as processing');
}
const payroll = await this.prisma.payroll.findUnique({ where: { id } });
if (!payroll) throw new NotFoundException('Payroll not found');
if (payroll.status !== 'APPROVED') {
throw new BadRequestException('Payroll must be approved to mark as processing');
}
return this.prisma.payroll.update({
where: { id },
data: { status: 'PROCESSING', processingAt: new Date() },
});
}
async markPaid(id: string, currentUser: RequestUser): Promise<any> {
if (currentUser.role !== 'SUPER_ADMIN' && currentUser.role !== 'ADMIN') {
throw new ForbiddenException('Only Super Admin and Admin can mark payroll as paid');
}
const payroll = await this.prisma.payroll.findUnique({ where: { id } });
if (!payroll) throw new NotFoundException('Payroll not found');
if (payroll.status !== 'PROCESSING') {
throw new BadRequestException('Payroll must be processing to mark as paid');
}
return this.prisma.payroll.update({
where: { id },
data: { status: 'PAID', paidAt: new Date(), paidById: currentUser.id },
});
}
private async getDeductionsByCategory(
userId: string,
month: number,
year: number,
): Promise<Record<string, number>> {
const result: Record<string, number> = { A: 0, B: 0, C: 0, D: 0 };
const deductions = await this.prisma.deduction.findMany({
where: {
userId,
payrollMonth: month,
payrollYear: year,
status: { in: ['UPHELD', 'REDUCED', 'AUTO_APPLIED'] },
appliedAmountPiasters: { not: null },
},
select: { category: true, appliedAmountPiasters: true },
});
for (const d of deductions) {
if (result[d.category] !== undefined) {
result[d.category] += d.appliedAmountPiasters || 0;
}
}
return result;
}
}
\ No newline at end of file
import { Controller, Get, Param, Query } from '@nestjs/common';
import { SalaryService } from './salary.service';
import { CurrentUser, RequestUser } from '../../common/decorators/current-user.decorator';
import { Roles } from '../../common/decorators/roles.decorator';
@Controller('salary')
export class SalaryController {
constructor(private readonly salaryService: SalaryService) {}
@Get('hud')
async getMyHud(@CurrentUser() user: RequestUser) {
const now = new Date();
const liveSalary = await this.salaryService.calculateLiveSalary(
user.id,
now.getMonth() + 1,
now.getFullYear(),
);
const streak = await this.salaryService.getStreakData(user.id);
const health = await this.salaryService.getHealthStatus(
user.id,
now.getMonth() + 1,
now.getFullYear(),
);
return { ...liveSalary, streak, health };
}
@Get('hud/:userId')
@Roles('SUPER_ADMIN', 'ADMIN')
async getContractorHud(
@Param('userId') userId: string,
@Query('month') month?: string,
@Query('year') year?: string,
) {
const now = new Date();
const m = month ? parseInt(month, 10) : now.getMonth() + 1;
const y = year ? parseInt(year, 10) : now.getFullYear();
const liveSalary = await this.salaryService.calculateLiveSalary(userId, m, y);
const streak = await this.salaryService.getStreakData(userId);
const health = await this.salaryService.getHealthStatus(userId, m, y);
return { ...liveSalary, streak, health };
}
@Get('history')
async getMyHistory(@CurrentUser() user: RequestUser) {
return this.salaryService.getSalaryHistory(user.id);
}
@Get('history/:userId')
@Roles('SUPER_ADMIN', 'ADMIN')
async getContractorHistory(@Param('userId') userId: string) {
return this.salaryService.getSalaryHistory(userId);
}
@Get('daily-rate/:userId')
@Roles('SUPER_ADMIN', 'ADMIN')
async getDailyRate(
@Param('userId') userId: string,
@Query('month') month?: string,
@Query('year') year?: string,
) {
const now = new Date();
const m = month ? parseInt(month, 10) : now.getMonth() + 1;
const y = year ? parseInt(year, 10) : now.getFullYear();
const dailyRate = await this.salaryService.getDailyRate(userId, m, y);
return { userId, month: m, year: y, dailyRatePiasters: dailyRate };
}
}
\ No newline at end of file
import { Module } from '@nestjs/common';
import { SalaryController } from './salary.controller';
import { SalaryService } from './salary.service';
@Module({
controllers: [SalaryController],
providers: [SalaryService],
exports: [SalaryService],
})
export class SalaryModule {}
\ No newline at end of file
import { Injectable, NotFoundException, Logger } from '@nestjs/common';
import { PrismaService } from '../../prisma/prisma.service';
import {
calculateDailyRatePiasters,
piasterToEgp,
formatEgp,
} from '../../common/utils/salary.util';
import {
getWorkingDaysInMonth,
getScheduledDaysOfWeek,
} from '../../common/utils/date.util';
export interface LiveSalaryData {
contractorId: string;
month: number;
year: number;
actualSalaryPiasters: number;
baseSalaryPiasters: number;
totalBountiesPiasters: number;
totalPositiveAdjustmentsPiasters: number;
totalDeductionsPiasters: number;
totalNegativeAdjustmentsPiasters: number;
netSalaryPiasters: number;
healthPercentage: number;
dailyRatePiasters: number;
expectedWorkingDays: number;
deductionCount: number;
bountyCount: number;
breakdown: {
bounties: Array<{ id: string; cardTitle: string; amountPiasters: number; paidAt: string }>;
deductions: Array<{ id: string; category: string; subCategory: string; reason: string; amountPiasters: number; appliedAt: string }>;
adjustments: Array<{ id: string; type: string; category: string; description: string; amountPiasters: number; approvedAt: string }>;
};
}
export interface StreakData {
currentStreak: number;
bestStreak: number;
rank: number | null;
totalActiveContractors: number;
}
@Injectable()
export class SalaryService {
private readonly logger = new Logger(SalaryService.name);
constructor(private readonly prisma: PrismaService) {}
async calculateLiveSalary(contractorId: string, month: number, year: number): Promise<LiveSalaryData> {
const user = await this.prisma.user.findFirst({
where: { id: contractorId, deletedAt: null },
select: {
id: true,
actualSalaryPiasters: true,
baseSalaryPiasters: true,
weeklySchedule: true,
contractorType: true,
},
});
if (!user) {
throw new NotFoundException('Contractor not found');
}
const actualSalary = user.actualSalaryPiasters || user.baseSalaryPiasters || 0;
// Get expected working days
const schedule = (user.weeklySchedule as Record<string, string>) || {};
const scheduledDays = getScheduledDaysOfWeek(schedule);
// Get holidays for the month
const holidays = await this.getHolidaysForMonth(month, year);
const expectedWorkingDays = getWorkingDaysInMonth(year, month, scheduledDays, holidays);
const dailyRate = calculateDailyRatePiasters(actualSalary, expectedWorkingDays);
// Get bounties for this month
const bountyPayouts = await this.prisma.bountyPayout.findMany({
where: {
userId: contractorId,
payrollMonth: month,
payrollYear: year,
revokedAt: null,
},
orderBy: { paidAt: 'asc' },
});
const totalBounties = bountyPayouts.reduce((sum, b) => sum + b.amountPiasters, 0);
// Get applied deductions for this month
const deductions = await this.prisma.deduction.findMany({
where: {
userId: contractorId,
payrollMonth: month,
payrollYear: year,
status: { in: ['UPHELD', 'REDUCED', 'AUTO_APPLIED'] },
appliedAmountPiasters: { not: null },
},
orderBy: { appliedAt: 'asc' },
});
const totalDeductions = deductions.reduce((sum, d) => sum + (d.appliedAmountPiasters || 0), 0);
// Get approved adjustments for this month
const adjustments = await this.prisma.adjustment.findMany({
where: {
userId: contractorId,
effectiveMonth: month,
effectiveYear: year,
status: 'APPROVED',
},
orderBy: { approvedAt: 'asc' },
});
const totalPositiveAdj = adjustments
.filter((a) => a.type === 'POSITIVE')
.reduce((sum, a) => sum + a.amountPiasters, 0);
const totalNegativeAdj = adjustments
.filter((a) => a.type === 'NEGATIVE')
.reduce((sum, a) => sum + a.amountPiasters, 0);
const netSalary = actualSalary + totalBounties + totalPositiveAdj - totalDeductions - totalNegativeAdj;
const healthPercentage = actualSalary > 0 ? Math.round((netSalary / actualSalary) * 100) : 100;
return {
contractorId,
month,
year,
actualSalaryPiasters: actualSalary,
baseSalaryPiasters: user.baseSalaryPiasters || 0,
totalBountiesPiasters: totalBounties,
totalPositiveAdjustmentsPiasters: totalPositiveAdj,
totalDeductionsPiasters: totalDeductions,
totalNegativeAdjustmentsPiasters: totalNegativeAdj,
netSalaryPiasters: netSalary,
healthPercentage,
dailyRatePiasters: dailyRate,
expectedWorkingDays,
deductionCount: deductions.length,
bountyCount: bountyPayouts.length,
breakdown: {
bounties: bountyPayouts.map((b) => ({
id: b.id,
cardTitle: b.cardTitle,
amountPiasters: b.amountPiasters,
paidAt: b.paidAt.toISOString(),
})),
deductions: deductions.map((d) => ({
id: d.id,
category: d.category,
subCategory: d.subCategory,
reason: d.description.substring(0, 100),
amountPiasters: d.appliedAmountPiasters || 0,
appliedAt: d.appliedAt?.toISOString() || d.createdAt.toISOString(),
})),
adjustments: adjustments.map((a) => ({
id: a.id,
type: a.type,
category: a.category,
description: a.description.substring(0, 100),
amountPiasters: a.type === 'POSITIVE' ? a.amountPiasters : -a.amountPiasters,
approvedAt: a.approvedAt?.toISOString() || a.createdAt.toISOString(),
})),
},
};
}
async getDailyRate(contractorId: string, month: number, year: number): Promise<number> {
const user = await this.prisma.user.findFirst({
where: { id: contractorId, deletedAt: null },
select: { actualSalaryPiasters: true, baseSalaryPiasters: true, weeklySchedule: true },
});
if (!user) return 0;
const actualSalary = user.actualSalaryPiasters || user.baseSalaryPiasters || 0;
const schedule = (user.weeklySchedule as Record<string, string>) || {};
const scheduledDays = getScheduledDaysOfWeek(schedule);
const holidays = await this.getHolidaysForMonth(month, year);
const expectedWorkingDays = getWorkingDaysInMonth(year, month, scheduledDays, holidays);
return calculateDailyRatePiasters(actualSalary, expectedWorkingDays);
}
async getStreakData(contractorId: string): Promise<StreakData> {
// Streak calculation is simplified until the Reports module is built in Phase 2D.
// For now, return placeholder data based on available information.
const user = await this.prisma.user.findFirst({
where: { id: contractorId, deletedAt: null },
select: { id: true, status: true },
});
if (!user) {
return { currentStreak: 0, bestStreak: 0, rank: null, totalActiveContractors: 0 };
}
// Count active contractors for ranking
const totalActive = await this.prisma.user.count({
where: { role: 'CONTRACTOR', status: 'ACTIVE', deletedAt: null },
});
// TODO: Full streak calculation requires Reports module (Phase 2D)
// For now, return 0 streaks
return {
currentStreak: 0,
bestStreak: 0,
rank: null,
totalActiveContractors: totalActive,
};
}
async getHealthStatus(contractorId: string, month: number, year: number): Promise<string> {
const liveSalary = await this.calculateLiveSalary(contractorId, month, year);
if (liveSalary.deductionCount > 3 || liveSalary.healthPercentage < 60) {
return 'CRITICAL';
}
if (
(liveSalary.deductionCount >= 2 && liveSalary.deductionCount <= 3) ||
(liveSalary.healthPercentage >= 60 && liveSalary.healthPercentage < 80)
) {
return 'WARNING';
}
return 'HEALTHY';
}
async getSalaryHistory(contractorId: string): Promise<any[]> {
// Get payroll items for this contractor
const items = await this.prisma.payrollItem.findMany({
where: { userId: contractorId },
include: {
payroll: {
select: { month: true, year: true, status: true, paidAt: true },
},
},
orderBy: { createdAt: 'desc' },
take: 24, // Last 2 years
});
return items.map((item) => ({
month: item.payroll.month,
year: item.payroll.year,
status: item.payroll.status,
actualSalaryPiasters: item.actualSalaryPiasters,
totalBountiesPiasters: item.totalBountiesPiasters,
totalDeductionsPiasters: item.totalDeductionsPiasters,
netPayablePiasters: item.netPayablePiasters,
paidAt: item.payroll.paidAt,
}));
}
private async getHolidaysForMonth(month: number, year: number): Promise<Date[]> {
try {
const startDate = new Date(year, month - 1, 1);
const endDate = new Date(year, month, 0);
const holidays = await this.prisma.holiday.findMany({
where: {
OR: [
{ startDate: { gte: startDate, lte: endDate } },
{ endDate: { gte: startDate, lte: endDate } },
],
},
});
const holidayDates: Date[] = [];
for (const holiday of holidays) {
const start = new Date(holiday.startDate);
const end = new Date(holiday.endDate);
for (let d = new Date(start); d <= end; d.setDate(d.getDate() + 1)) {
if (d >= startDate && d <= endDate) {
holidayDates.push(new Date(d));
}
}
}
return holidayDates;
} catch {
// Holiday table may not exist yet
return [];
}
}
}
\ No newline at end of file
// ═══════════════════════════════════════════════════
// FINANCIAL MODELS Phase 1D
// ═══════════════════════════════════════════════════
model Deduction {
id String @id @default(uuid())
userId String
user User @relation("UserDeductions", fields: [userId], references: [id], onDelete: Restrict)
category String // A, B, C, D
subCategory String // A1, A2, A3, A4, A5, B1, B2, B3, B4, C1, C2, C3, C4, D1, D2, D3, D4
cardId String?
card Card? @relation("CardDeductions", fields: [cardId], references: [id], onDelete: SetNull)
reportId String?
// No direct relation to Report model yet will be added in Phase 2D
violationDate DateTime
description String
evidence Json? // Array of file paths / descriptions
amountPiasters Int
originalAmountPiasters Int // Before any reduction
calculationBasis String? // Description of how amount was calculated
status String @default("DRAFT")
// DRAFT, PENDING_ADMIN_REVIEW, PENDING_ACKNOWLEDGMENT, PENDING_RESPONSE,
// UPHELD, REDUCED, DISMISSED, AUTO_APPLIED, CANCELLED
// Acknowledgment
acknowledgedAt DateTime?
acknowledgedById String?
// Contractor response
responseType String? // ACCEPT, DISPUTE
responseText String?
responseEvidence Json?
respondedAt DateTime?
// Review decision
reviewDecision String? // UPHELD, REDUCED, DISMISSED
reviewNotes String?
reviewedById String?
reviewedBy User? @relation("DeductionReviewer", fields: [reviewedById], references: [id], onDelete: SetNull)
reviewedAt DateTime?
reducedAmountPiasters Int?
// Auto-apply tracking
autoApplyJobId String?
autoAppliedAt DateTime?
// Who initiated
initiatedById String
initiatedBy User @relation("DeductionInitiator", fields: [initiatedById], references: [id], onDelete: Restrict)
initiatedByRole String // Role at time of initiation
// Final applied amount (after review)
appliedAmountPiasters Int?
appliedAt DateTime?
// Payroll link
payrollMonth Int?
payrollYear Int?
presetId String?
preset DeductionPreset? @relation(fields: [presetId], references: [id], onDelete: SetNull)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([userId])
@@index([status])
@@index([category, subCategory])
@@index([cardId])
@@index([payrollMonth, payrollYear])
@@index([createdAt])
@@index([initiatedById])
}
model DeductionPreset {
id String @id @default(uuid())
name String @unique
category String
subCategory String
description String
calculationFormula String // Human-readable description of the calculation
isActive Boolean @default(true)
createdById String
createdBy User @relation("DeductionPresetCreator", fields: [createdById], references: [id], onDelete: Restrict)
deductions Deduction[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model Adjustment {
id String @id @default(uuid())
userId String
user User @relation("UserAdjustments", fields: [userId], references: [id], onDelete: Restrict)
type String // POSITIVE, NEGATIVE
category String // ADVANCE, REIMBURSEMENT, BONUS, CORRECTION, LOAN, OTHER
amountPiasters Int
description String
effectiveMonth Int
effectiveYear Int
status String @default("PENDING_APPROVAL")
// PENDING_APPROVAL, APPROVED, REJECTED
// Approval
approvedById String?
approvedBy User? @relation("AdjustmentApprover", fields: [approvedById], references: [id], onDelete: SetNull)
approvedAt DateTime?
rejectionReason String?
// Who created
createdById String
createdBy User @relation("AdjustmentCreator", fields: [createdById], references: [id], onDelete: Restrict)
// Payroll link
payrollMonth Int?
payrollYear Int?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([userId])
@@index([status])
@@index([effectiveMonth, effectiveYear])
@@index([createdById])
@@index([type])
}
model BountyPayout {
id String @id @default(uuid())
cardId String
card Card @relation("CardBountyPayouts", fields: [cardId], references: [id], onDelete: Restrict)
userId String
user User @relation("UserBountyPayouts", fields: [userId], references: [id], onDelete: Restrict)
amountPiasters Int
splitPercentage Float @default(100)
boardId String
cardNumber String
cardTitle String
payrollMonth Int
payrollYear Int
paidAt DateTime @default(now())
revokedAt DateTime?
revokedById String?
revokeReason String?
createdAt DateTime @default(now())
@@index([userId])
@@index([cardId])
@@index([payrollMonth, payrollYear])
@@index([paidAt])
}
model Payroll {
id String @id @default(uuid())
month Int
year Int
status String @default("PENDING_CALCULATION")
// PENDING_CALCULATION, CALCULATED, UNDER_REVIEW, SUBMITTED, APPROVED, REJECTED, PROCESSING, PAID
totalGrossPiasters Int @default(0)
totalDeductionsPiasters Int @default(0)
totalBountiesPiasters Int @default(0)
totalAdjustmentsPiasters Int @default(0)
totalNetPiasters Int @default(0)
contractorCount Int @default(0)
calculatedAt DateTime?
calculatedById String?
reviewedById String?
reviewedAt DateTime?
reviewNotes String?
submittedById String?
submittedAt DateTime?
approvedById String?
approvedAt DateTime?
approvalNotes String?
rejectedById String?
rejectedAt DateTime?
rejectionReason String?
processingAt DateTime?
paidAt DateTime?
paidById String?
items PayrollItem[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([month, year])
@@index([status])
}
model PayrollItem {
id String @id @default(uuid())
payrollId String
payroll Payroll @relation(fields: [payrollId], references: [id], onDelete: Cascade)
userId String
user User @relation("UserPayrollItems", fields: [userId], references: [id], onDelete: Restrict)
actualSalaryPiasters Int
totalBountiesPiasters Int @default(0)
totalPositiveAdjPiasters Int @default(0)
totalNegativeAdjPiasters Int @default(0)
totalCatADeductions Int @default(0)
totalCatBDeductions Int @default(0)
totalCatCDeductions Int @default(0)
totalCatDDeductions Int @default(0)
totalDeductionsPiasters Int @default(0)
netPayablePiasters Int @default(0)
expectedWorkingDays Int @default(0)
actualWorkingDays Int @default(0)
dailyRatePiasters Int @default(0)
notes String?
overrideAmount Int?
overrideReason String?
overriddenById String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([payrollId, userId])
@@index([userId])
@@index([payrollId])
}
\ No newline at end of file
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment