Files
hr-portal/app/backend/src/payroll/payroll.service.ts
T
TenX PM 87e9346d62 feat: complete HR portal full-stack application
- 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>
2026-05-04 19:32:52 +00:00

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