const Dinero = require("dinero.js"); const queries = require("../graphql-client/queries"); const logger = require("../utils/logger"); Dinero.globalRoundingMode = "HALF_EVEN"; Dinero.globalFormatRoundingMode = "HALF_EVEN"; const PAYOUT_METHODS = { hourly: "hourly", commission: "commission" }; const CURRENCY_PRECISION = 2; const HOURS_PRECISION = 5; const toNumber = (value) => { const parsed = Number(value); return Number.isFinite(parsed) ? parsed : 0; }; const normalizeNumericString = (value) => { if (typeof value === "string") { return value.trim(); } if (typeof value === "number" && Number.isFinite(value)) { const asString = value.toString(); if (!asString.toLowerCase().includes("e")) { return asString; } return value.toFixed(12).replace(/0+$/, "").replace(/\.$/, ""); } return `${value ?? ""}`.trim(); }; const decimalToDinero = (value, errorMessage = "Invalid numeric value.") => { const normalizedValue = normalizeNumericString(value); const parsedValue = Number(normalizedValue); if (!Number.isFinite(parsedValue)) { throw new Error(errorMessage); } const isNegative = normalizedValue.startsWith("-"); const unsignedValue = normalizedValue.replace(/^[+-]/, ""); const [wholePart = "0", fractionPartRaw = ""] = unsignedValue.split("."); const wholeDigits = wholePart.replace(/\D/g, "") || "0"; const fractionDigits = fractionPartRaw.replace(/\D/g, ""); const amount = Number(`${wholeDigits}${fractionDigits}` || "0") * (isNegative ? -1 : 1); return Dinero({ amount, precision: fractionDigits.length }); }; const roundValueWithDinero = (value, precision, errorMessage) => decimalToDinero(value, errorMessage).convertPrecision(precision, Dinero.globalRoundingMode).toUnit(); const roundCurrency = (value, errorMessage = "Invalid currency value.") => roundValueWithDinero(value, CURRENCY_PRECISION, errorMessage); const roundHours = (value, errorMessage = "Invalid hours value.") => roundValueWithDinero(value, HOURS_PRECISION, errorMessage); const normalizePayoutMethod = (value) => value === PAYOUT_METHODS.commission ? PAYOUT_METHODS.commission : PAYOUT_METHODS.hourly; const hasOwnValue = (obj, key) => Object.prototype.hasOwnProperty.call(obj || {}, key); const getJobSaleRateField = (laborType) => `rate_${String(laborType || "").toLowerCase()}`; const getTeamMemberLabel = (teamMember) => { const fullName = `${teamMember?.employee?.first_name || ""} ${teamMember?.employee?.last_name || ""}`.trim(); return fullName || teamMember?.employee?.id || teamMember?.employeeid || "unknown employee"; }; const parseRequiredNumber = (value, errorMessage) => { const parsed = Number(value); if (!Number.isFinite(parsed)) { throw new Error(errorMessage); } return parsed; }; const buildFallbackPayoutContext = ({ laborType, rate }) => ({ payout_type: "legacy", payout_method: "legacy", cut_percent_applied: null, source_labor_rate: null, source_labor_type: laborType, effective_rate: roundCurrency(rate) }); function BuildPayoutDetails(job, teamMember, laborType) { const payoutMethod = normalizePayoutMethod(teamMember?.payout_method); const teamMemberLabel = getTeamMemberLabel(teamMember); const sourceLaborRateField = getJobSaleRateField(laborType); if (payoutMethod === PAYOUT_METHODS.hourly && !hasOwnValue(teamMember?.labor_rates, laborType)) { throw new Error(`Missing hourly payout rate for ${teamMemberLabel} on labor type ${laborType}.`); } if (payoutMethod === PAYOUT_METHODS.commission && !hasOwnValue(teamMember?.commission_rates, laborType)) { throw new Error(`Missing commission percent for ${teamMemberLabel} on labor type ${laborType}.`); } if (payoutMethod === PAYOUT_METHODS.commission && !hasOwnValue(job, sourceLaborRateField)) { throw new Error(`Missing sale rate ${sourceLaborRateField} for labor type ${laborType}.`); } const hourlyRate = payoutMethod === PAYOUT_METHODS.hourly ? roundCurrency( parseRequiredNumber( teamMember?.labor_rates?.[laborType], `Invalid hourly payout rate for ${teamMemberLabel} on labor type ${laborType}.` ) ) : null; const commissionPercent = payoutMethod === PAYOUT_METHODS.commission ? roundCurrency( parseRequiredNumber( teamMember?.commission_rates?.[laborType], `Invalid commission percent for ${teamMemberLabel} on labor type ${laborType}.` ) ) : null; if (commissionPercent !== null && (commissionPercent < 0 || commissionPercent > 100)) { throw new Error(`Commission percent for ${teamMemberLabel} on labor type ${laborType} must be between 0 and 100.`); } const sourceLaborRate = payoutMethod === PAYOUT_METHODS.commission ? roundCurrency( parseRequiredNumber(job?.[sourceLaborRateField], `Invalid sale rate ${sourceLaborRateField} for labor type ${laborType}.`) ) : null; const effectiveRate = payoutMethod === PAYOUT_METHODS.commission ? roundCurrency((sourceLaborRate * toNumber(commissionPercent)) / 100) : hourlyRate; return { effectiveRate, payoutContext: { payout_type: payoutMethod === PAYOUT_METHODS.commission ? "cut" : "hourly", payout_method: payoutMethod, cut_percent_applied: commissionPercent, source_labor_rate: sourceLaborRate, source_labor_type: laborType, effective_rate: effectiveRate } }; } function BuildGeneratedPayoutContext({ baseContext, generatedBy, generatedFrom, taskName, usedTicketFallback }) { return { ...(baseContext || {}), generated_by: generatedBy, generated_at: new Date().toISOString(), generated_from: generatedFrom, task_name: taskName, used_ticket_fallback: Boolean(usedTicketFallback) }; } function getAllKeys(...objects) { return [...new Set(objects.flatMap((obj) => (obj ? Object.keys(obj) : [])))]; } function buildPayAllMemo({ deltaHours, hasExpected, hasClaimed, userEmail }) { if (!hasClaimed && deltaHours > 0) { return `Add unflagged hours. (${userEmail})`; } if (!hasExpected && deltaHours < 0) { return `Remove flagged hours per assignment. (${userEmail})`; } return `Adjust flagged hours per assignment. (${userEmail})`; } exports.payall = async function (req, res) { const { jobid } = req.body; logger.log("job-payroll-pay-all", "DEBUG", req.user.email, jobid, null); const BearerToken = req.BearerToken; const client = req.userGraphQLClient; try { const { jobs_by_pk: job } = await client .setHeaders({ Authorization: BearerToken }) .request(queries.QUERY_JOB_PAYROLL_DATA, { id: jobid }); const { employeeHash, assignmentHash } = CalculateExpectedHoursForJob(job); const ticketHash = CalculateTicketsHoursForJob(job); if (assignmentHash.unassigned > 0) { res.json({ success: false, error: "Not all hours have been assigned." }); return; } const ticketsToInsert = []; const employeeIds = getAllKeys(employeeHash, ticketHash); employeeIds.forEach((employeeId) => { const expectedByLabor = employeeHash[employeeId] || {}; const claimedByLabor = ticketHash[employeeId] || {}; getAllKeys(expectedByLabor, claimedByLabor).forEach((laborType) => { const expected = expectedByLabor[laborType]; const claimed = claimedByLabor[laborType]; const deltaHours = roundHours((expected?.hours || 0) - (claimed?.hours || 0)); if (deltaHours === 0) { return; } const effectiveRate = roundCurrency(expected?.rate ?? claimed?.rate); const payoutContext = BuildGeneratedPayoutContext({ baseContext: expected?.payoutContext || claimed?.payoutContext || buildFallbackPayoutContext({ laborType, rate: effectiveRate }), generatedBy: req.user.email, generatedFrom: "payall", taskName: "Pay All", usedTicketFallback: !expected && Boolean(claimed) }); ticketsToInsert.push({ task_name: "Pay All", jobid: job.id, bodyshopid: job.bodyshop.id, employeeid: employeeId, productivehrs: deltaHours, rate: effectiveRate, ciecacode: laborType, cost_center: job.bodyshop.md_responsibility_centers.defaults.costs[laborType], flat_rate: true, created_by: req.user.email, payout_context: payoutContext, memo: buildPayAllMemo({ deltaHours, hasExpected: Boolean(expected), hasClaimed: Boolean(claimed), userEmail: req.user.email }) }); }); }); const filteredTickets = ticketsToInsert.filter((ticket) => ticket.productivehrs !== 0); if (filteredTickets.length > 0) { await client.request(queries.INSERT_TIME_TICKETS, { timetickets: filteredTickets }); } res.json(filteredTickets); } catch (error) { logger.log("job-payroll-labor-totals-error", "ERROR", req.user.email, jobid, { jobid, error: JSON.stringify(error) }); res.status(400).json({ error: error.message }); } }; function CalculateExpectedHoursForJob(job, filterToLbrTypes) { const assignmentHash = { unassigned: 0 }; const employeeHash = {}; // employeeid => Cieca labor type => { hours, rate, payoutContext } const laborTypeFilter = Array.isArray(filterToLbrTypes) ? filterToLbrTypes : null; job.joblines .filter((jobline) => { if (!laborTypeFilter) { return true; } const convertedLaborType = jobline.convertedtolbr ? jobline.convertedtolbr_data?.mod_lbr_ty : null; return laborTypeFilter.includes(jobline.mod_lbr_ty) || (convertedLaborType && laborTypeFilter.includes(convertedLaborType)); }) .forEach((jobline) => { const laborType = jobline.convertedtolbr ? jobline.convertedtolbr_data?.mod_lbr_ty || jobline.mod_lbr_ty : jobline.mod_lbr_ty; const laborHours = roundHours( toNumber(jobline.mod_lb_hrs) + (jobline.convertedtolbr ? toNumber(jobline.convertedtolbr_data?.mod_lb_hrs) : 0) ); if (laborHours === 0) { return; } if (jobline.assigned_team === null) { assignmentHash.unassigned = roundHours(assignmentHash.unassigned + laborHours); return; } const theTeam = job.bodyshop.employee_teams.find((team) => team.id === jobline.assigned_team); if (!theTeam) { assignmentHash.unassigned = roundHours(assignmentHash.unassigned + laborHours); return; } assignmentHash[jobline.assigned_team] = roundHours((assignmentHash[jobline.assigned_team] || 0) + laborHours); theTeam.employee_team_members.forEach((teamMember) => { const employeeId = teamMember.employee.id; const { effectiveRate, payoutContext } = BuildPayoutDetails(job, teamMember, laborType); if (!employeeHash[employeeId]) { employeeHash[employeeId] = {}; } if (!employeeHash[employeeId][laborType]) { employeeHash[employeeId][laborType] = { hours: 0, rate: effectiveRate, payoutContext }; } const hoursOwed = roundHours((toNumber(teamMember.percentage) * laborHours) / 100); employeeHash[employeeId][laborType].hours = roundHours(employeeHash[employeeId][laborType].hours + hoursOwed); employeeHash[employeeId][laborType].rate = effectiveRate; employeeHash[employeeId][laborType].payoutContext = payoutContext; }); }); return { assignmentHash, employeeHash }; } function CalculateTicketsHoursForJob(job) { const ticketHash = {}; // employeeid => Cieca labor type => { hours, rate, payoutContext } job.timetickets.forEach((ticket) => { if (!ticket?.employeeid || !ticket?.ciecacode) { return; } if (!ticketHash[ticket.employeeid]) { ticketHash[ticket.employeeid] = {}; } if (!ticketHash[ticket.employeeid][ticket.ciecacode]) { ticketHash[ticket.employeeid][ticket.ciecacode] = { hours: 0, rate: roundCurrency(ticket.rate), payoutContext: ticket.payout_context || null }; } ticketHash[ticket.employeeid][ticket.ciecacode].hours = roundHours( ticketHash[ticket.employeeid][ticket.ciecacode].hours + toNumber(ticket.productivehrs) ); if (ticket.rate !== null && ticket.rate !== undefined) { ticketHash[ticket.employeeid][ticket.ciecacode].rate = roundCurrency(ticket.rate); } if (ticket.payout_context) { ticketHash[ticket.employeeid][ticket.ciecacode].payoutContext = ticket.payout_context; } }); return ticketHash; } exports.BuildPayoutDetails = BuildPayoutDetails; exports.CalculateExpectedHoursForJob = CalculateExpectedHoursForJob; exports.CalculateTicketsHoursForJob = CalculateTicketsHoursForJob; exports.RoundPayrollHours = roundHours;