function parseDateOnly(value) { return value ? new Date(`${value}T00:00:00.000Z`) : null; } function formatDateOnly(value) { return value?.toISOString?.()?.slice(0, 10) ?? null; } function parsePayMonth(value) { if (typeof value !== "string" || !/^\d{4}-\d{2}$/.test(value)) { return null; } const [yearText, monthText] = value.split("-"); const year = Number(yearText); const month = Number(monthText); if (!Number.isInteger(year) || !Number.isInteger(month) || month < 1 || month > 12) { return null; } const startDate = new Date(Date.UTC(year, month - 1, 1)); const endDate = new Date(Date.UTC(year, month, 0, 23, 59, 59, 999)); return { value, startDate, endDate, }; } function formatPayMonth(value) { return value?.toISOString?.()?.slice(0, 7) ?? null; } function shiftMonth(startDate, delta) { return new Date(Date.UTC(startDate.getUTCFullYear(), startDate.getUTCMonth() + delta, 1)); } function sortSalaryItems(items) { return [...(items ?? [])].sort((left, right) => { if ((left.sort_order ?? 0) !== (right.sort_order ?? 0)) { return (left.sort_order ?? 0) - (right.sort_order ?? 0); } return String(left.name ?? "").localeCompare(String(right.name ?? "")); }); } function normalizeMoneyInput(value) { if (typeof value === "number") { if (!Number.isFinite(value) || value < 0) { return null; } const rounded = Number(value.toFixed(2)); if (Math.abs(value - rounded) > 1e-9) { return null; } return rounded.toFixed(2); } if (typeof value === "string") { const trimmed = value.trim(); if (!/^\d+(\.\d{1,2})?$/.test(trimmed)) { return null; } return Number(trimmed).toFixed(2); } return null; } function moneyTextToCents(value) { const normalized = normalizeMoneyInput(value); if (!normalized) { return null; } const [integerPart, decimalPart] = normalized.split("."); return Number(integerPart) * 100 + Number(decimalPart); } function centsToMoneyText(value) { return (value / 100).toFixed(2); } function centsToNumber(value) { return Number(centsToMoneyText(value)); } function toNumber(value) { return value == null ? null : Number(value); } function mapSalaryItem(item) { return { id: item.id, name: item.name, itemType: item.item_type, sortOrder: item.sort_order, enabled: item.enabled, createdAt: item.created_at?.toISOString?.() ?? null, createdBy: item.created_by ?? null, updatedAt: item.updated_at?.toISOString?.() ?? null, updatedBy: item.updated_by ?? null, }; } function mapEmployeeSalarySnapshotRows(rows) { const snapshots = new Map(); for (const row of rows) { const key = formatDateOnly(row.effective_date); if (!snapshots.has(key)) { snapshots.set(key, { effectiveDate: key, createdAt: row.created_at?.toISOString?.() ?? null, createdBy: row.created_by ?? null, updatedAt: row.updated_at?.toISOString?.() ?? null, updatedBy: row.updated_by ?? null, items: [], }); } snapshots.get(key).items.push({ salaryItemId: row.salary_item.id, name: row.salary_item.name, itemType: row.salary_item.item_type, sortOrder: row.salary_item.sort_order, monthlyAmount: toNumber(row.monthly_amount) ?? 0, }); } return [...snapshots.values()] .map((snapshot) => ({ ...snapshot, items: snapshot.items.sort((left, right) => left.sortOrder - right.sortOrder || left.name.localeCompare(right.name)), })) .sort((left, right) => right.effectiveDate.localeCompare(left.effectiveDate)); } function calculatePayslipTotals(items) { let grossCents = 0; let deductionCents = 0; for (const item of items) { const cents = moneyTextToCents(item.amount); if (cents == null) { throw new Error("invalid_money_value"); } if (item.itemType === "deduction") { deductionCents += cents; } else { grossCents += cents; } } const netCents = grossCents - deductionCents; return { grossAmount: centsToNumber(grossCents), deductionAmount: centsToNumber(deductionCents), netAmount: centsToNumber(netCents), grossAmountText: centsToMoneyText(grossCents), deductionAmountText: centsToMoneyText(deductionCents), netAmountText: centsToMoneyText(netCents), }; } function mapPayslipItem(item) { return { salaryItemId: item.salary_item.id, name: item.salary_item.name, itemType: item.salary_item.item_type, sortOrder: item.salary_item.sort_order, amount: toNumber(item.amount) ?? 0, }; } function mapPayslip(payslip) { return { id: payslip.id, employeeId: payslip.employee_id, payMonth: formatPayMonth(payslip.pay_month), status: payslip.status, grossAmount: toNumber(payslip.gross_amount) ?? 0, deductionAmount: toNumber(payslip.deduction_amount) ?? 0, netAmount: toNumber(payslip.net_amount) ?? 0, issuedAt: payslip.issued_at?.toISOString?.() ?? null, createdAt: payslip.created_at?.toISOString?.() ?? null, createdBy: payslip.created_by ?? null, updatedAt: payslip.updated_at?.toISOString?.() ?? null, updatedBy: payslip.updated_by ?? null, employee: payslip.employee ? { id: payslip.employee.id, employeeNo: payslip.employee.employee_no, name: payslip.employee.name, title: payslip.employee.title, department: payslip.employee.department ? { id: payslip.employee.department.id, name: payslip.employee.department.name, } : null, } : null, items: [...(payslip.items ?? [])] .sort((left, right) => { const leftOrder = left.salary_item?.sort_order ?? 0; const rightOrder = right.salary_item?.sort_order ?? 0; if (leftOrder !== rightOrder) { return leftOrder - rightOrder; } return String(left.salary_item?.name ?? "").localeCompare(String(right.salary_item?.name ?? "")); }) .map(mapPayslipItem), }; } async function loadMonthlyPayslips(prisma, monthStart) { const payslips = await prisma.payslip.findMany({ where: { pay_month: monthStart }, orderBy: [{ created_at: "asc" }, { id: "asc" }], include: { employee: { select: { id: true, employee_no: true, name: true, title: true, department: { select: { id: true, name: true } }, }, }, items: { include: { salary_item: true, }, }, }, }); const salaryItemMap = new Map(); for (const payslip of payslips) { for (const item of payslip.items ?? []) { if (item.salary_item && !salaryItemMap.has(item.salary_item.id)) { salaryItemMap.set(item.salary_item.id, item.salary_item); } } } return { salaryItems: sortSalaryItems([...salaryItemMap.values()]).map(mapSalaryItem), payslips: payslips .map(mapPayslip) .sort( (left, right) => String(left.employee?.employeeNo ?? "").localeCompare(String(right.employee?.employeeNo ?? "")) || String(left.id).localeCompare(String(right.id)), ), }; } module.exports = { calculatePayslipTotals, centsToMoneyText, formatDateOnly, formatPayMonth, loadMonthlyPayslips, mapEmployeeSalarySnapshotRows, mapPayslip, mapSalaryItem, moneyTextToCents, normalizeMoneyInput, parseDateOnly, parsePayMonth, shiftMonth, sortSalaryItems, toNumber, };