87e9346d62
- NestJS backend with 11 modules: Auth, Employees, Departments, Attendance, Leaves, Payroll, Reimbursements, Announcements, Tax, Reports, Admin - JWT authentication with refresh tokens, role-based access (employee/hr_admin/super_admin) - MongoDB schemas with Mongoose for all entities - PDF payslip generation with pdfkit - OpenTelemetry tracing to SigNoz - Automatic database seeding on first startup - Next.js 14 frontend with App Router, Tailwind CSS - 25 pages covering all employee, HR admin, and super admin workflows - Multi-stage Dockerfile with nginx proxy - test-manifest.json for E2E testing Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
284 lines
9.8 KiB
TypeScript
284 lines
9.8 KiB
TypeScript
import {
|
|
Injectable, NotFoundException, ConflictException, BadRequestException
|
|
} from '@nestjs/common';
|
|
import { InjectModel } from '@nestjs/mongoose';
|
|
import { Model, Types } from 'mongoose';
|
|
import { Response } from 'express';
|
|
import * as PDFDocument from 'pdfkit';
|
|
import { PayrollRun, PayrollRunDocument, Payslip, PayslipDocument } from './schemas/payroll.schema';
|
|
import { Employee, EmployeeDocument } from '../employees/schemas/employee.schema';
|
|
import { Leave, LeaveDocument } from '../leaves/schemas/leave.schema';
|
|
import { TaxDeclaration, TaxDeclarationDocument } from '../tax/schemas/tax.schema';
|
|
import { GeneratePayrollDto } from './dto/payroll.dto';
|
|
|
|
@Injectable()
|
|
export class PayrollService {
|
|
constructor(
|
|
@InjectModel(PayrollRun.name) private payrollRunModel: Model<PayrollRunDocument>,
|
|
@InjectModel(Payslip.name) private payslipModel: Model<PayslipDocument>,
|
|
@InjectModel(Employee.name) private employeeModel: Model<EmployeeDocument>,
|
|
@InjectModel(Leave.name) private leaveModel: Model<LeaveDocument>,
|
|
@InjectModel(TaxDeclaration.name) private taxDecModel: Model<TaxDeclarationDocument>,
|
|
) {}
|
|
|
|
private calculatePT(annualSalary: number): number {
|
|
// Karnataka PT slabs
|
|
if (annualSalary <= 150000) return 0;
|
|
if (annualSalary <= 200000) return 1500 / 12;
|
|
return 2500 / 12;
|
|
}
|
|
|
|
private calculateTDS(annualGross: number, declarations: any): number {
|
|
const deductions = Math.min(declarations?.section80C || 0, 150000);
|
|
const section80D = Math.min(declarations?.section80D || 0, 25000);
|
|
const hra = declarations?.hra || 0;
|
|
const lta = declarations?.lta || 0;
|
|
const homeLoanInterest = Math.min(declarations?.homeLoanInterest || 0, 200000);
|
|
|
|
const totalDeductions = deductions + section80D + hra + lta + homeLoanInterest + 50000; // Standard deduction
|
|
const taxableIncome = Math.max(0, annualGross - totalDeductions);
|
|
|
|
let tax = 0;
|
|
if (taxableIncome <= 250000) {
|
|
tax = 0;
|
|
} else if (taxableIncome <= 500000) {
|
|
tax = (taxableIncome - 250000) * 0.05;
|
|
} else if (taxableIncome <= 1000000) {
|
|
tax = 12500 + (taxableIncome - 500000) * 0.2;
|
|
} else {
|
|
tax = 112500 + (taxableIncome - 1000000) * 0.3;
|
|
}
|
|
|
|
// Add 4% cess
|
|
tax = tax * 1.04;
|
|
|
|
return Math.round(tax / 12);
|
|
}
|
|
|
|
async generatePayroll(dto: GeneratePayrollDto, generatedBy: string) {
|
|
const existing = await this.payrollRunModel.findOne({
|
|
month: dto.month,
|
|
year: dto.year,
|
|
});
|
|
|
|
if (existing) {
|
|
throw new ConflictException(`Payroll for ${dto.month}/${dto.year} already generated`);
|
|
}
|
|
|
|
const employees = await this.employeeModel.find({ isActive: true }).lean();
|
|
|
|
const payrollRun = new this.payrollRunModel({
|
|
month: dto.month,
|
|
year: dto.year,
|
|
status: 'processing',
|
|
generatedBy: new Types.ObjectId(generatedBy),
|
|
generatedAt: new Date(),
|
|
});
|
|
|
|
const savedRun = await payrollRun.save();
|
|
let totalGross = 0;
|
|
let totalNet = 0;
|
|
|
|
for (const employee of employees) {
|
|
const salary = (employee.salaryStructure || {}) as any;
|
|
const basic = salary.basic || 0;
|
|
const hra = salary.hra || 0;
|
|
const da = salary.da || 0;
|
|
const specialAllowance = salary.specialAllowance || 0;
|
|
const gross = basic + hra + da + specialAllowance;
|
|
|
|
// Calculate LOP days
|
|
const fromDate = new Date(dto.year, dto.month - 1, 1);
|
|
const toDate = new Date(dto.year, dto.month, 0);
|
|
const lopLeaves = await this.leaveModel.find({
|
|
employeeId: employee._id,
|
|
leaveType: 'lop',
|
|
status: 'approved',
|
|
fromDate: { $gte: fromDate },
|
|
toDate: { $lte: toDate },
|
|
}).lean();
|
|
|
|
const lopDays = lopLeaves.reduce((sum, l) => sum + l.numberOfDays, 0);
|
|
const workingDays = toDate.getDate();
|
|
const lopDeduction = lopDays > 0 ? (gross / workingDays) * lopDays : 0;
|
|
const adjustedGross = gross - lopDeduction;
|
|
|
|
// PF
|
|
const pfEmployee = Math.round(Math.min(basic, 15000) * 0.12);
|
|
const pfEmployer = Math.round(Math.min(basic, 15000) * 0.12);
|
|
|
|
// Professional Tax
|
|
const pt = Math.round(this.calculatePT(adjustedGross * 12));
|
|
|
|
// Tax Declarations
|
|
const currentFY = dto.month >= 4 ? `${dto.year}-${dto.year + 1}` : `${dto.year - 1}-${dto.year}`;
|
|
const taxDec = await this.taxDecModel.findOne({
|
|
employeeId: employee._id,
|
|
financialYear: currentFY,
|
|
}).lean();
|
|
|
|
const tds = this.calculateTDS(adjustedGross * 12, taxDec?.declarations);
|
|
const totalDeductions = pfEmployee + pt + tds;
|
|
const netPay = Math.round(adjustedGross - totalDeductions);
|
|
|
|
const payslip = new this.payslipModel({
|
|
employeeId: employee._id,
|
|
payrollRunId: savedRun._id,
|
|
month: dto.month,
|
|
year: dto.year,
|
|
earnings: {
|
|
basic: Math.round(basic),
|
|
hra: Math.round(hra),
|
|
da: Math.round(da),
|
|
specialAllowance: Math.round(specialAllowance),
|
|
gross: Math.round(adjustedGross),
|
|
},
|
|
deductions: {
|
|
pfEmployee,
|
|
pfEmployer,
|
|
tds,
|
|
professionalTax: pt,
|
|
totalDeductions,
|
|
},
|
|
netPay,
|
|
lopDays,
|
|
status: 'generated',
|
|
});
|
|
|
|
await payslip.save();
|
|
totalGross += Math.round(adjustedGross);
|
|
totalNet += netPay;
|
|
}
|
|
|
|
return this.payrollRunModel.findByIdAndUpdate(
|
|
savedRun._id,
|
|
{
|
|
status: 'completed',
|
|
totalEmployees: employees.length,
|
|
totalGross,
|
|
totalNet,
|
|
},
|
|
{ new: true },
|
|
);
|
|
}
|
|
|
|
async findPayrollRuns(query: any) {
|
|
const filter: any = {};
|
|
if (query.month) filter.month = parseInt(query.month);
|
|
if (query.year) filter.year = parseInt(query.year);
|
|
|
|
return this.payrollRunModel
|
|
.find(filter)
|
|
.populate('generatedBy', 'firstName lastName')
|
|
.sort({ year: -1, month: -1 })
|
|
.lean();
|
|
}
|
|
|
|
async findPayslips(query: any, currentUser: any) {
|
|
const filter: any = {};
|
|
|
|
if (currentUser.role === 'employee') {
|
|
filter.employeeId = new Types.ObjectId(currentUser._id);
|
|
} else if (query.employeeId) {
|
|
filter.employeeId = new Types.ObjectId(query.employeeId);
|
|
}
|
|
|
|
if (query.month) filter.month = parseInt(query.month);
|
|
if (query.year) filter.year = parseInt(query.year);
|
|
|
|
return this.payslipModel
|
|
.find(filter)
|
|
.populate('employeeId', 'firstName lastName employeeId department designation')
|
|
.populate('payrollRunId')
|
|
.sort({ year: -1, month: -1 })
|
|
.lean();
|
|
}
|
|
|
|
async findPayslipById(id: string) {
|
|
const payslip = await this.payslipModel
|
|
.findById(id)
|
|
.populate('employeeId', 'firstName lastName employeeId department designation email panNumber bankAccountNumber bankIfscCode')
|
|
.populate('payrollRunId')
|
|
.lean();
|
|
|
|
if (!payslip) throw new NotFoundException('Payslip not found');
|
|
return payslip;
|
|
}
|
|
|
|
async generatePdf(id: string, res: Response) {
|
|
const payslip = await this.payslipModel
|
|
.findById(id)
|
|
.populate<{ employeeId: any }>('employeeId', 'firstName lastName employeeId department designation email panNumber')
|
|
.lean();
|
|
|
|
if (!payslip) throw new NotFoundException('Payslip not found');
|
|
|
|
const doc = new PDFDocument({ margin: 50 });
|
|
res.setHeader('Content-Type', 'application/pdf');
|
|
res.setHeader(
|
|
'Content-Disposition',
|
|
`attachment; filename=payslip-${payslip.month}-${payslip.year}.pdf`,
|
|
);
|
|
|
|
doc.pipe(res);
|
|
|
|
// Header
|
|
doc.fontSize(20).font('Helvetica-Bold').text('HR PORTAL', { align: 'center' });
|
|
doc.fontSize(14).font('Helvetica').text('PAYSLIP', { align: 'center' });
|
|
doc.moveDown();
|
|
|
|
const monthNames = ['', 'January', 'February', 'March', 'April', 'May', 'June',
|
|
'July', 'August', 'September', 'October', 'November', 'December'];
|
|
doc.fontSize(12).text(`Pay Period: ${monthNames[payslip.month]} ${payslip.year}`, { align: 'center' });
|
|
doc.moveDown();
|
|
|
|
// Employee Info
|
|
doc.fontSize(10).font('Helvetica-Bold').text('Employee Details');
|
|
doc.moveTo(50, doc.y).lineTo(550, doc.y).stroke();
|
|
doc.moveDown(0.5);
|
|
|
|
const emp = payslip.employeeId as any;
|
|
doc.font('Helvetica');
|
|
doc.text(`Name: ${emp?.firstName} ${emp?.lastName}`);
|
|
doc.text(`Employee ID: ${emp?.employeeId}`);
|
|
doc.text(`Designation: ${emp?.designation || 'N/A'}`);
|
|
doc.text(`PAN: ${emp?.panNumber || 'N/A'}`);
|
|
doc.moveDown();
|
|
|
|
// Earnings
|
|
doc.font('Helvetica-Bold').text('Earnings');
|
|
doc.moveTo(50, doc.y).lineTo(550, doc.y).stroke();
|
|
doc.moveDown(0.5);
|
|
doc.font('Helvetica');
|
|
|
|
const earnings = payslip.earnings as any;
|
|
doc.text(`Basic Salary: ₹${earnings?.basic?.toLocaleString() || 0}`);
|
|
doc.text(`HRA: ₹${earnings?.hra?.toLocaleString() || 0}`);
|
|
doc.text(`DA: ₹${earnings?.da?.toLocaleString() || 0}`);
|
|
doc.text(`Special Allowance: ₹${earnings?.specialAllowance?.toLocaleString() || 0}`);
|
|
doc.font('Helvetica-Bold').text(`Gross Pay: ₹${earnings?.gross?.toLocaleString() || 0}`);
|
|
doc.moveDown();
|
|
|
|
// Deductions
|
|
doc.font('Helvetica-Bold').text('Deductions');
|
|
doc.moveTo(50, doc.y).lineTo(550, doc.y).stroke();
|
|
doc.moveDown(0.5);
|
|
doc.font('Helvetica');
|
|
|
|
const deductions = payslip.deductions as any;
|
|
doc.text(`PF (Employee): ₹${deductions?.pfEmployee?.toLocaleString() || 0}`);
|
|
doc.text(`Professional Tax: ₹${deductions?.professionalTax?.toLocaleString() || 0}`);
|
|
doc.text(`TDS: ₹${deductions?.tds?.toLocaleString() || 0}`);
|
|
doc.font('Helvetica-Bold').text(`Total Deductions: ₹${deductions?.totalDeductions?.toLocaleString() || 0}`);
|
|
doc.moveDown();
|
|
|
|
// Net Pay
|
|
doc.fontSize(14).font('Helvetica-Bold');
|
|
doc.moveTo(50, doc.y).lineTo(550, doc.y).stroke();
|
|
doc.moveDown(0.5);
|
|
doc.text(`NET PAY: ₹${payslip.netPay?.toLocaleString() || 0}`, { align: 'right' });
|
|
|
|
doc.end();
|
|
}
|
|
}
|