From a4478a28c42c2442ea923b8661ead3ab85537f36 Mon Sep 17 00:00:00 2001 From: Louisa Best Date: Tue, 28 Apr 2026 11:54:32 +0930 Subject: [PATCH] feat(payroll): clean payroll implementation with generation, export, approve, process --- app-backend/package-lock.json | 196 ++++- app-backend/package.json | 1 + .../src/controllers/payroll.controller.js | 118 ++- app-backend/src/middleware/logger.js | 2 + app-backend/src/models/Payroll.js | 179 ++++ app-backend/src/routes/payroll.routes.js | 179 +++- app-backend/src/services/payroll.service.js | 791 ++++++++++++++---- 7 files changed, 1241 insertions(+), 225 deletions(-) create mode 100644 app-backend/src/models/Payroll.js diff --git a/app-backend/package-lock.json b/app-backend/package-lock.json index 30712b4b2..06dec8db6 100644 --- a/app-backend/package-lock.json +++ b/app-backend/package-lock.json @@ -22,6 +22,7 @@ "morgan": "^1.10.1", "multer": "^2.0.2", "nodemailer": "^7.0.5", + "pdfkit": "^0.18.0", "swagger-jsdoc": "^6.2.8", "swagger-ui-express": "^5.0.1" }, @@ -132,6 +133,7 @@ "integrity": "sha512-UlLAnTPrFdNGoFtbSXwcGFQBtQZJCNjaN6hQNP3UPvuNXT1i82N26KL3dZeIpNalWywr9IuQuncaAfUaS1g6sQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@ampproject/remapping": "^2.2.0", "@babel/code-frame": "^7.27.1", @@ -2591,11 +2593,22 @@ "sparse-bitfield": "^3.0.3" } }, + "node_modules/@noble/ciphers": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-1.3.0.tgz", + "integrity": "sha512-2I0gnIVPtfnMw9ee9h1dJG7tp81+8Ob3OJb3Mv37rx5L40/b0i7djjCVvGOVqc9AEIQyvyu1i6ypKdFw8R8gQw==", + "license": "MIT", + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/@noble/hashes": { "version": "1.8.0", "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz", "integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==", - "dev": true, "license": "MIT", "engines": { "node": "^14.21.3 || >=16" @@ -2655,6 +2668,15 @@ "@sinonjs/commons": "^3.0.0" } }, + "node_modules/@swc/helpers": { + "version": "0.5.21", + "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.21.tgz", + "integrity": "sha512-jI/VAmtdjB/RnI8GTnokyX7Ug8c+g+ffD6QRLa6XQewtnGyukKkKSk3wLTM3b5cjt1jNh9x0jfVlagdN2gDKQg==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.8.0" + } + }, "node_modules/@types/babel__core": { "version": "7.20.5", "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", @@ -2832,6 +2854,7 @@ "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", "dev": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -3517,6 +3540,26 @@ "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", "license": "MIT" }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, "node_modules/basic-auth": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/basic-auth/-/basic-auth-2.0.1.tgz", @@ -3613,6 +3656,24 @@ "node": ">=8" } }, + "node_modules/brotli": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/brotli/-/brotli-1.3.3.tgz", + "integrity": "sha512-oTKjJdShmDuGW94SyyaoQvAjf30dZaHnjJ8uAF+u2/vGJkJbJPJAT1gDiOJP5v1Zb6f9KEyW/1HpuaWIXtGHPg==", + "license": "MIT", + "dependencies": { + "base64-js": "^1.1.2" + } + }, + "node_modules/browserify-zlib": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/browserify-zlib/-/browserify-zlib-0.2.0.tgz", + "integrity": "sha512-Z942RysHXmJrhqk88FmKBVq/v5tqmSkDz7p54G/MGyjMnCFFnC79XWNbg+Vta8W6Wb2qtSZTSxIGkJrRpCFEiA==", + "license": "MIT", + "dependencies": { + "pako": "~1.0.5" + } + }, "node_modules/browserslist": { "version": "4.25.1", "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.25.1.tgz", @@ -3633,6 +3694,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "caniuse-lite": "^1.0.30001726", "electron-to-chromium": "^1.5.173", @@ -3895,6 +3957,15 @@ "node": ">=12" } }, + "node_modules/clone": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/clone/-/clone-2.1.2.tgz", + "integrity": "sha512-3Pe/CF1Nn94hyhIYpjtiLhdCoEoz0DqQ+988E9gmeEdQZlojxnOb74wctFyuwWQHzqyf9X7C7MG8juUpqBJT8w==", + "license": "MIT", + "engines": { + "node": ">=0.8" + } + }, "node_modules/co": { "version": "4.6.0", "resolved": "https://registry.npmjs.org/co/-/co-4.6.0.tgz", @@ -4282,6 +4353,12 @@ "wrappy": "1" } }, + "node_modules/dfa": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/dfa/-/dfa-1.2.0.tgz", + "integrity": "sha512-ED3jP8saaweFTjeGX8HQPjeC1YYyZs98jGNZx6IiBvxW7JG5v492kamAQB3m2wop07CvU/RQmzcKr6bgcC5D/Q==", + "license": "MIT" + }, "node_modules/diff-sequences": { "version": "29.6.3", "resolved": "https://registry.npmjs.org/diff-sequences/-/diff-sequences-29.6.3.tgz", @@ -4572,6 +4649,7 @@ "integrity": "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -4927,6 +5005,7 @@ "resolved": "https://registry.npmjs.org/express/-/express-5.1.0.tgz", "integrity": "sha512-DT9ck5YIRU+8GYzzU5kT3eHGA5iL+1Zd0EutOmTE9Dtk+Tvuzd23VBU+ec7HPNSTxXYO55gPV/hq4pSBJDjFpA==", "license": "MIT", + "peer": true, "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.0", @@ -4988,7 +5067,6 @@ "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", - "dev": true, "license": "MIT" }, "node_modules/fast-json-stable-stringify": { @@ -5103,6 +5181,23 @@ "dev": true, "license": "ISC" }, + "node_modules/fontkit": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/fontkit/-/fontkit-2.0.4.tgz", + "integrity": "sha512-syetQadaUEDNdxdugga9CpEYVaQIxOwk7GlwZWWZ19//qW4zE5bknOKeMBDYAASwnpaSHKJITRLMF9m1fp3s6g==", + "license": "MIT", + "dependencies": { + "@swc/helpers": "^0.5.12", + "brotli": "^1.3.2", + "clone": "^2.1.2", + "dfa": "^1.2.0", + "fast-deep-equal": "^3.1.3", + "restructure": "^3.0.0", + "tiny-inflate": "^1.0.3", + "unicode-properties": "^1.4.0", + "unicode-trie": "^2.0.0" + } + }, "node_modules/for-each": { "version": "0.3.5", "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz", @@ -6878,6 +6973,12 @@ "url": "https://github.com/chalk/supports-color?sponsor=1" } }, + "node_modules/js-md5": { + "version": "0.8.3", + "resolved": "https://registry.npmjs.org/js-md5/-/js-md5-0.8.3.tgz", + "integrity": "sha512-qR0HB5uP6wCuRMrWPTrkMaev7MJZwJuuw4fnwAzRgP4J4/F8RwtodOKpGp4XpqsLBFzzgqIO42efFAyz2Et6KQ==", + "license": "MIT" + }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -7047,6 +7148,25 @@ "node": ">= 0.8.0" } }, + "node_modules/linebreak": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/linebreak/-/linebreak-1.1.0.tgz", + "integrity": "sha512-MHp03UImeVhB7XZtjd0E4n6+3xr5Dq/9xI/5FptGk5FrbDR3zagPa2DS6U8ks/3HjbKWG9Q1M2ufOzxV2qLYSQ==", + "license": "MIT", + "dependencies": { + "base64-js": "0.0.8", + "unicode-trie": "^2.0.0" + } + }, + "node_modules/linebreak/node_modules/base64-js": { + "version": "0.0.8", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-0.0.8.tgz", + "integrity": "sha512-3XSA2cR/h/73EzlXXdU6YNycmYI7+kicTxks4eJg2g39biHR84slg2+des+p7iHYhbRg/udIS4TD53WabcOUkw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, "node_modules/lines-and-columns": { "version": "1.2.4", "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", @@ -7910,6 +8030,12 @@ "node": ">=6" } }, + "node_modules/pako": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz", + "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==", + "license": "(MIT AND Zlib)" + }, "node_modules/parent-module": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", @@ -7996,6 +8122,20 @@ "node": ">=16" } }, + "node_modules/pdfkit": { + "version": "0.18.0", + "resolved": "https://registry.npmjs.org/pdfkit/-/pdfkit-0.18.0.tgz", + "integrity": "sha512-NvUwSDZ0eYEzqAiWwVQkRkjYUkZ48kcsHuCO31ykqPPIVkwoSDjDGiwIgHHNtsiwls3z3P/zy4q00hl2chg2Ug==", + "license": "MIT", + "dependencies": { + "@noble/ciphers": "^1.0.0", + "@noble/hashes": "^1.6.0", + "fontkit": "^2.0.4", + "js-md5": "^0.8.3", + "linebreak": "^1.1.0", + "png-js": "^1.0.0" + } + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -8095,6 +8235,14 @@ "node": ">=8" } }, + "node_modules/png-js": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/png-js/-/png-js-1.1.0.tgz", + "integrity": "sha512-PM/uYGzGdNSzqeOgly68+6wKQDL1SY0a/N+OEa/+br6LnHWOAJB0Npiamnodfq3jd2LS/i2fMeOKSAILjA+m5Q==", + "dependencies": { + "browserify-zlib": "^0.2.0" + } + }, "node_modules/possible-typed-array-names": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", @@ -8481,6 +8629,12 @@ "node": ">=10" } }, + "node_modules/restructure": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/restructure/-/restructure-3.0.2.tgz", + "integrity": "sha512-gSfoiOEA0VPE6Tukkrr7I0RBdE0s7H1eFCDBk05l1KIQT1UIKNc5JZy6jdyW6eYH3aR3g5b3PuL77rq0hvwtAw==", + "license": "MIT" + }, "node_modules/router": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", @@ -9210,6 +9364,12 @@ "node": ">=8" } }, + "node_modules/tiny-inflate": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/tiny-inflate/-/tiny-inflate-1.0.3.tgz", + "integrity": "sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw==", + "license": "MIT" + }, "node_modules/tmpl": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/tmpl/-/tmpl-1.0.5.tgz", @@ -9297,6 +9457,12 @@ "node": ">=4" } }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, "node_modules/type-check": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", @@ -9485,6 +9651,16 @@ "node": ">=4" } }, + "node_modules/unicode-properties": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/unicode-properties/-/unicode-properties-1.4.1.tgz", + "integrity": "sha512-CLjCCLQ6UuMxWnbIylkisbRj31qxHPAurvena/0iwSVbQ2G1VY5/HjV0IRabOEbDHlzZlRdCrD4NhB0JtU40Pg==", + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.0", + "unicode-trie": "^2.0.0" + } + }, "node_modules/unicode-property-aliases-ecmascript": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/unicode-property-aliases-ecmascript/-/unicode-property-aliases-ecmascript-2.1.0.tgz", @@ -9495,6 +9671,22 @@ "node": ">=4" } }, + "node_modules/unicode-trie": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/unicode-trie/-/unicode-trie-2.0.0.tgz", + "integrity": "sha512-x7bc76x0bm4prf1VLg79uhAzKw8DVboClSN5VxJuQ+LKDOVEW9CdH+VY7SP+vX7xCYQqzzgQpFqz15zeLvAtZQ==", + "license": "MIT", + "dependencies": { + "pako": "^0.2.5", + "tiny-inflate": "^1.0.0" + } + }, + "node_modules/unicode-trie/node_modules/pako": { + "version": "0.2.9", + "resolved": "https://registry.npmjs.org/pako/-/pako-0.2.9.tgz", + "integrity": "sha512-NUcwaKxUxWrZLpDG+z/xZaCgQITkA/Dv4V/T6bw7VON6l1Xz/VnrBqrYjZQ12TamKHzITTfOEIYUj48y2KXImA==", + "license": "MIT" + }, "node_modules/unpipe": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", diff --git a/app-backend/package.json b/app-backend/package.json index 83942b1f8..1799760a4 100644 --- a/app-backend/package.json +++ b/app-backend/package.json @@ -49,6 +49,7 @@ "morgan": "^1.10.1", "multer": "^2.0.2", "nodemailer": "^7.0.5", + "pdfkit": "^0.18.0", "swagger-jsdoc": "^6.2.8", "swagger-ui-express": "^5.0.1" }, diff --git a/app-backend/src/controllers/payroll.controller.js b/app-backend/src/controllers/payroll.controller.js index 1fa524a64..1cfbf8d75 100644 --- a/app-backend/src/controllers/payroll.controller.js +++ b/app-backend/src/controllers/payroll.controller.js @@ -1,41 +1,103 @@ -import { buildPayrollSummary } from "../services/payroll.service.js"; +import { ACTIONS } from '../middleware/logger.js'; +import { + approvePayrollRecords, + exportPayrollCsv, + exportPayrollPdf, + getPayrollRecords, + processPayrollRecords, +} from '../services/payroll.service.js'; -export const getPayrollSummary = async (req, res) => { +const sendError = (res, error, fallbackMessage) => { + return res.status(error.statusCode || 500).json({ + message: error.message || fallbackMessage, + }); +}; + +export const getPayroll = async (req, res) => { try { - const result = await buildPayrollSummary(req.query, req.user); + const result = await getPayrollRecords(req.query, req.user); return res.status(200).json(result); } catch (error) { - if ( - error.message.includes("required") || - error.message.includes("periodType") || - error.message.includes("ISO") || - error.message.includes("Invalid startDate") || - error.message.includes("after") - ) { - return res.status(400).json({ - message: error.message, - }); - } + return sendError(res, error, 'Failed to retrieve payroll'); + } +}; - if ( - error.message.includes("Forbidden") || - error.message.includes("only access their own") || - error.message.includes("unsupported role") - ) { - return res.status(403).json({ - message: error.message, +export const approvePayroll = async (req, res) => { + try { + const payroll = await approvePayrollRecords(req.body.payrollIds, req.user); + + if (req.audit?.log) { + await req.audit.log(req.user._id || req.user.id, ACTIONS.PAYROLL_APPROVED, { + payrollIds: req.body.payrollIds, }); } - if (error.message.includes("Unauthorised")) { - return res.status(401).json({ - message: error.message, + return res.status(200).json({ + message: 'Payroll approved successfully', + payroll, + }); + } catch (error) { + return sendError(res, error, 'Failed to approve payroll'); + } +}; + +export const processPayroll = async (req, res) => { + try { + const payroll = await processPayrollRecords(req.body.payrollIds, req.user); + + if (req.audit?.log) { + await req.audit.log(req.user._id || req.user.id, ACTIONS.PAYROLL_PROCESSED, { + payrollIds: req.body.payrollIds, }); } - return res.status(500).json({ - message: "Failed to retrieve payroll summary", - error: error.message, + return res.status(200).json({ + message: 'Payroll processed successfully', + payroll, }); + } catch (error) { + return sendError(res, error, 'Failed to process payroll'); + } +}; + +export const downloadPayrollCsv = async (req, res) => { + try { + const csv = await exportPayrollCsv(req.query, req.user); + + res.setHeader('Content-Type', 'text/csv; charset=utf-8'); + res.setHeader('Content-Disposition', 'attachment; filename="payroll.csv"'); + + return res.status(200).send(csv); + } catch (error) { + return sendError(res, error, 'Failed to export payroll CSV'); + } +}; + +export const downloadPayrollExport = async (req, res) => { + const format = String(req.query.format || '').toLowerCase(); + + if (format === 'csv') { + return downloadPayrollCsv(req, res); + } + + if (format === 'pdf') { + return downloadPayrollPdf(req, res); + } + + return res.status(400).json({ + message: 'format query parameter must be csv or pdf', + }); +}; + +export const downloadPayrollPdf = async (req, res) => { + try { + const pdfBuffer = await exportPayrollPdf(req.query, req.user); + + res.setHeader('Content-Type', 'application/pdf'); + res.setHeader('Content-Disposition', 'attachment; filename="payroll.pdf"'); + + return res.status(200).send(pdfBuffer); + } catch (error) { + return sendError(res, error, 'Failed to export payroll PDF'); } -}; \ No newline at end of file +}; diff --git a/app-backend/src/middleware/logger.js b/app-backend/src/middleware/logger.js index 90d3caf5d..c3aec1a01 100644 --- a/app-backend/src/middleware/logger.js +++ b/app-backend/src/middleware/logger.js @@ -33,6 +33,8 @@ export const ACTIONS = { INCIDENT_CREATED: "INCIDENT_CREATED", INCIDENT_UPDATED: "INCIDENT_UPDATED", INCIDENT_DELETED: "INCIDENT_DELETED", + PAYROLL_APPROVED: 'PAYROLL_APPROVED', + PAYROLL_PROCESSED: 'PAYROLL_PROCESSED', }; // Middleware to attach audit logging function to req diff --git a/app-backend/src/models/Payroll.js b/app-backend/src/models/Payroll.js new file mode 100644 index 000000000..6ad7e4529 --- /dev/null +++ b/app-backend/src/models/Payroll.js @@ -0,0 +1,179 @@ +import mongoose from 'mongoose'; + +const payrollEntrySchema = new mongoose.Schema( + { + shiftId: { + type: mongoose.Schema.Types.ObjectId, + ref: 'Shift', + required: true, + }, + shiftDate: { + type: Date, + required: true, + }, + department: { + type: String, + trim: true, + default: null, + }, + hourlyRate: { + type: Number, + default: 0, + min: 0, + }, + scheduledHours: { + type: Number, + default: 0, + min: 0, + }, + actualHours: { + type: Number, + default: 0, + min: 0, + }, + payableHours: { + type: Number, + default: 0, + min: 0, + }, + ordinaryHours: { + type: Number, + default: 0, + min: 0, + }, + overtimeHours: { + type: Number, + default: 0, + min: 0, + }, + ordinaryAmount: { + type: Number, + default: 0, + min: 0, + }, + overtimeAmount: { + type: Number, + default: 0, + min: 0, + }, + totalAmount: { + type: Number, + default: 0, + min: 0, + }, + attendanceBased: { + type: Boolean, + default: false, + }, + }, + { _id: false } +); + +const payrollSchema = new mongoose.Schema( + { + guardId: { + type: mongoose.Schema.Types.ObjectId, + ref: 'User', + required: true, + }, + employerId: { + type: mongoose.Schema.Types.ObjectId, + ref: 'User', + required: true, + }, + periodType: { + type: String, + enum: ['daily', 'weekly', 'monthly'], + required: true, + }, + periodStart: { + type: Date, + required: true, + }, + periodEnd: { + type: Date, + required: true, + }, + totalScheduledHours: { + type: Number, + default: 0, + min: 0, + }, + totalActualHours: { + type: Number, + default: 0, + min: 0, + }, + totalPayableHours: { + type: Number, + default: 0, + min: 0, + }, + totalOrdinaryHours: { + type: Number, + default: 0, + min: 0, + }, + totalOvertimeHours: { + type: Number, + default: 0, + min: 0, + }, + totalOrdinaryAmount: { + type: Number, + default: 0, + min: 0, + }, + totalOvertimeAmount: { + type: Number, + default: 0, + min: 0, + }, + totalAmount: { + type: Number, + default: 0, + min: 0, + }, + status: { + type: String, + enum: ['PENDING', 'APPROVED', 'PROCESSED'], + default: 'PENDING', + index: true, + }, + entries: { + type: [payrollEntrySchema], + default: [], + }, + approvedAt: { + type: Date, + default: null, + }, + approvedBy: { + type: mongoose.Schema.Types.ObjectId, + ref: 'User', + default: null, + }, + processedAt: { + type: Date, + default: null, + }, + processedBy: { + type: mongoose.Schema.Types.ObjectId, + ref: 'User', + default: null, + }, + }, + { timestamps: true } +); + +payrollSchema.index( + { guardId: 1, employerId: 1, periodType: 1, periodStart: 1, periodEnd: 1 }, + { unique: true } +); + +payrollSchema.index({ employerId: 1, periodStart: 1, periodEnd: 1 }); +payrollSchema.index({ guardId: 1, periodStart: 1, periodEnd: 1 }); + +const Payroll = mongoose.model('Payroll', payrollSchema); + +export default Payroll; diff --git a/app-backend/src/routes/payroll.routes.js b/app-backend/src/routes/payroll.routes.js index b04d33a34..c634fcdf5 100644 --- a/app-backend/src/routes/payroll.routes.js +++ b/app-backend/src/routes/payroll.routes.js @@ -1,28 +1,39 @@ -import express from "express"; -import auth from "../middleware/auth.js"; -import { getPayrollSummary } from "../controllers/payroll.controller.js"; +import express from 'express'; +import auth from '../middleware/auth.js'; +import { + approvePayroll, + downloadPayrollExport, + downloadPayrollCsv, + downloadPayrollPdf, + getPayroll, + processPayroll, +} from '../controllers/payroll.controller.js'; const router = express.Router(); const authorizeRole = (...allowedRoles) => (req, res, next) => { - if (!req.user || !allowedRoles.includes(req.user.role)) { - return res.status(403).json({ - message: "Forbidden: insufficient permissions", - }); + if (!req.user) { + return res.status(401).json({ message: 'Unauthorized' }); } + + if (!allowedRoles.includes(req.user.role)) { + return res.status(403).json({ message: 'Forbidden: insufficient permissions' }); + } + next(); }; +/** + * @swagger + * tags: + * name: Payroll + * description: Payroll generation, approval, processing, and export + */ /** * @swagger * /api/v1/payroll: * get: - * summary: Retrieve payroll summary for guards and employees - * description: | - * Role access: - * - Admin: can fetch payroll summaries for all guards, optionally filtered - * - Employer: can fetch payroll summaries only for completed shifts they created - * - Guard: can fetch only their own payroll summary + * summary: Generate or retrieve payroll documents for a date range * tags: [Payroll] * security: * - bearerAuth: [] @@ -33,51 +44,165 @@ const authorizeRole = (...allowedRoles) => (req, res, next) => { * schema: * type: string * format: date - * description: Start date in ISO format YYYY-MM-DD + * description: Start date in YYYY-MM-DD format * - in: query * name: endDate * required: true * schema: * type: string * format: date - * description: End date in ISO format YYYY-MM-DD + * description: End date in YYYY-MM-DD format * - in: query * name: periodType * required: true * schema: * type: string * enum: [daily, weekly, monthly] - * description: Aggregation type for payroll summaries * - in: query * name: guardId * required: false * schema: * type: string - * description: Optional filter for a specific guard + * description: Optional guard filter + * - in: query + * name: department + * required: false + * schema: + * type: string + * description: Optional department filter mapped from Shift.field + * responses: + * 200: + * description: Payroll documents returned successfully + * 400: + * description: Invalid query parameters + * 401: + * description: Unauthorized + * 403: + * description: Forbidden + */ +router.get('/', auth, authorizeRole('admin', 'employer', 'guard'), getPayroll); +/** + * @swagger + * /api/v1/payroll/export: + * get: + * summary: Export payroll documents as CSV or PDF + * tags: [Payroll] + * security: + * - bearerAuth: [] + * parameters: + * - in: query + * name: startDate + * required: true + * schema: + * type: string + * format: date + * - in: query + * name: endDate + * required: true + * schema: + * type: string + * format: date + * - in: query + * name: periodType + * required: true + * schema: + * type: string + * enum: [daily, weekly, monthly] + * - in: query + * name: format + * required: true + * schema: + * type: string + * enum: [csv, pdf] + * description: Export format * - in: query - * name: site + * name: guardId * required: false * schema: * type: string - * description: Optional filter for a specific site * - in: query * name: department * required: false * schema: * type: string - * description: Optional filter for a specific department * responses: * 200: - * description: Payroll summary retrieved successfully + * description: Export generated successfully + * 400: + * description: Invalid query parameters + * 401: + * description: Unauthorized + * 403: + * description: Forbidden + */ +router.get('/export', auth, authorizeRole('admin', 'employer', 'guard'), downloadPayrollExport); +router.get('/export/csv', auth, authorizeRole('admin', 'employer', 'guard'), downloadPayrollCsv); +router.get('/export/pdf', auth, authorizeRole('admin', 'employer', 'guard'), downloadPayrollPdf); +/** + * @swagger + * /api/v1/payroll/approve: + * post: + * summary: Approve payroll documents in bulk + * tags: [Payroll] + * security: + * - bearerAuth: [] + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * required: [payrollIds] + * properties: + * payrollIds: + * type: array + * items: + * type: string + * responses: + * 200: + * description: Payroll approved successfully + * 400: + * description: Invalid request payload + * 401: + * description: Unauthorized + * 403: + * description: Forbidden + * 409: + * description: Invalid payroll state transition + */ +router.post('/approve', auth, authorizeRole('admin', 'employer'), approvePayroll); +/** + * @swagger + * /api/v1/payroll/process: + * post: + * summary: Process payroll documents in bulk + * tags: [Payroll] + * security: + * - bearerAuth: [] + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * required: [payrollIds] + * properties: + * payrollIds: + * type: array + * items: + * type: string + * responses: + * 200: + * description: Payroll processed successfully * 400: - * description: Invalid request parameters + * description: Invalid request payload * 401: - * description: Unauthorised + * description: Unauthorized * 403: * description: Forbidden - * 500: - * description: Server error + * 409: + * description: Invalid payroll state transition */ -router.get("/", auth, authorizeRole("admin", "employer", "guard"), getPayrollSummary); +router.post('/process', auth, authorizeRole('admin', 'employer'), processPayroll); -export default router; \ No newline at end of file +export default router; diff --git a/app-backend/src/services/payroll.service.js b/app-backend/src/services/payroll.service.js index 55e638ef7..b6c8b7516 100644 --- a/app-backend/src/services/payroll.service.js +++ b/app-backend/src/services/payroll.service.js @@ -1,19 +1,59 @@ -import Shift from "../models/Shift.js"; -import ShiftAttendance from "../models/ShiftAttendance.js"; +import PDFDocument from 'pdfkit'; +import Payroll from '../models/Payroll.js'; +import Shift from '../models/Shift.js'; +import ShiftAttendance from '../models/ShiftAttendance.js'; const ISO_DATE_ONLY_REGEX = /^\d{4}-\d{2}-\d{2}$/; +const ALLOWED_PERIOD_TYPES = new Set(['daily', 'weekly', 'monthly']); + +const roundHours = (value) => Math.round((Math.max(0, value) + Number.EPSILON) * 100) / 100; +const roundMoney = (value) => Math.round((Math.max(0, value) + Number.EPSILON) * 100) / 100; + +const createHttpError = (statusCode, message) => { + const error = new Error(message); + error.statusCode = statusCode; + return error; +}; const isValidISODateOnly = (value) => { - if (!ISO_DATE_ONLY_REGEX.test(value)) { - return false; - } + if (!ISO_DATE_ONLY_REGEX.test(value)) return false; const date = new Date(`${value}T00:00:00.000Z`); - if (Number.isNaN(date.getTime())) { - return false; + return !Number.isNaN(date.getTime()) && date.toISOString().slice(0, 10) === value; +}; + +const parseDateRange = ({ startDate, endDate, periodType }) => { + if (!startDate || !endDate || !periodType) { + throw createHttpError(400, 'startDate, endDate, and periodType are required'); + } + + if (!isValidISODateOnly(startDate) || !isValidISODateOnly(endDate)) { + throw createHttpError(400, 'startDate and endDate must be valid ISO dates in YYYY-MM-DD format'); } - return date.toISOString().slice(0, 10) === value; + if (!ALLOWED_PERIOD_TYPES.has(periodType)) { + throw createHttpError(400, 'periodType must be one of daily, weekly, or monthly'); + } + + const start = new Date(`${startDate}T00:00:00.000Z`); + const end = new Date(`${endDate}T23:59:59.999Z`); + + if (start > end) { + throw createHttpError(400, 'startDate cannot be after endDate'); + } + + return { start, end }; +}; + +const getUserContext = (user) => { + const userId = user?._id || user?.id; + const role = user?.role; + + if (!userId || !role) { + throw createHttpError(401, 'Unauthorised user context'); + } + + return { userId, role }; }; const getWeekStart = (dateValue) => { @@ -23,36 +63,61 @@ const getWeekStart = (dateValue) => { date.setUTCDate(diff); date.setUTCHours(0, 0, 0, 0); - return date; }; -const formatPeriodLabel = (dateValue, periodType) => { +const getPeriodBoundsForDate = (dateValue, periodType) => { const date = new Date(dateValue); + date.setUTCHours(0, 0, 0, 0); - if (periodType === "daily") { - return date.toISOString().split("T")[0]; + if (periodType === 'daily') { + const periodStart = new Date(date); + const periodEnd = new Date(date); + periodEnd.setUTCHours(23, 59, 59, 999); + return { periodStart, periodEnd, label: periodStart.toISOString().slice(0, 10) }; } - if (periodType === "weekly") { - const weekStart = getWeekStart(date); - return `week-of-${weekStart.toISOString().split("T")[0]}`; + if (periodType === 'weekly') { + const periodStart = getWeekStart(date); + const periodEnd = new Date(periodStart); + periodEnd.setUTCDate(periodEnd.getUTCDate() + 6); + periodEnd.setUTCHours(23, 59, 59, 999); + return { + periodStart, + periodEnd, + label: `week-of-${periodStart.toISOString().slice(0, 10)}`, + }; } - if (periodType === "monthly") { - return `${date.getUTCFullYear()}-${String(date.getUTCMonth() + 1).padStart(2, "0")}`; + const periodStart = new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), 1)); + const periodEnd = new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth() + 1, 0, 23, 59, 59, 999)); + + return { + periodStart, + periodEnd, + label: `${periodStart.getUTCFullYear()}-${String(periodStart.getUTCMonth() + 1).padStart(2, '0')}`, + }; +}; + +const getShiftStartDateTime = (shift) => { + const [hour, minute] = String(shift.startTime || '').split(':').map(Number); + const start = new Date(shift.date); + + if (Number.isNaN(hour) || Number.isNaN(minute)) { + return start; } - return "unknown"; + start.setHours(hour, minute, 0, 0); + return start; }; const calculateScheduledHours = (shift) => { - if (!shift.startTime || !shift.endTime || !shift.date) { + if (!shift?.date || !shift?.startTime || !shift?.endTime) { return 0; } - const [startHour, startMinute] = String(shift.startTime).split(":").map(Number); - const [endHour, endMinute] = String(shift.endTime).split(":").map(Number); + const [startHour, startMinute] = String(shift.startTime).split(':').map(Number); + const [endHour, endMinute] = String(shift.endTime).split(':').map(Number); if ( Number.isNaN(startHour) || @@ -73,207 +138,597 @@ const calculateScheduledHours = (shift) => { scheduledEnd.setDate(scheduledEnd.getDate() + 1); } - return (scheduledEnd - scheduledStart) / (1000 * 60 * 60); -}; - -export const buildPayrollSummary = async (query, user) => { - const { startDate, endDate, periodType, guardId, site, department } = query; + const breakMinutes = Number.isFinite(shift.breakTime) ? shift.breakTime : 0; + const hours = (scheduledEnd - scheduledStart) / (1000 * 60 * 60) - breakMinutes / 60; - if (!user?._id || !user?.role) { - throw new Error("Unauthorised user context"); - } + return roundHours(hours); +}; - if (!startDate || !endDate || !periodType) { - throw new Error("startDate, endDate, and periodType are required"); +const calculateAttendanceHours = (attendance) => { + if (!attendance?.checkInTime || !attendance?.checkOutTime) { + return null; } - if (!isValidISODateOnly(startDate) || !isValidISODateOnly(endDate)) { - throw new Error("startDate and endDate must be valid ISO dates in YYYY-MM-DD format"); - } + const hours = + (new Date(attendance.checkOutTime) - new Date(attendance.checkInTime)) / (1000 * 60 * 60); - const allowedPeriods = ["daily", "weekly", "monthly"]; - if (!allowedPeriods.includes(periodType)) { - throw new Error("periodType must be daily, weekly, or monthly"); + if (!Number.isFinite(hours) || hours <= 0) { + return null; } - const start = new Date(`${startDate}T00:00:00.000Z`); - const end = new Date(`${endDate}T23:59:59.999Z`); + return roundHours(hours); +}; - if (start > end) { - throw new Error("startDate cannot be after endDate"); - } +const buildShiftQuery = (query, userContext, range) => { + const { guardId, department } = query; + const { userId, role } = userContext; const shiftQuery = { - status: "completed", + status: 'completed', + acceptedBy: { $ne: null }, date: { - $gte: start, - $lte: end, + $gte: range.start, + $lte: range.end, }, }; - // Role-based access rules - if (user.role === "admin") { - if (guardId) { - shiftQuery.acceptedBy = guardId; + if (role === 'guard') { + if (guardId && String(guardId) !== String(userId)) { + throw createHttpError(403, 'Guards can only access their own payroll'); } - } else if (user.role === "employer") { - // current scoping uses shift ownership - shiftQuery.createdBy = user._id; + shiftQuery.acceptedBy = userId; + } else if (role === 'employer') { + shiftQuery.createdBy = userId; + if (guardId) shiftQuery.acceptedBy = guardId; + } else if (role === 'admin') { + if (guardId) shiftQuery.acceptedBy = guardId; + } else { + throw createHttpError(403, 'Forbidden: unsupported role'); + } - if (guardId) { - shiftQuery.acceptedBy = guardId; + if (department) { + shiftQuery.field = department; + } + + return shiftQuery; +}; + +const escapeCsv = (value) => { + const stringValue = value == null ? '' : String(value); + return `"${stringValue.replace(/"/g, '""')}"`; +}; + +const ensurePayrollIds = (payrollIds) => { + if (!Array.isArray(payrollIds) || payrollIds.length === 0 || payrollIds.some((id) => !id)) { + throw createHttpError(400, 'payrollIds must be a non-empty array'); + } + + return payrollIds; +}; + +const serializePayroll = (payrollDoc) => ({ + id: String(payrollDoc._id), + guard: payrollDoc.guardId + ? { + id: String(payrollDoc.guardId._id || payrollDoc.guardId), + name: payrollDoc.guardId.name || null, + } + : null, + employer: payrollDoc.employerId + ? { + id: String(payrollDoc.employerId._id || payrollDoc.employerId), + name: payrollDoc.employerId.name || null, + } + : null, + periodType: payrollDoc.periodType, + periodStart: payrollDoc.periodStart, + periodEnd: payrollDoc.periodEnd, + totalScheduledHours: payrollDoc.totalScheduledHours, + totalActualHours: payrollDoc.totalActualHours, + totalPayableHours: payrollDoc.totalPayableHours, + totalOrdinaryHours: payrollDoc.totalOrdinaryHours, + totalOvertimeHours: payrollDoc.totalOvertimeHours, + totalOrdinaryAmount: payrollDoc.totalOrdinaryAmount, + totalOvertimeAmount: payrollDoc.totalOvertimeAmount, + totalAmount: payrollDoc.totalAmount, + status: payrollDoc.status, + approvedAt: payrollDoc.approvedAt, + processedAt: payrollDoc.processedAt, + entries: payrollDoc.entries.map((entry) => ({ + shiftId: String(entry.shiftId), + shiftDate: entry.shiftDate, + department: entry.department || null, + hourlyRate: entry.hourlyRate, + scheduledHours: entry.scheduledHours, + actualHours: entry.actualHours, + payableHours: entry.payableHours, + ordinaryHours: entry.ordinaryHours, + overtimeHours: entry.overtimeHours, + ordinaryAmount: entry.ordinaryAmount, + overtimeAmount: entry.overtimeAmount, + totalAmount: entry.totalAmount, + attendanceBased: entry.attendanceBased, + })), +}); + +const applyDailyOvertime = (records) => { + const groups = new Map(); + + for (const record of records) { + const key = `${record.guardId}:${new Date(record.shiftDate).toISOString().slice(0, 10)}`; + if (!groups.has(key)) groups.set(key, []); + groups.get(key).push(record); + } + + for (const group of groups.values()) { + group.sort((a, b) => a.shiftStartAt - b.shiftStartAt); + const totalHours = group.reduce((sum, item) => sum + item.payableHours, 0); + let remainingOvertime = Math.max(0, totalHours - 8); + + for (let index = group.length - 1; index >= 0 && remainingOvertime > 0; index -= 1) { + const item = group[index]; + const allocated = Math.min(remainingOvertime, item.payableHours); + item.dailyOvertimeHours = roundHours(allocated); + item.ordinaryAfterDailyHours = roundHours(item.payableHours - item.dailyOvertimeHours); + remainingOvertime = roundHours(remainingOvertime - allocated); } - } else if (user.role === "guard") { - shiftQuery.acceptedBy = user._id; - if (guardId && String(guardId) !== String(user._id)) { - throw new Error("Guards can only access their own payroll summary"); + for (const item of group) { + if (item.dailyOvertimeHours == null) item.dailyOvertimeHours = 0; + if (item.ordinaryAfterDailyHours == null) item.ordinaryAfterDailyHours = roundHours(item.payableHours); } - } else { - throw new Error("Forbidden: unsupported role"); } +}; - if (site) { - shiftQuery.location = site; - } +const applyWeeklyOvertime = (records) => { + const groups = new Map(); - // depends on current Shift model support - if (department) { - shiftQuery.field = department; + for (const record of records) { + const weekStart = getWeekStart(record.shiftDate).toISOString().slice(0, 10); + const key = `${record.guardId}:${weekStart}`; + if (!groups.has(key)) groups.set(key, []); + groups.get(key).push(record); } - const shifts = await Shift.find(shiftQuery); - const shiftIds = shifts.map((shift) => shift._id); + for (const group of groups.values()) { + group.sort((a, b) => a.shiftStartAt - b.shiftStartAt); + const ordinaryPool = group.reduce((sum, item) => sum + item.ordinaryAfterDailyHours, 0); + let remainingWeeklyOvertime = Math.max(0, ordinaryPool - 38); - const attendanceRecords = await ShiftAttendance.find({ - shiftId: { $in: shiftIds }, - }); + for (let index = group.length - 1; index >= 0 && remainingWeeklyOvertime > 0; index -= 1) { + const item = group[index]; + const allocated = Math.min(remainingWeeklyOvertime, item.ordinaryAfterDailyHours); + item.weeklyOvertimeHours = roundHours(allocated); + remainingWeeklyOvertime = roundHours(remainingWeeklyOvertime - allocated); + } - const attendanceMap = new Map(); - for (const record of attendanceRecords) { - attendanceMap.set(String(record.shiftId), record); + for (const item of group) { + if (item.weeklyOvertimeHours == null) item.weeklyOvertimeHours = 0; + } } +}; - const payrollDetails = shifts.map((shift) => { - const attendance = attendanceMap.get(String(shift._id)); +const computeDerivedAmounts = (records) => { + for (const record of records) { + record.overtimeHours = roundHours(record.dailyOvertimeHours + record.weeklyOvertimeHours); + record.ordinaryHours = roundHours(record.payableHours - record.overtimeHours); + record.ordinaryAmount = roundMoney(record.ordinaryHours * record.hourlyRate); + record.overtimeAmount = roundMoney(record.overtimeHours * record.hourlyRate * 1.5); + record.totalAmount = roundMoney(record.ordinaryAmount + record.overtimeAmount); + } +}; - const checkInTime = attendance?.checkInTime ? new Date(attendance.checkInTime) : null; - const checkOutTime = attendance?.checkOutTime ? new Date(attendance.checkOutTime) : null; +const buildComputedEntries = (shifts, attendanceRecords) => { + const attendanceMap = new Map(); - let totalHours = 0; - let overtimeHours = 0; - let pendingApproval = 0; - let underworkedShift = 0; + for (const attendance of attendanceRecords) { + attendanceMap.set(`${String(attendance.shiftId)}:${String(attendance.guardId)}`, attendance); + } - const scheduledHours = calculateScheduledHours(shift); + const records = []; - if (checkInTime && checkOutTime) { - totalHours = (checkOutTime - checkInTime) / (1000 * 60 * 60); - overtimeHours = Math.max(0, totalHours - 8); + for (const shift of shifts) { + const guardId = String(shift.acceptedBy?._id || shift.acceptedBy); + if (!guardId) continue; - if (scheduledHours > 0 && totalHours < scheduledHours) { - underworkedShift = 1; - } - } else { - pendingApproval = 1; - } + const attendance = attendanceMap.get(`${String(shift._id)}:${guardId}`); + const scheduledHours = calculateScheduledHours(shift); + const actualHours = calculateAttendanceHours(attendance); - return { + records.push({ shiftId: shift._id, - guardId: shift.acceptedBy || null, - guardName: shift.guardName || null, - employerId: shift.createdBy || null, - location: shift.location || null, + guardId, + employerId: String(shift.createdBy?._id || shift.createdBy), + shiftDate: shift.date, + shiftStartAt: getShiftStartDateTime(shift), department: shift.field || null, - date: shift.date || null, + hourlyRate: roundMoney(Number.isFinite(shift.payRate) ? shift.payRate : 0), scheduledHours, - totalHours, - overtimeHours, - underworkedShift, - pendingApproval, - attendanceStatus: checkInTime && checkOutTime ? "complete" : "pending_review", - }; - }); + actualHours: roundHours(actualHours ?? scheduledHours), + payableHours: roundHours(actualHours ?? scheduledHours), + attendanceBased: actualHours != null, + }); + } - const guardSummaryMap = new Map(); + applyDailyOvertime(records); + applyWeeklyOvertime(records); + computeDerivedAmounts(records); - for (const item of payrollDetails) { - const key = String(item.guardId || "unassigned"); + return records; +}; - if (!guardSummaryMap.has(key)) { - guardSummaryMap.set(key, { - guardId: item.guardId, - guardName: item.guardName, - totalShifts: 0, - totalHours: 0, - overtimeHours: 0, - underworkedShifts: 0, - pendingApproval: 0, +const buildPayrollGroups = (records, periodType) => { + const groups = new Map(); + + for (const record of records) { + const bounds = getPeriodBoundsForDate(record.shiftDate, periodType); + const key = [ + record.guardId, + record.employerId, + periodType, + bounds.periodStart.toISOString(), + bounds.periodEnd.toISOString(), + ].join(':'); + + if (!groups.has(key)) { + groups.set(key, { + guardId: record.guardId, + employerId: record.employerId, + periodType, + periodStart: bounds.periodStart, + periodEnd: bounds.periodEnd, + periodLabel: bounds.label, + entries: [], + totalScheduledHours: 0, + totalActualHours: 0, + totalPayableHours: 0, + totalOrdinaryHours: 0, + totalOvertimeHours: 0, + totalOrdinaryAmount: 0, + totalOvertimeAmount: 0, + totalAmount: 0, }); } - const summary = guardSummaryMap.get(key); - summary.totalShifts += 1; - summary.totalHours += item.totalHours; - summary.overtimeHours += item.overtimeHours; - summary.underworkedShifts += item.underworkedShift; - summary.pendingApproval += item.pendingApproval; + const group = groups.get(key); + group.entries.push({ + shiftId: record.shiftId, + shiftDate: record.shiftDate, + department: record.department, + hourlyRate: record.hourlyRate, + scheduledHours: record.scheduledHours, + actualHours: record.actualHours, + payableHours: record.payableHours, + ordinaryHours: record.ordinaryHours, + overtimeHours: record.overtimeHours, + ordinaryAmount: record.ordinaryAmount, + overtimeAmount: record.overtimeAmount, + totalAmount: record.totalAmount, + attendanceBased: record.attendanceBased, + }); + group.totalScheduledHours = roundHours(group.totalScheduledHours + record.scheduledHours); + group.totalActualHours = roundHours(group.totalActualHours + record.actualHours); + group.totalPayableHours = roundHours(group.totalPayableHours + record.payableHours); + group.totalOrdinaryHours = roundHours(group.totalOrdinaryHours + record.ordinaryHours); + group.totalOvertimeHours = roundHours(group.totalOvertimeHours + record.overtimeHours); + group.totalOrdinaryAmount = roundMoney(group.totalOrdinaryAmount + record.ordinaryAmount); + group.totalOvertimeAmount = roundMoney(group.totalOvertimeAmount + record.overtimeAmount); + group.totalAmount = roundMoney(group.totalAmount + record.totalAmount); } - const guardSummaries = Array.from(guardSummaryMap.values()); + return Array.from(groups.values()).map((group) => ({ + ...group, + entries: group.entries.sort((a, b) => new Date(a.shiftDate) - new Date(b.shiftDate)), + })); +}; - const periodSummaryMap = new Map(); +const syncPayrollDocuments = async (groups) => { + if (!groups.length) return []; + + await Payroll.bulkWrite( + groups.map((group) => ({ + updateOne: { + filter: { + guardId: group.guardId, + employerId: group.employerId, + periodType: group.periodType, + periodStart: group.periodStart, + periodEnd: group.periodEnd, + }, + update: { + $set: { + entries: group.entries, + totalScheduledHours: group.totalScheduledHours, + totalActualHours: group.totalActualHours, + totalPayableHours: group.totalPayableHours, + totalOrdinaryHours: group.totalOrdinaryHours, + totalOvertimeHours: group.totalOvertimeHours, + totalOrdinaryAmount: group.totalOrdinaryAmount, + totalOvertimeAmount: group.totalOvertimeAmount, + totalAmount: group.totalAmount, + }, + $setOnInsert: { + status: 'PENDING', + }, + }, + upsert: true, + }, + })), + { ordered: false } + ); + + return Payroll.find({ + $or: groups.map((group) => ({ + guardId: group.guardId, + employerId: group.employerId, + periodType: group.periodType, + periodStart: group.periodStart, + periodEnd: group.periodEnd, + })), + }) + .populate('guardId', 'name') + .populate('employerId', 'name') + .sort({ periodStart: 1, createdAt: 1 }); +}; - for (const item of payrollDetails) { - const label = formatPeriodLabel(item.date, periodType); +const buildSummary = (payrollDocs) => { + return payrollDocs.reduce( + (summary, doc) => { + summary.count += 1; + summary.totalScheduledHours = roundHours(summary.totalScheduledHours + doc.totalScheduledHours); + summary.totalActualHours = roundHours(summary.totalActualHours + doc.totalActualHours); + summary.totalPayableHours = roundHours(summary.totalPayableHours + doc.totalPayableHours); + summary.totalOrdinaryHours = roundHours(summary.totalOrdinaryHours + doc.totalOrdinaryHours); + summary.totalOvertimeHours = roundHours(summary.totalOvertimeHours + doc.totalOvertimeHours); + summary.totalOrdinaryAmount = roundMoney(summary.totalOrdinaryAmount + doc.totalOrdinaryAmount); + summary.totalOvertimeAmount = roundMoney(summary.totalOvertimeAmount + doc.totalOvertimeAmount); + summary.totalAmount = roundMoney(summary.totalAmount + doc.totalAmount); + return summary; + }, + { + count: 0, + totalScheduledHours: 0, + totalActualHours: 0, + totalPayableHours: 0, + totalOrdinaryHours: 0, + totalOvertimeHours: 0, + totalOrdinaryAmount: 0, + totalOvertimeAmount: 0, + totalAmount: 0, + } + ); +}; - if (!periodSummaryMap.has(label)) { - periodSummaryMap.set(label, { - periodLabel: label, - totalShifts: 0, - totalHours: 0, - overtimeHours: 0, - underworkedShifts: 0, - pendingApproval: 0, - }); +const ensureScopedPayrollDocs = async (payrollIds, user) => { + const { userId, role } = getUserContext(user); + const docs = await Payroll.find({ _id: { $in: ensurePayrollIds(payrollIds) } }) + .populate('guardId', 'name') + .populate('employerId', 'name'); + + if (docs.length !== payrollIds.length) { + throw createHttpError(404, 'One or more payroll records were not found'); + } + + if (role === 'guard') { + throw createHttpError(403, 'Guards cannot approve or process payroll'); + } + + if (role === 'employer') { + const invalid = docs.some((doc) => String(doc.employerId._id || doc.employerId) !== String(userId)); + if (invalid) { + throw createHttpError(403, 'Forbidden: one or more payroll records are outside your scope'); } + } + + return docs; +}; + +const formatCurrency = (value) => `$${Number(value || 0).toFixed(2)}`; + +export const getPayrollRecords = async (query, user) => { + const userContext = getUserContext(user); + const range = parseDateRange(query); + const shiftQuery = buildShiftQuery(query, userContext, range); - const periodSummary = periodSummaryMap.get(label); - periodSummary.totalShifts += 1; - periodSummary.totalHours += item.totalHours; - periodSummary.overtimeHours += item.overtimeHours; - periodSummary.underworkedShifts += item.underworkedShift; - periodSummary.pendingApproval += item.pendingApproval; + const shifts = await Shift.find(shiftQuery) + .populate('acceptedBy', 'name') + .populate('createdBy', 'name') + .sort({ date: 1, startTime: 1 }); + + if (!shifts.length) { + return { + filters: { + startDate: query.startDate, + endDate: query.endDate, + periodType: query.periodType, + guardId: query.guardId || null, + department: query.department || null, + }, + summary: buildSummary([]), + payroll: [], + }; } - const periods = Array.from(periodSummaryMap.values()); + const attendanceRecords = await ShiftAttendance.find({ + shiftId: { $in: shifts.map((shift) => shift._id) }, + }); + + const computedEntries = buildComputedEntries(shifts, attendanceRecords); + const groups = buildPayrollGroups(computedEntries, query.periodType); + const payrollDocs = await syncPayrollDocuments(groups); return { - message: "Payroll data retrieved successfully", - accessScope: { - requestedBy: user._id, - role: user.role, - guardRestrictedToSelf: user.role === "guard", - employerRestrictedToOwnShifts: user.role === "employer", - }, filters: { - startDate, - endDate, - periodType, - guardId: guardId || null, - site: site || null, - department: department || null, - }, - summary: { - totalCompletedShifts: shifts.length, - totalAttendanceRecords: attendanceRecords.length, - totalGuards: guardSummaries.length, - totalHours: guardSummaries.reduce((sum, guard) => sum + guard.totalHours, 0), - totalOvertimeHours: guardSummaries.reduce((sum, guard) => sum + guard.overtimeHours, 0), - totalPendingApproval: guardSummaries.reduce((sum, guard) => sum + guard.pendingApproval, 0), + startDate: query.startDate, + endDate: query.endDate, + periodType: query.periodType, + guardId: query.guardId || null, + department: query.department || null, }, - guards: guardSummaries, - periods, - payrollDetails, + summary: buildSummary(payrollDocs), + payroll: payrollDocs.map(serializePayroll), }; -}; \ No newline at end of file +}; + +export const approvePayrollRecords = async (payrollIds, user) => { + const docs = await ensureScopedPayrollDocs(payrollIds, user); + const userId = user._id || user.id; + + for (const doc of docs) { + if (doc.status !== 'PENDING') { + throw createHttpError(409, 'Only payroll records in PENDING status can be approved'); + } + } + + await Payroll.updateMany( + { _id: { $in: docs.map((doc) => doc._id) } }, + { + $set: { + status: 'APPROVED', + approvedAt: new Date(), + approvedBy: userId, + }, + } + ); + + const updatedDocs = await Payroll.find({ _id: { $in: docs.map((doc) => doc._id) } }) + .populate('guardId', 'name') + .populate('employerId', 'name') + .sort({ periodStart: 1, createdAt: 1 }); + + return updatedDocs.map(serializePayroll); +}; + +export const processPayrollRecords = async (payrollIds, user) => { + const docs = await ensureScopedPayrollDocs(payrollIds, user); + const userId = user._id || user.id; + + for (const doc of docs) { + if (doc.status !== 'APPROVED') { + throw createHttpError(409, 'Only payroll records in APPROVED status can be processed'); + } + } + + await Payroll.updateMany( + { _id: { $in: docs.map((doc) => doc._id) } }, + { + $set: { + status: 'PROCESSED', + processedAt: new Date(), + processedBy: userId, + }, + } + ); + + const updatedDocs = await Payroll.find({ _id: { $in: docs.map((doc) => doc._id) } }) + .populate('guardId', 'name') + .populate('employerId', 'name') + .sort({ periodStart: 1, createdAt: 1 }); + + return updatedDocs.map(serializePayroll); +}; + +export const exportPayrollCsv = async (query, user) => { + const result = await getPayrollRecords(query, user); + const rows = [ + [ + 'payrollId', + 'guardId', + 'guardName', + 'employerId', + 'employerName', + 'periodType', + 'periodStart', + 'periodEnd', + 'status', + 'totalScheduledHours', + 'totalActualHours', + 'totalPayableHours', + 'totalOrdinaryHours', + 'totalOvertimeHours', + 'totalAmount', + 'entryCount', + ].map(escapeCsv).join(','), + ]; + + for (const item of result.payroll) { + rows.push( + [ + item.id, + item.guard?.id || '', + item.guard?.name || '', + item.employer?.id || '', + item.employer?.name || '', + item.periodType, + item.periodStart ? new Date(item.periodStart).toISOString() : '', + item.periodEnd ? new Date(item.periodEnd).toISOString() : '', + item.status, + item.totalScheduledHours, + item.totalActualHours, + item.totalPayableHours, + item.totalOrdinaryHours, + item.totalOvertimeHours, + item.totalAmount, + item.entries.length, + ].map(escapeCsv).join(',') + ); + } + + return rows.join('\n'); +}; + +export const exportPayrollPdf = async (query, user) => { + const result = await getPayrollRecords(query, user); + const doc = new PDFDocument({ margin: 40, size: 'A4' }); + const buffers = []; + + doc.on('data', (chunk) => buffers.push(chunk)); + + const finishPromise = new Promise((resolve, reject) => { + doc.on('end', () => resolve(Buffer.concat(buffers))); + doc.on('error', reject); + }); + + doc.fontSize(18).text('Payroll Report'); + doc.moveDown(0.5); + doc.fontSize(10).text(`Period: ${query.startDate} to ${query.endDate}`); + doc.text(`Grouping: ${query.periodType}`); + doc.moveDown(); + doc.text(`Payroll documents: ${result.summary.count}`); + doc.text(`Total payable hours: ${result.summary.totalPayableHours}`); + doc.text(`Total overtime hours: ${result.summary.totalOvertimeHours}`); + doc.text(`Total amount: ${formatCurrency(result.summary.totalAmount)}`); + doc.moveDown(); + + for (const payroll of result.payroll) { + doc.fontSize(12).text( + `${payroll.guard?.name || 'Unknown guard'} | ${payroll.periodType} | ${formatCurrency(payroll.totalAmount)}`, + { underline: true } + ); + doc.fontSize(10).text(`Status: ${payroll.status}`); + doc.text(`Employer: ${payroll.employer?.name || 'Unknown employer'}`); + doc.text( + `Period: ${new Date(payroll.periodStart).toISOString().slice(0, 10)} to ${new Date(payroll.periodEnd) + .toISOString() + .slice(0, 10)}` + ); + doc.text( + `Hours: ordinary ${payroll.totalOrdinaryHours}, overtime ${payroll.totalOvertimeHours}, payable ${payroll.totalPayableHours}` + ); + doc.moveDown(0.25); + + for (const entry of payroll.entries) { + doc.text( + `- ${new Date(entry.shiftDate).toISOString().slice(0, 10)} | Shift ${entry.shiftId} | ${entry.payableHours}h | ${formatCurrency(entry.totalAmount)}` + ); + } + + doc.moveDown(); + if (doc.y > 700) { + doc.addPage(); + } + } + + doc.end(); + return finishPromise; +};