From 7a2037b93b5bc8230382619dac02f94e40d51e60 Mon Sep 17 00:00:00 2001 From: Satyam Pandey Date: Sun, 8 Feb 2026 23:48:12 +0530 Subject: [PATCH] feat: Implement Global Payroll Architect, Employee Perks & Statutory Engine (#589) - Add SalaryStructure, PayrollRun, and EmployeePerk models - Implement comprehensive DeductionEngine with TDS calculation for both tax regimes - Support Professional Tax, PF, ESI calculations with state-specific rates - Build PayrollService with batch processing and approval workflow - Create automated payroll generation with multi-stage approval system - Add HRA and LTA exemption calculations - Implement YTD tracking and payslip generation - Build premium Payroll Management Dashboard with Chart.js integration - Create interactive employee salary structures and payroll run management - Add automated monthly payroll generation cron job - Support flexible component-based salary structures (earnings, deductions, reimbursements) --- models/EmployeePerk.js | 99 +++++++ models/PayrollRun.js | 153 ++++++++++ models/SalaryStructure.js | 124 ++++++++ public/expensetracker.css | 360 +++++++++++++++++++++++ public/js/payroll-controller.js | 496 ++++++++++++++++++++++++++++++++ public/payroll-management.html | 254 ++++++++++++++++ routes/payroll.js | 247 ++++++++++++++++ server.js | 22 +- services/cronJobs.js | 34 +++ services/deductionEngine.js | 271 +++++++++++++++++ services/payrollService.js | 331 +++++++++++++++++++++ 11 files changed, 2381 insertions(+), 10 deletions(-) create mode 100644 models/EmployeePerk.js create mode 100644 models/PayrollRun.js create mode 100644 models/SalaryStructure.js create mode 100644 public/js/payroll-controller.js create mode 100644 public/payroll-management.html create mode 100644 routes/payroll.js create mode 100644 services/deductionEngine.js create mode 100644 services/payrollService.js diff --git a/models/EmployeePerk.js b/models/EmployeePerk.js new file mode 100644 index 00000000..fcef9bfb --- /dev/null +++ b/models/EmployeePerk.js @@ -0,0 +1,99 @@ +const mongoose = require('mongoose'); + +/** + * EmployeePerk Model + * Manages non-cash benefits and perquisites for employees + */ +const employeePerkSchema = new mongoose.Schema({ + userId: { + type: mongoose.Schema.Types.ObjectId, + ref: 'User', + required: true, + index: true + }, + employeeId: { + type: String, + required: true, + index: true + }, + employeeName: String, + perkType: { + type: String, + enum: [ + 'company_car', + 'housing', + 'meal_vouchers', + 'health_insurance', + 'life_insurance', + 'stock_options', + 'club_membership', + 'phone_allowance', + 'internet_allowance', + 'education_allowance', + 'relocation_assistance', + 'other' + ], + required: true + }, + perkName: { + type: String, + required: true + }, + description: String, + monetaryValue: { + type: Number, + default: 0 + }, + taxableValue: { + type: Number, + default: 0 + }, + isTaxable: { + type: Boolean, + default: true + }, + frequency: { + type: String, + enum: ['one_time', 'monthly', 'quarterly', 'annual'], + default: 'monthly' + }, + effectiveFrom: { + type: Date, + required: true + }, + effectiveTo: Date, + provider: { + name: String, + contactDetails: String + }, + documents: [{ + documentType: String, + documentUrl: String, + uploadedAt: Date + }], + utilizationTracking: { + isTracked: { + type: Boolean, + default: false + }, + utilizationLimit: Number, + utilizationUsed: { + type: Number, + default: 0 + } + }, + status: { + type: String, + enum: ['active', 'suspended', 'expired', 'cancelled'], + default: 'active' + }, + notes: String +}, { + timestamps: true +}); + +// Indexes +employeePerkSchema.index({ userId: 1, employeeId: 1, status: 1 }); +employeePerkSchema.index({ perkType: 1, effectiveFrom: 1 }); + +module.exports = mongoose.model('EmployeePerk', employeePerkSchema); diff --git a/models/PayrollRun.js b/models/PayrollRun.js new file mode 100644 index 00000000..c83b18ef --- /dev/null +++ b/models/PayrollRun.js @@ -0,0 +1,153 @@ +const mongoose = require('mongoose'); + +/** + * PayrollRun Model + * Manages batch payroll processing for a specific period + */ +const payrollEntrySchema = new mongoose.Schema({ + employeeId: { + type: String, + required: true + }, + employeeName: String, + salaryStructureId: { + type: mongoose.Schema.Types.ObjectId, + ref: 'SalaryStructure' + }, + earnings: [{ + componentName: String, + amount: Number + }], + deductions: [{ + componentName: String, + amount: Number + }], + reimbursements: [{ + componentName: String, + amount: Number + }], + grossPay: { + type: Number, + required: true + }, + totalDeductions: { + type: Number, + default: 0 + }, + netPay: { + type: Number, + required: true + }, + taxDeducted: { + type: Number, + default: 0 + }, + professionalTax: { + type: Number, + default: 0 + }, + providentFund: { + type: Number, + default: 0 + }, + esi: { + type: Number, + default: 0 + }, + paymentStatus: { + type: String, + enum: ['pending', 'processed', 'failed', 'reversed'], + default: 'pending' + }, + paymentDate: Date, + paymentReference: String, + remarks: String +}, { _id: false }); + +const payrollRunSchema = new mongoose.Schema({ + userId: { + type: mongoose.Schema.Types.ObjectId, + ref: 'User', + required: true, + index: true + }, + runId: { + type: String, + unique: true, + required: true + }, + payrollPeriod: { + month: { + type: Number, + required: true, + min: 1, + max: 12 + }, + year: { + type: Number, + required: true + } + }, + periodStart: { + type: Date, + required: true + }, + periodEnd: { + type: Date, + required: true + }, + entries: [payrollEntrySchema], + summary: { + totalEmployees: { + type: Number, + default: 0 + }, + totalGrossPay: { + type: Number, + default: 0 + }, + totalDeductions: { + type: Number, + default: 0 + }, + totalNetPay: { + type: Number, + default: 0 + }, + totalTax: { + type: Number, + default: 0 + } + }, + status: { + type: String, + enum: ['draft', 'pending_approval', 'approved', 'processing', 'completed', 'failed'], + default: 'draft' + }, + approvedBy: { + type: mongoose.Schema.Types.ObjectId, + ref: 'User' + }, + approvedAt: Date, + processedAt: Date, + notes: String +}, { + timestamps: true +}); + +// Pre-save hook to calculate summary +payrollRunSchema.pre('save', function (next) { + this.summary.totalEmployees = this.entries.length; + this.summary.totalGrossPay = this.entries.reduce((sum, e) => sum + e.grossPay, 0); + this.summary.totalDeductions = this.entries.reduce((sum, e) => sum + e.totalDeductions, 0); + this.summary.totalNetPay = this.entries.reduce((sum, e) => sum + e.netPay, 0); + this.summary.totalTax = this.entries.reduce((sum, e) => sum + e.taxDeducted, 0); + + next(); +}); + +// Indexes +payrollRunSchema.index({ userId: 1, 'payrollPeriod.year': 1, 'payrollPeriod.month': 1 }); +payrollRunSchema.index({ status: 1, createdAt: -1 }); + +module.exports = mongoose.model('PayrollRun', payrollRunSchema); diff --git a/models/SalaryStructure.js b/models/SalaryStructure.js new file mode 100644 index 00000000..d632e0bd --- /dev/null +++ b/models/SalaryStructure.js @@ -0,0 +1,124 @@ +const mongoose = require('mongoose'); + +/** + * SalaryStructure Model + * Defines flexible component-based salary structure for employees + */ +const salaryComponentSchema = new mongoose.Schema({ + componentName: { + type: String, + required: true + }, + componentType: { + type: String, + enum: ['earning', 'deduction', 'reimbursement'], + required: true + }, + calculationType: { + type: String, + enum: ['fixed', 'percentage', 'formula'], + default: 'fixed' + }, + amount: { + type: Number, + default: 0 + }, + percentage: { + type: Number, + default: 0 + }, + baseComponent: { + type: String, + default: null + }, + isTaxable: { + type: Boolean, + default: true + }, + isStatutory: { + type: Boolean, + default: false + } +}, { _id: false }); + +const salaryStructureSchema = new mongoose.Schema({ + userId: { + type: mongoose.Schema.Types.ObjectId, + ref: 'User', + required: true, + index: true + }, + employeeId: { + type: String, + required: true, + index: true + }, + employeeName: { + type: String, + required: true + }, + designation: String, + department: String, + effectiveFrom: { + type: Date, + required: true + }, + effectiveTo: Date, + components: [salaryComponentSchema], + ctc: { + type: Number, + required: true + }, + grossSalary: { + type: Number, + default: 0 + }, + netSalary: { + type: Number, + default: 0 + }, + paymentFrequency: { + type: String, + enum: ['monthly', 'bi-weekly', 'weekly'], + default: 'monthly' + }, + bankDetails: { + accountNumber: String, + ifscCode: String, + bankName: String, + accountHolderName: String + }, + taxRegime: { + type: String, + enum: ['old', 'new'], + default: 'new' + }, + isActive: { + type: Boolean, + default: true + } +}, { + timestamps: true +}); + +// Pre-save hook to calculate gross and net salary +salaryStructureSchema.pre('save', function (next) { + const earnings = this.components + .filter(c => c.componentType === 'earning') + .reduce((sum, c) => sum + c.amount, 0); + + const deductions = this.components + .filter(c => c.componentType === 'deduction') + .reduce((sum, c) => sum + c.amount, 0); + + this.grossSalary = earnings; + this.netSalary = earnings - deductions; + + next(); +}); + +// Indexes +salaryStructureSchema.index({ userId: 1, employeeId: 1 }); +salaryStructureSchema.index({ effectiveFrom: 1, isActive: 1 }); + +module.exports = mongoose.model('SalaryStructure', salaryStructureSchema); diff --git a/public/expensetracker.css b/public/expensetracker.css index f7307077..0ca9e194 100644 --- a/public/expensetracker.css +++ b/public/expensetracker.css @@ -9631,3 +9631,363 @@ input:checked + .toggle-slider::before { } .checkbox-container input { width: 16px; height: 16px; } + +/* ============================================ + GLOBAL PAYROLL MANAGEMENT + Issue #589: Payroll & Statutory Engine + ============================================ */ + +.payroll-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 30px; +} + +.header-actions { + display: flex; + gap: 15px; +} + +.stats-grid { + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: 20px; + margin-bottom: 30px; +} + +.stat-card { + display: flex; + align-items: center; + gap: 15px; + padding: 20px; +} + +.stat-icon { + width: 50px; + height: 50px; + border-radius: 10px; + display: flex; + align-items: center; + justify-content: center; + font-size: 1.5rem; +} + +.stat-content label { + font-size: 0.75rem; + color: var(--text-secondary); + display: block; +} + +.stat-content h2 { + font-size: 1.8rem; + margin: 5px 0 0 0; +} + +.payroll-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 25px; + margin-bottom: 25px; +} + +.filter-tabs { + display: flex; + gap: 10px; +} + +.tab-btn { + padding: 5px 15px; + background: rgba(255,255,255,0.05); + border: 1px solid rgba(255,255,255,0.1); + border-radius: 6px; + color: var(--text-secondary); + cursor: pointer; + transition: all 0.3s; +} + +.tab-btn.active { + background: var(--accent-primary); + color: var(--bg-primary); + border-color: var(--accent-primary); +} + +.payroll-runs-list, .employees-list { + display: flex; + flex-direction: column; + gap: 15px; + max-height: 600px; + overflow-y: auto; +} + +.payroll-run-card { + padding: 20px; + cursor: pointer; + transition: transform 0.2s; +} + +.payroll-run-card:hover { + transform: translateY(-2px); +} + +.run-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 15px; +} + +.run-period strong { + display: block; + font-size: 1.1rem; +} + +.run-id { + font-size: 0.75rem; + color: var(--text-secondary); +} + +.run-summary { + display: flex; + gap: 20px; + padding: 15px 0; + border-top: 1px solid rgba(255,255,255,0.05); + border-bottom: 1px solid rgba(255,255,255,0.05); +} + +.summary-item { + flex: 1; +} + +.summary-item label { + font-size: 0.7rem; + color: var(--text-secondary); + display: block; +} + +.summary-item strong { + font-size: 1rem; +} + +.run-actions { + margin-top: 15px; + display: flex; + gap: 10px; +} + +.employee-card { + padding: 15px; +} + +.emp-header { + display: flex; + align-items: center; + gap: 12px; + margin-bottom: 15px; +} + +.emp-avatar { + width: 45px; + height: 45px; + border-radius: 50%; + background: rgba(100, 255, 218, 0.15); + display: flex; + align-items: center; + justify-content: center; + color: var(--accent-primary); +} + +.emp-info { + flex: 1; +} + +.emp-info strong { + display: block; + font-size: 0.95rem; +} + +.emp-info span { + font-size: 0.7rem; + color: var(--text-secondary); + display: block; +} + +.emp-designation { + margin-top: 2px; +} + +.status-pill { + font-size: 0.65rem; + padding: 3px 10px; + border-radius: 12px; + text-transform: uppercase; +} + +.status-pill.active { + background: rgba(100, 255, 218, 0.2); + color: #64ffda; +} + +.status-pill.inactive { + background: rgba(255, 107, 107, 0.2); + color: #ff6b6b; +} + +.emp-salary { + display: flex; + gap: 15px; + padding-top: 12px; + border-top: 1px solid rgba(255,255,255,0.05); +} + +.salary-item { + flex: 1; +} + +.salary-item label { + font-size: 0.65rem; + color: var(--text-secondary); + display: block; +} + +.salary-item strong { + font-size: 0.9rem; +} + +.status-badge { + font-size: 0.7rem; + padding: 4px 12px; + border-radius: 4px; + text-transform: uppercase; +} + +.status-badge.draft { + background: rgba(136, 146, 176, 0.2); + color: #8892b0; +} + +.status-badge.pending_approval { + background: rgba(255, 159, 67, 0.2); + color: #ff9f43; +} + +.status-badge.approved { + background: rgba(72, 219, 251, 0.2); + color: #48dbfb; +} + +.status-badge.processing { + background: rgba(255, 159, 67, 0.2); + color: #ff9f43; +} + +.status-badge.completed { + background: rgba(100, 255, 218, 0.2); + color: #64ffda; +} + +.status-badge.failed { + background: rgba(255, 107, 107, 0.2); + color: #ff6b6b; +} + +.modal-large { + max-width: 900px; +} + +.form-section { + margin-bottom: 25px; + padding-bottom: 20px; + border-bottom: 1px solid rgba(255,255,255,0.05); +} + +.form-section h4 { + margin-bottom: 15px; + color: var(--accent-primary); +} + +.component-row { + margin-bottom: 10px; +} + +.search-input { + padding: 8px 15px; + background: rgba(255,255,255,0.05); + border: 1px solid rgba(255,255,255,0.1); + border-radius: 6px; + color: white; + width: 250px; +} + +.payroll-details { + padding: 20px 0; +} + +.details-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 20px; +} + +.details-summary { + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: 15px; + margin-bottom: 25px; +} + +.summary-card { + padding: 15px; + background: rgba(255,255,255,0.02); + border-radius: 8px; + text-align: center; +} + +.summary-card label { + font-size: 0.7rem; + color: var(--text-secondary); + display: block; + margin-bottom: 8px; +} + +.summary-card h3 { + font-size: 1.5rem; + margin: 0; +} + +.entries-table { + overflow-x: auto; +} + +.entries-table table { + width: 100%; + border-collapse: collapse; +} + +.entries-table th { + text-align: left; + padding: 12px; + background: rgba(255,255,255,0.02); + font-size: 0.8rem; + color: var(--text-secondary); +} + +.entries-table td { + padding: 12px; + border-bottom: 1px solid rgba(255,255,255,0.05); +} + +.entries-table td small { + color: var(--text-secondary); + font-size: 0.75rem; +} + +.btn-success { + background: linear-gradient(135deg, #64ffda, #48dbfb); + color: var(--bg-primary); +} + +.text-accent { + color: var(--accent-primary); +} diff --git a/public/js/payroll-controller.js b/public/js/payroll-controller.js new file mode 100644 index 00000000..65ba5da5 --- /dev/null +++ b/public/js/payroll-controller.js @@ -0,0 +1,496 @@ +/** + * Payroll Management Controller + */ + +let payrollTrendsChart = null; +let currentPayrollRuns = []; +let currentEmployees = []; + +document.addEventListener('DOMContentLoaded', () => { + initializeYearDropdown(); + loadPayrollDashboard(); + loadPayrollRuns(); + loadEmployees(); + setupForms(); +}); + +function initializeYearDropdown() { + const yearSelect = document.getElementById('payroll-year'); + const currentYear = new Date().getFullYear(); + + for (let i = currentYear; i >= currentYear - 5; i--) { + const option = document.createElement('option'); + option.value = i; + option.textContent = i; + if (i === currentYear) option.selected = true; + yearSelect.appendChild(option); + } + + // Set current month + const monthSelect = document.getElementById('payroll-month'); + monthSelect.value = new Date().getMonth() + 1; +} + +async function loadPayrollDashboard() { + try { + const res = await fetch('/api/payroll/dashboard', { + headers: { 'Authorization': `Bearer ${localStorage.getItem('token')}` } + }); + const { data } = await res.json(); + + updateDashboardStats(data); + renderPayrollTrends(data.monthlyTrends); + } catch (err) { + console.error('Failed to load payroll dashboard:', err); + } +} + +function updateDashboardStats(data) { + document.getElementById('active-employees').textContent = data.activeEmployeeCount || 0; + document.getElementById('pending-approvals').textContent = data.pendingApprovals || 0; + + if (data.currentPayroll) { + document.getElementById('monthly-payout').textContent = + `₹${data.currentPayroll.summary.totalNetPay.toLocaleString()}`; + document.getElementById('tax-deducted').textContent = + `₹${data.currentPayroll.summary.totalTax.toLocaleString()}`; + } else { + document.getElementById('monthly-payout').textContent = '₹0'; + document.getElementById('tax-deducted').textContent = '₹0'; + } +} + +function renderPayrollTrends(trends) { + const ctx = document.getElementById('payrollTrendsChart').getContext('2d'); + + if (payrollTrendsChart) { + payrollTrendsChart.destroy(); + } + + const labels = trends.map(t => `${getMonthName(t.month)} ${t.year}`); + const netPayData = trends.map(t => t.totalNetPay); + const taxData = trends.map(t => t.totalTax); + + payrollTrendsChart = new Chart(ctx, { + type: 'line', + data: { + labels, + datasets: [ + { + label: 'Net Payout', + data: netPayData, + borderColor: '#64ffda', + backgroundColor: 'rgba(100, 255, 218, 0.1)', + fill: true, + tension: 0.4 + }, + { + label: 'Tax Deducted', + data: taxData, + borderColor: '#ff9f43', + backgroundColor: 'rgba(255, 159, 67, 0.1)', + fill: true, + tension: 0.4 + } + ] + }, + options: { + responsive: true, + maintainAspectRatio: false, + plugins: { + legend: { + labels: { color: '#8892b0' } + } + }, + scales: { + x: { + ticks: { color: '#8892b0' }, + grid: { color: 'rgba(255,255,255,0.05)' } + }, + y: { + ticks: { + color: '#8892b0', + callback: value => '₹' + value.toLocaleString() + }, + grid: { color: 'rgba(255,255,255,0.05)' } + } + } + } + }); +} + +async function loadPayrollRuns() { + try { + const res = await fetch('/api/payroll/runs', { + headers: { 'Authorization': `Bearer ${localStorage.getItem('token')}` } + }); + const { data } = await res.json(); + + currentPayrollRuns = data; + renderPayrollRuns(data); + } catch (err) { + console.error('Failed to load payroll runs:', err); + } +} + +function renderPayrollRuns(runs) { + const list = document.getElementById('payroll-runs-list'); + + if (!runs || runs.length === 0) { + list.innerHTML = '
No payroll runs found.
'; + return; + } + + list.innerHTML = runs.map(run => ` +
+
+
+ ${getMonthName(run.payrollPeriod.month)} ${run.payrollPeriod.year} + ${run.runId} +
+ ${run.status.replace('_', ' ')} +
+
+
+ + ${run.summary.totalEmployees} +
+
+ + ₹${run.summary.totalGrossPay.toLocaleString()} +
+
+ + ₹${run.summary.totalNetPay.toLocaleString()} +
+
+ ${run.status === 'draft' || run.status === 'pending_approval' ? ` +
+ +
+ ` : ''} + ${run.status === 'approved' ? ` +
+ +
+ ` : ''} +
+ `).join(''); +} + +async function loadEmployees() { + try { + const res = await fetch('/api/payroll/salary-structures', { + headers: { 'Authorization': `Bearer ${localStorage.getItem('token')}` } + }); + const { data } = await res.json(); + + currentEmployees = data; + renderEmployees(data); + } catch (err) { + console.error('Failed to load employees:', err); + } +} + +function renderEmployees(employees) { + const list = document.getElementById('employees-list'); + + if (!employees || employees.length === 0) { + list.innerHTML = '
No employees found.
'; + return; + } + + list.innerHTML = employees.map(emp => ` +
+
+
+ +
+
+ ${emp.employeeName} + ${emp.employeeId} + ${emp.designation || 'N/A'} +
+ + ${emp.isActive ? 'Active' : 'Inactive'} + +
+
+
+ + ₹${emp.ctc.toLocaleString()}/yr +
+
+ + ₹${Math.round(emp.grossSalary).toLocaleString()} +
+
+ + ₹${Math.round(emp.netSalary).toLocaleString()} +
+
+
+ `).join(''); +} + +async function viewPayrollDetails(runId) { + try { + const res = await fetch(`/api/payroll/runs/${runId}`, { + headers: { 'Authorization': `Bearer ${localStorage.getItem('token')}` } + }); + const { data } = await res.json(); + + const modal = document.getElementById('payroll-details-modal'); + const content = document.getElementById('payroll-details-content'); + + content.innerHTML = ` +
+
+

${getMonthName(data.payrollPeriod.month)} ${data.payrollPeriod.year}

+ ${data.status.replace('_', ' ')} +
+
+
+ +

${data.summary.totalEmployees}

+
+
+ +

₹${data.summary.totalGrossPay.toLocaleString()}

+
+
+ +

₹${data.summary.totalDeductions.toLocaleString()}

+
+
+ +

₹${data.summary.totalNetPay.toLocaleString()}

+
+
+
+ + + + + + + + + + + + ${data.entries.map(entry => ` + + + + + + + + `).join('')} + +
EmployeeGrossDeductionsNet PayStatus
+ ${entry.employeeName}
+ ${entry.employeeId} +
₹${entry.grossPay.toLocaleString()}₹${entry.totalDeductions.toLocaleString()}₹${entry.netPay.toLocaleString()}${entry.paymentStatus}
+
+
+ `; + + modal.classList.remove('hidden'); + } catch (err) { + console.error('Failed to load payroll details:', err); + } +} + +async function approvePayroll(event, runId) { + event.stopPropagation(); + + if (!confirm('Approve this payroll run?')) return; + + try { + const res = await fetch(`/api/payroll/runs/${runId}/approve`, { + method: 'POST', + headers: { 'Authorization': `Bearer ${localStorage.getItem('token')}` } + }); + + if (res.ok) { + loadPayrollRuns(); + loadPayrollDashboard(); + } + } catch (err) { + console.error('Failed to approve payroll:', err); + } +} + +async function processPayroll(event, runId) { + event.stopPropagation(); + + if (!confirm('Process payments for this payroll run?')) return; + + try { + const res = await fetch(`/api/payroll/runs/${runId}/process`, { + method: 'POST', + headers: { 'Authorization': `Bearer ${localStorage.getItem('token')}` } + }); + + if (res.ok) { + loadPayrollRuns(); + loadPayrollDashboard(); + } + } catch (err) { + console.error('Failed to process payroll:', err); + } +} + +function filterRuns(filter) { + document.querySelectorAll('.tab-btn').forEach(btn => btn.classList.remove('active')); + event.target.classList.add('active'); + + let filtered = currentPayrollRuns; + + if (filter === 'pending') { + filtered = currentPayrollRuns.filter(r => + r.status === 'draft' || r.status === 'pending_approval' || r.status === 'approved' + ); + } else if (filter === 'completed') { + filtered = currentPayrollRuns.filter(r => r.status === 'completed'); + } + + renderPayrollRuns(filtered); +} + +function getMonthName(month) { + const months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']; + return months[month - 1]; +} + +// Modal Functions +function openGeneratePayrollModal() { + document.getElementById('generate-payroll-modal').classList.remove('hidden'); +} + +function closeGeneratePayrollModal() { + document.getElementById('generate-payroll-modal').classList.add('hidden'); +} + +function openSalaryStructureModal() { + document.getElementById('salary-structure-modal').classList.remove('hidden'); +} + +function closeSalaryStructureModal() { + document.getElementById('salary-structure-modal').classList.add('hidden'); +} + +function closePayrollDetailsModal() { + document.getElementById('payroll-details-modal').classList.add('hidden'); +} + +function addComponent() { + const container = document.getElementById('components-container'); + const componentDiv = document.createElement('div'); + componentDiv.className = 'component-row'; + componentDiv.innerHTML = ` +
+
+ +
+
+ +
+
+ +
+ +
+ `; + container.appendChild(componentDiv); +} + +function setupForms() { + // Generate Payroll Form + document.getElementById('generate-payroll-form').addEventListener('submit', async (e) => { + e.preventDefault(); + + const month = parseInt(document.getElementById('payroll-month').value); + const year = parseInt(document.getElementById('payroll-year').value); + + try { + const res = await fetch('/api/payroll/generate', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${localStorage.getItem('token')}` + }, + body: JSON.stringify({ month, year }) + }); + + if (res.ok) { + closeGeneratePayrollModal(); + loadPayrollRuns(); + loadPayrollDashboard(); + } + } catch (err) { + console.error('Failed to generate payroll:', err); + } + }); + + // Salary Structure Form + document.getElementById('salary-structure-form').addEventListener('submit', async (e) => { + e.preventDefault(); + + const components = []; + document.querySelectorAll('.component-row').forEach(row => { + components.push({ + componentName: row.querySelector('.comp-name').value, + componentType: row.querySelector('.comp-type').value, + calculationType: 'fixed', + amount: parseFloat(row.querySelector('.comp-amount').value), + isTaxable: true + }); + }); + + const structureData = { + employeeId: document.getElementById('emp-id').value, + employeeName: document.getElementById('emp-name').value, + designation: document.getElementById('emp-designation').value, + department: document.getElementById('emp-department').value, + ctc: parseFloat(document.getElementById('emp-ctc').value), + taxRegime: document.getElementById('emp-tax-regime').value, + effectiveFrom: new Date(), + components, + bankDetails: { + accountNumber: document.getElementById('emp-account').value, + ifscCode: document.getElementById('emp-ifsc').value + } + }; + + try { + const res = await fetch('/api/payroll/salary-structures', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${localStorage.getItem('token')}` + }, + body: JSON.stringify(structureData) + }); + + if (res.ok) { + closeSalaryStructureModal(); + loadEmployees(); + loadPayrollDashboard(); + } + } catch (err) { + console.error('Failed to create salary structure:', err); + } + }); +} diff --git a/public/payroll-management.html b/public/payroll-management.html new file mode 100644 index 00000000..a4bf2110 --- /dev/null +++ b/public/payroll-management.html @@ -0,0 +1,254 @@ + + + + + + + Global Payroll Management - ExpenseFlow + + + + + + + + +
+ +
+
+

Enterprise Payroll Command Center

+

Automated salary processing, statutory compliance, and employee benefits management

+
+
+ + +
+
+ + +
+
+
+ +
+
+ +

0

+
+
+
+
+ +
+
+ +

₹0

+
+
+
+
+ +
+
+ +

₹0

+
+
+
+
+ +
+
+ +

0

+
+
+
+ + +
+ +
+
+

Payroll Runs

+
+ + + +
+
+
+
Loading payroll runs...
+
+
+ + +
+
+

Employee Salary Structures

+ +
+
+ +
+
+
+ + +
+
+

Payroll Trends (Last 6 Months)

+
+
+ +
+
+
+ + + + + + + + + + + + + + + \ No newline at end of file diff --git a/routes/payroll.js b/routes/payroll.js new file mode 100644 index 00000000..020a8957 --- /dev/null +++ b/routes/payroll.js @@ -0,0 +1,247 @@ +const express = require('express'); +const router = express.Router(); +const auth = require('../middleware/auth'); +const payrollService = require('../services/payrollService'); +const SalaryStructure = require('../models/SalaryStructure'); +const PayrollRun = require('../models/PayrollRun'); +const EmployeePerk = require('../models/EmployeePerk'); + +/** + * Get Payroll Dashboard + */ +router.get('/dashboard', auth, async (req, res) => { + try { + const dashboard = await payrollService.getPayrollDashboard(req.user._id); + res.json({ success: true, data: dashboard }); + } catch (err) { + res.status(500).json({ success: false, error: err.message }); + } +}); + +/** + * Generate Payroll for a Period + */ +router.post('/generate', auth, async (req, res) => { + try { + const { month, year } = req.body; + const payrollRun = await payrollService.generatePayroll(req.user._id, month, year); + res.json({ success: true, data: payrollRun }); + } catch (err) { + res.status(400).json({ success: false, error: err.message }); + } +}); + +/** + * Get All Payroll Runs + */ +router.get('/runs', auth, async (req, res) => { + try { + const runs = await PayrollRun.find({ userId: req.user._id }) + .sort({ createdAt: -1 }) + .limit(50); + res.json({ success: true, data: runs }); + } catch (err) { + res.status(500).json({ success: false, error: err.message }); + } +}); + +/** + * Get Specific Payroll Run + */ +router.get('/runs/:id', auth, async (req, res) => { + try { + const run = await PayrollRun.findOne({ + _id: req.params.id, + userId: req.user._id + }); + + if (!run) { + return res.status(404).json({ success: false, error: 'Payroll run not found' }); + } + + res.json({ success: true, data: run }); + } catch (err) { + res.status(500).json({ success: false, error: err.message }); + } +}); + +/** + * Approve Payroll Run + */ +router.post('/runs/:id/approve', auth, async (req, res) => { + try { + const payrollRun = await payrollService.approvePayroll(req.params.id, req.user._id); + res.json({ success: true, data: payrollRun }); + } catch (err) { + res.status(400).json({ success: false, error: err.message }); + } +}); + +/** + * Process Payroll Run (Disburse Payments) + */ +router.post('/runs/:id/process', auth, async (req, res) => { + try { + const payrollRun = await payrollService.processPayroll(req.params.id); + res.json({ success: true, data: payrollRun }); + } catch (err) { + res.status(400).json({ success: false, error: err.message }); + } +}); + +/** + * Get Employee Payslip + */ +router.get('/runs/:id/payslip/:employeeId', auth, async (req, res) => { + try { + const payslip = await payrollService.getPayslip(req.params.id, req.params.employeeId); + res.json({ success: true, data: payslip }); + } catch (err) { + res.status(404).json({ success: false, error: err.message }); + } +}); + +/** + * Create Salary Structure + */ +router.post('/salary-structures', auth, async (req, res) => { + try { + const salaryStructure = new SalaryStructure({ + ...req.body, + userId: req.user._id + }); + await salaryStructure.save(); + res.json({ success: true, data: salaryStructure }); + } catch (err) { + res.status(400).json({ success: false, error: err.message }); + } +}); + +/** + * Get All Salary Structures + */ +router.get('/salary-structures', auth, async (req, res) => { + try { + const structures = await SalaryStructure.find({ userId: req.user._id }); + res.json({ success: true, data: structures }); + } catch (err) { + res.status(500).json({ success: false, error: err.message }); + } +}); + +/** + * Update Salary Structure + */ +router.patch('/salary-structures/:id', auth, async (req, res) => { + try { + const structure = await SalaryStructure.findOneAndUpdate( + { _id: req.params.id, userId: req.user._id }, + req.body, + { new: true, runValidators: true } + ); + + if (!structure) { + return res.status(404).json({ success: false, error: 'Salary structure not found' }); + } + + res.json({ success: true, data: structure }); + } catch (err) { + res.status(400).json({ success: false, error: err.message }); + } +}); + +/** + * Deactivate Salary Structure + */ +router.delete('/salary-structures/:id', auth, async (req, res) => { + try { + const structure = await SalaryStructure.findOneAndUpdate( + { _id: req.params.id, userId: req.user._id }, + { isActive: false, effectiveTo: new Date() }, + { new: true } + ); + + if (!structure) { + return res.status(404).json({ success: false, error: 'Salary structure not found' }); + } + + res.json({ success: true, data: structure }); + } catch (err) { + res.status(400).json({ success: false, error: err.message }); + } +}); + +/** + * Create Employee Perk + */ +router.post('/perks', auth, async (req, res) => { + try { + const perk = new EmployeePerk({ + ...req.body, + userId: req.user._id + }); + await perk.save(); + res.json({ success: true, data: perk }); + } catch (err) { + res.status(400).json({ success: false, error: err.message }); + } +}); + +/** + * Get All Perks + */ +router.get('/perks', auth, async (req, res) => { + try { + const { employeeId } = req.query; + const query = { userId: req.user._id }; + + if (employeeId) { + query.employeeId = employeeId; + } + + const perks = await EmployeePerk.find(query); + res.json({ success: true, data: perks }); + } catch (err) { + res.status(500).json({ success: false, error: err.message }); + } +}); + +/** + * Update Perk + */ +router.patch('/perks/:id', auth, async (req, res) => { + try { + const perk = await EmployeePerk.findOneAndUpdate( + { _id: req.params.id, userId: req.user._id }, + req.body, + { new: true, runValidators: true } + ); + + if (!perk) { + return res.status(404).json({ success: false, error: 'Perk not found' }); + } + + res.json({ success: true, data: perk }); + } catch (err) { + res.status(400).json({ success: false, error: err.message }); + } +}); + +/** + * Get Employee YTD Statistics + */ +router.get('/ytd/:employeeId', auth, async (req, res) => { + try { + const year = parseInt(req.query.year) || new Date().getFullYear(); + const ytd = await payrollService.getEmployeeYTD( + req.user._id, + req.params.employeeId, + year + ); + res.json({ success: true, data: ytd }); + } catch (err) { + res.status(500).json({ success: false, error: err.message }); + } +}); + +module.exports = router; diff --git a/server.js b/server.js index 620fcffb..847acb19 100644 --- a/server.js +++ b/server.js @@ -43,8 +43,8 @@ const server = http.createServer(app); const io = socketIo(server, { cors: { origin: [process.env.FRONTEND_URL || - "http://localhost:3000", - 'https://accounts.clerk.dev', + "http://localhost:3000", + 'https://accounts.clerk.dev', 'https://*.clerk.accounts.dev' ], methods: ["GET", "POST"], @@ -63,10 +63,10 @@ app.use(helmet({ directives: { defaultSrc: ["'self'"], styleSrc: [ - "'self'", - "'unsafe-inline'", - "https://cdnjs.cloudflare.com", - "https://fonts.googleapis.com", + "'self'", + "'unsafe-inline'", + "https://cdnjs.cloudflare.com", + "https://fonts.googleapis.com", "https://api.github.com" ], scriptSrc: [ @@ -83,10 +83,10 @@ app.use(helmet({ scriptSrcAttr: ["'unsafe-inline'"], workerSrc: ["'self'", "blob:"], imgSrc: [ - "'self'", - "data:", - "https:", - "https://res.cloudinary.com", + "'self'", + "data:", + "https:", + "https://res.cloudinary.com", "https://api.github.com", "https://img.clerk.com" // For Clerk user avatars ], @@ -277,6 +277,8 @@ console.log('Collaboration handler initialized'); app.use('/api/auth', require('./middleware/rateLimiter').authLimiter, authRoutes); app.use('/api/expenses', expenseRoutes); // Expense management app.use('/api/currency', require('./routes/currency')); +app.use('/api/treasury', require('./routes/treasury')); +app.use('/api/payroll', require('./routes/payroll')); app.use('/api/groups', require('./routes/groups')); app.use('/api/splits', require('./routes/splits')); app.use('/api/workspaces', require('./routes/workspaces')); diff --git a/services/cronJobs.js b/services/cronJobs.js index 5f9c5227..26d7d710 100644 --- a/services/cronJobs.js +++ b/services/cronJobs.js @@ -344,6 +344,40 @@ class CronJobs { await this.retrainCategorizationModels(); }); + // Monthly payroll generation - 1st day of month at 12 AM UTC + cron.schedule('0 0 1 * *', async () => { + try { + console.log('[CronJobs] Running automated monthly payroll generation...'); + const payrollService = require('./payrollService'); + const User = require('../models/User'); + + const currentDate = new Date(); + const month = currentDate.getMonth() + 1; + const year = currentDate.getFullYear(); + + // Get all users with active salary structures + const SalaryStructure = require('../models/SalaryStructure'); + const usersWithPayroll = await SalaryStructure.distinct('userId', { isActive: true }); + + let successCount = 0; + let failCount = 0; + + for (const userId of usersWithPayroll) { + try { + await payrollService.generatePayroll(userId, month, year); + successCount++; + } catch (err) { + console.error(`[CronJobs] Failed to generate payroll for user ${userId}:`, err.message); + failCount++; + } + } + + console.log(`[CronJobs] Payroll generation completed: ${successCount} success, ${failCount} failed`); + } catch (err) { + console.error('[CronJobs] Error in monthly payroll generation:', err); + } + }); + console.log('Cron jobs initialized successfully'); } diff --git a/services/deductionEngine.js b/services/deductionEngine.js new file mode 100644 index 00000000..97cf4cb7 --- /dev/null +++ b/services/deductionEngine.js @@ -0,0 +1,271 @@ +/** + * Deduction Engine + * Calculates statutory deductions including TDS, Professional Tax, PF, ESI + */ + +class DeductionEngine { + /** + * Calculate Income Tax (TDS) based on tax regime and salary + */ + calculateIncomeTax(annualIncome, taxRegime = 'new', deductions = {}) { + if (taxRegime === 'new') { + return this.calculateNewRegimeTax(annualIncome); + } else { + return this.calculateOldRegimeTax(annualIncome, deductions); + } + } + + /** + * New Tax Regime (FY 2023-24 onwards) + */ + calculateNewRegimeTax(annualIncome) { + let tax = 0; + + // Tax slabs for new regime + const slabs = [ + { limit: 300000, rate: 0 }, + { limit: 600000, rate: 0.05 }, + { limit: 900000, rate: 0.10 }, + { limit: 1200000, rate: 0.15 }, + { limit: 1500000, rate: 0.20 }, + { limit: Infinity, rate: 0.30 } + ]; + + let previousLimit = 0; + for (const slab of slabs) { + if (annualIncome > previousLimit) { + const taxableInSlab = Math.min(annualIncome, slab.limit) - previousLimit; + tax += taxableInSlab * slab.rate; + previousLimit = slab.limit; + } else { + break; + } + } + + // Add cess (4% of tax) + tax += tax * 0.04; + + // Rebate under section 87A (if income <= 7 lakhs) + if (annualIncome <= 700000) { + tax = Math.max(0, tax - 25000); + } + + return Math.round(tax); + } + + /** + * Old Tax Regime with deductions + */ + calculateOldRegimeTax(annualIncome, deductions = {}) { + // Standard deduction + const standardDeduction = 50000; + + // Section 80C (max 150000) + const section80C = Math.min(deductions.section80C || 0, 150000); + + // Section 80D (Health insurance - max 25000 for self, 50000 if senior citizen) + const section80D = Math.min(deductions.section80D || 0, 25000); + + // HRA exemption + const hraExemption = deductions.hraExemption || 0; + + // Calculate taxable income + let taxableIncome = annualIncome - standardDeduction - section80C - section80D - hraExemption; + taxableIncome = Math.max(0, taxableIncome); + + let tax = 0; + + // Tax slabs for old regime + const slabs = [ + { limit: 250000, rate: 0 }, + { limit: 500000, rate: 0.05 }, + { limit: 1000000, rate: 0.20 }, + { limit: Infinity, rate: 0.30 } + ]; + + let previousLimit = 0; + for (const slab of slabs) { + if (taxableIncome > previousLimit) { + const taxableInSlab = Math.min(taxableIncome, slab.limit) - previousLimit; + tax += taxableInSlab * slab.rate; + previousLimit = slab.limit; + } else { + break; + } + } + + // Add cess (4% of tax) + tax += tax * 0.04; + + // Rebate under section 87A (if taxable income <= 5 lakhs) + if (taxableIncome <= 500000) { + tax = Math.max(0, tax - 12500); + } + + return Math.round(tax); + } + + /** + * Calculate Professional Tax (State-specific) + * Using Maharashtra rates as example + */ + calculateProfessionalTax(monthlySalary, state = 'Maharashtra') { + const stateTaxRates = { + 'Maharashtra': [ + { limit: 7500, tax: 0 }, + { limit: 10000, tax: 175 }, + { limit: Infinity, tax: 200 } + ], + 'Karnataka': [ + { limit: 15000, tax: 0 }, + { limit: Infinity, tax: 200 } + ], + 'West Bengal': [ + { limit: 10000, tax: 0 }, + { limit: 15000, tax: 110 }, + { limit: 25000, tax: 130 }, + { limit: 40000, tax: 150 }, + { limit: Infinity, tax: 200 } + ] + }; + + const rates = stateTaxRates[state] || stateTaxRates['Maharashtra']; + + for (const bracket of rates) { + if (monthlySalary <= bracket.limit) { + return bracket.tax; + } + } + + return 0; + } + + /** + * Calculate Provident Fund (PF) + * Employee contribution: 12% of Basic + DA + * Employer contribution: 12% of Basic + DA (3.67% to EPF, 8.33% to EPS) + */ + calculateProvidentFund(basicSalary, daAllowance = 0) { + const pfBase = basicSalary + daAllowance; + const pfWageLimit = 15000; // PF wage ceiling + + const contributoryWage = Math.min(pfBase, pfWageLimit); + const employeeContribution = Math.round(contributoryWage * 0.12); + const employerContribution = Math.round(contributoryWage * 0.12); + + return { + employeeContribution, + employerContribution, + totalContribution: employeeContribution + employerContribution, + contributoryWage + }; + } + + /** + * Calculate Employee State Insurance (ESI) + * Applicable if gross salary <= 21,000 per month + * Employee: 0.75%, Employer: 3.25% + */ + calculateESI(grossSalary) { + const esiLimit = 21000; + + if (grossSalary > esiLimit) { + return { + employeeContribution: 0, + employerContribution: 0, + totalContribution: 0, + isApplicable: false + }; + } + + const employeeContribution = Math.round(grossSalary * 0.0075); + const employerContribution = Math.round(grossSalary * 0.0325); + + return { + employeeContribution, + employerContribution, + totalContribution: employeeContribution + employerContribution, + isApplicable: true + }; + } + + /** + * Calculate HRA exemption (for old tax regime) + */ + calculateHRAExemption(basicSalary, hraReceived, rentPaid, isMetro = false) { + // HRA exemption is minimum of: + // 1. Actual HRA received + // 2. 50% of basic (metro) or 40% of basic (non-metro) + // 3. Rent paid - 10% of basic + + const metroPercentage = isMetro ? 0.50 : 0.40; + + const option1 = hraReceived; + const option2 = basicSalary * metroPercentage; + const option3 = Math.max(0, rentPaid - (basicSalary * 0.10)); + + const exemption = Math.min(option1, option2, option3); + + return Math.max(0, exemption); + } + + /** + * Calculate LTA (Leave Travel Allowance) exemption + */ + calculateLTAExemption(ltaReceived, actualTravelExpense) { + // LTA exemption is minimum of actual LTA received and actual travel expense + // Limited to 2 journeys in a block of 4 years + return Math.min(ltaReceived, actualTravelExpense); + } + + /** + * Calculate total monthly deductions for an employee + */ + calculateMonthlyDeductions(salaryComponents, taxRegime = 'new', state = 'Maharashtra') { + const basic = salaryComponents.basic || 0; + const hra = salaryComponents.hra || 0; + const da = salaryComponents.da || 0; + const grossSalary = salaryComponents.gross || 0; + const annualIncome = grossSalary * 12; + + // Calculate annual tax + const annualTax = this.calculateIncomeTax(annualIncome, taxRegime); + const monthlyTDS = Math.round(annualTax / 12); + + // Professional Tax + const professionalTax = this.calculateProfessionalTax(grossSalary, state); + + // PF + const pf = this.calculateProvidentFund(basic, da); + + // ESI + const esi = this.calculateESI(grossSalary); + + return { + tds: monthlyTDS, + professionalTax, + providentFund: pf.employeeContribution, + esi: esi.employeeContribution, + totalDeductions: monthlyTDS + professionalTax + pf.employeeContribution + esi.employeeContribution, + breakdown: { + annualTax, + pf, + esi + } + }; + } + + /** + * Calculate take-home salary + */ + calculateTakeHome(grossSalary, deductions) { + const totalDeductions = deductions.tds + + deductions.professionalTax + + deductions.providentFund + + deductions.esi; + + return grossSalary - totalDeductions; + } +} + +module.exports = new DeductionEngine(); diff --git a/services/payrollService.js b/services/payrollService.js new file mode 100644 index 00000000..02aa1d89 --- /dev/null +++ b/services/payrollService.js @@ -0,0 +1,331 @@ +const SalaryStructure = require('../models/SalaryStructure'); +const PayrollRun = require('../models/PayrollRun'); +const EmployeePerk = require('../models/EmployeePerk'); +const deductionEngine = require('./deductionEngine'); + +class PayrollService { + /** + * Generate payroll for a specific period + */ + async generatePayroll(userId, month, year) { + const periodStart = new Date(year, month - 1, 1); + const periodEnd = new Date(year, month, 0); + + // Get all active salary structures + const salaryStructures = await SalaryStructure.find({ + userId, + isActive: true, + effectiveFrom: { $lte: periodEnd } + }); + + if (salaryStructures.length === 0) { + throw new Error('No active salary structures found'); + } + + // Generate payroll entries + const entries = []; + + for (const structure of salaryStructures) { + const entry = await this.generatePayrollEntry(structure, month, year); + entries.push(entry); + } + + // Create payroll run + const runId = `PR-${year}${String(month).padStart(2, '0')}-${Date.now()}`; + + const payrollRun = new PayrollRun({ + userId, + runId, + payrollPeriod: { month, year }, + periodStart, + periodEnd, + entries, + status: 'draft' + }); + + await payrollRun.save(); + return payrollRun; + } + + /** + * Generate individual payroll entry + */ + async generatePayrollEntry(salaryStructure, month, year) { + // Extract components + const earnings = salaryStructure.components.filter(c => c.componentType === 'earning'); + const deductions = salaryStructure.components.filter(c => c.componentType === 'deduction'); + const reimbursements = salaryStructure.components.filter(c => c.componentType === 'reimbursement'); + + // Calculate component amounts + const calculatedEarnings = this.calculateComponents(earnings, salaryStructure); + const calculatedDeductions = this.calculateComponents(deductions, salaryStructure); + const calculatedReimbursements = this.calculateComponents(reimbursements, salaryStructure); + + // Calculate gross pay + const grossPay = calculatedEarnings.reduce((sum, e) => sum + e.amount, 0); + + // Get basic and HRA for statutory calculations + const basic = calculatedEarnings.find(e => e.componentName.toLowerCase().includes('basic'))?.amount || grossPay * 0.4; + const hra = calculatedEarnings.find(e => e.componentName.toLowerCase().includes('hra'))?.amount || 0; + const da = calculatedEarnings.find(e => e.componentName.toLowerCase().includes('da'))?.amount || 0; + + // Calculate statutory deductions + const statutory = deductionEngine.calculateMonthlyDeductions({ + basic, + hra, + da, + gross: grossPay + }, salaryStructure.taxRegime); + + // Add perks taxable value + const perks = await EmployeePerk.find({ + userId: salaryStructure.userId, + employeeId: salaryStructure.employeeId, + status: 'active', + effectiveFrom: { $lte: new Date(year, month - 1, 1) } + }); + + const perksTaxableValue = perks + .filter(p => p.isTaxable && p.frequency === 'monthly') + .reduce((sum, p) => sum + p.taxableValue, 0); + + // Combine all deductions + const allDeductions = [ + ...calculatedDeductions, + { componentName: 'TDS', amount: statutory.tds }, + { componentName: 'Professional Tax', amount: statutory.professionalTax }, + { componentName: 'Provident Fund', amount: statutory.providentFund }, + { componentName: 'ESI', amount: statutory.esi } + ]; + + const totalDeductions = allDeductions.reduce((sum, d) => sum + d.amount, 0); + const netPay = grossPay - totalDeductions; + + return { + employeeId: salaryStructure.employeeId, + employeeName: salaryStructure.employeeName, + salaryStructureId: salaryStructure._id, + earnings: calculatedEarnings, + deductions: allDeductions, + reimbursements: calculatedReimbursements, + grossPay, + totalDeductions, + netPay, + taxDeducted: statutory.tds, + professionalTax: statutory.professionalTax, + providentFund: statutory.providentFund, + esi: statutory.esi, + paymentStatus: 'pending' + }; + } + + /** + * Calculate component amounts based on calculation type + */ + calculateComponents(components, salaryStructure) { + return components.map(component => { + let amount = 0; + + switch (component.calculationType) { + case 'fixed': + amount = component.amount; + break; + + case 'percentage': + if (component.baseComponent) { + const baseComp = salaryStructure.components.find( + c => c.componentName === component.baseComponent + ); + amount = baseComp ? (baseComp.amount * component.percentage / 100) : 0; + } else { + // Percentage of CTC + amount = salaryStructure.ctc * component.percentage / 100; + } + break; + + case 'formula': + // For complex formulas, implement custom logic + amount = component.amount; + break; + } + + return { + componentName: component.componentName, + amount: Math.round(amount) + }; + }); + } + + /** + * Approve payroll run + */ + async approvePayroll(payrollRunId, approverId) { + const payrollRun = await PayrollRun.findById(payrollRunId); + + if (!payrollRun) { + throw new Error('Payroll run not found'); + } + + if (payrollRun.status !== 'draft' && payrollRun.status !== 'pending_approval') { + throw new Error('Payroll run cannot be approved in current status'); + } + + payrollRun.status = 'approved'; + payrollRun.approvedBy = approverId; + payrollRun.approvedAt = new Date(); + + await payrollRun.save(); + return payrollRun; + } + + /** + * Process payroll (mark as processing/completed) + */ + async processPayroll(payrollRunId) { + const payrollRun = await PayrollRun.findById(payrollRunId); + + if (!payrollRun) { + throw new Error('Payroll run not found'); + } + + if (payrollRun.status !== 'approved') { + throw new Error('Payroll must be approved before processing'); + } + + payrollRun.status = 'processing'; + await payrollRun.save(); + + // Simulate payment processing + // In real implementation, integrate with payment gateway + + for (const entry of payrollRun.entries) { + entry.paymentStatus = 'processed'; + entry.paymentDate = new Date(); + entry.paymentReference = `PAY-${Date.now()}-${entry.employeeId}`; + } + + payrollRun.status = 'completed'; + payrollRun.processedAt = new Date(); + await payrollRun.save(); + + return payrollRun; + } + + /** + * Get payroll dashboard statistics + */ + async getPayrollDashboard(userId) { + const currentDate = new Date(); + const currentMonth = currentDate.getMonth() + 1; + const currentYear = currentDate.getFullYear(); + + // Get current month payroll + const currentPayroll = await PayrollRun.findOne({ + userId, + 'payrollPeriod.month': currentMonth, + 'payrollPeriod.year': currentYear + }); + + // Get all active employees + const activeEmployees = await SalaryStructure.find({ userId, isActive: true }); + + // Get last 6 months payroll history + const sixMonthsAgo = new Date(); + sixMonthsAgo.setMonth(sixMonthsAgo.getMonth() - 6); + + const payrollHistory = await PayrollRun.find({ + userId, + periodStart: { $gte: sixMonthsAgo } + }).sort({ periodStart: -1 }); + + // Calculate trends + const monthlyTrends = payrollHistory.map(pr => ({ + month: pr.payrollPeriod.month, + year: pr.payrollPeriod.year, + totalNetPay: pr.summary.totalNetPay, + totalTax: pr.summary.totalTax, + employeeCount: pr.summary.totalEmployees + })); + + return { + currentPayroll, + activeEmployeeCount: activeEmployees.length, + monthlyTrends, + pendingApprovals: await PayrollRun.countDocuments({ + userId, + status: { $in: ['draft', 'pending_approval'] } + }) + }; + } + + /** + * Get employee payslip + */ + async getPayslip(payrollRunId, employeeId) { + const payrollRun = await PayrollRun.findById(payrollRunId); + + if (!payrollRun) { + throw new Error('Payroll run not found'); + } + + const entry = payrollRun.entries.find(e => e.employeeId === employeeId); + + if (!entry) { + throw new Error('Employee not found in this payroll run'); + } + + return { + payrollPeriod: payrollRun.payrollPeriod, + employee: { + id: entry.employeeId, + name: entry.employeeName + }, + earnings: entry.earnings, + deductions: entry.deductions, + reimbursements: entry.reimbursements, + grossPay: entry.grossPay, + totalDeductions: entry.totalDeductions, + netPay: entry.netPay, + paymentStatus: entry.paymentStatus, + paymentDate: entry.paymentDate, + paymentReference: entry.paymentReference + }; + } + + /** + * Calculate year-to-date (YTD) statistics for an employee + */ + async getEmployeeYTD(userId, employeeId, year) { + const payrollRuns = await PayrollRun.find({ + userId, + 'payrollPeriod.year': year, + status: 'completed' + }); + + let ytdGross = 0; + let ytdDeductions = 0; + let ytdNet = 0; + let ytdTax = 0; + + for (const run of payrollRuns) { + const entry = run.entries.find(e => e.employeeId === employeeId); + if (entry) { + ytdGross += entry.grossPay; + ytdDeductions += entry.totalDeductions; + ytdNet += entry.netPay; + ytdTax += entry.taxDeducted; + } + } + + return { + year, + ytdGross, + ytdDeductions, + ytdNet, + ytdTax, + monthsProcessed: payrollRuns.length + }; + } +} + +module.exports = new PayrollService();