{
+ const allocationErrors = getTaskPresetAllocationErrors(presets, t);
+
+ if (allocationErrors.length > 0) {
+ throw new Error(allocationErrors.join(" "));
+ }
+ }
+ }
+ ]}
+ >
+ {(fields, { add, remove, move }, { errors }) => {
return (
{fields.map((field, index) => (
@@ -189,6 +239,7 @@ export function ShopInfoTaskPresets({ bodyshop }) {
))}
+
-
-
-
-
-
-
-
-
-
-
+
+
+ {() => {
+ const payoutMethod =
+ form.getFieldValue(["employee_team_members", field.name, "payout_method"]) || "hourly";
+ const fieldName = payoutMethod === "commission" ? "commission_rates" : "labor_rates";
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+ return LABOR_TYPES.map((laborType) => (
+
+ {payoutMethod === "commission" ? (
+
+ ) : (
+
+ )}
+
+ ));
+ }}
{
- add();
+ add({
+ percentage: 0,
+ payout_method: "hourly",
+ labor_rates: {},
+ commission_rates: {}
+ });
}}
style={{ width: "100%" }}
>
{t("employee_teams.actions.newmember")}
+
+ {() => {
+ const teamMembers = form.getFieldValue(["employee_team_members"]) || [];
+ const splitTotal = getSplitTotal(teamMembers);
+
+ return (
+
+ {t("employee_teams.labels.allocation_total", {
+ total: splitTotal.toFixed(2)
+ })}
+
+ );
+ }}
+
);
}}
diff --git a/client/src/components/time-ticket-task-modal/time-ticket-task-modal.component.jsx b/client/src/components/time-ticket-task-modal/time-ticket-task-modal.component.jsx
index 276b852fe..d06e2fed6 100644
--- a/client/src/components/time-ticket-task-modal/time-ticket-task-modal.component.jsx
+++ b/client/src/components/time-ticket-task-modal/time-ticket-task-modal.component.jsx
@@ -35,7 +35,15 @@ export function TimeTicketTaskModalComponent({ bodyshop, form, loading, complete
-
+
{loading ? (
) : (
@@ -93,6 +101,8 @@ export function TimeTicketTaskModalComponent({ bodyshop, form, loading, complete
{t("timetickets.fields.cost_center")} |
{t("timetickets.fields.ciecacode")} |
{t("timetickets.fields.productivehrs")} |
+ {t("timetickets.fields.rate")} |
+ {t("timetickets.fields.amount")} |
@@ -118,6 +128,16 @@ export function TimeTicketTaskModalComponent({ bodyshop, form, loading, complete
+
+
+
+
+ |
+
+
+
+
+ |
))}
diff --git a/client/src/components/time-ticket-task-modal/time-ticket-task-modal.container.jsx b/client/src/components/time-ticket-task-modal/time-ticket-task-modal.container.jsx
index 22bf9ea94..f69d68523 100644
--- a/client/src/components/time-ticket-task-modal/time-ticket-task-modal.container.jsx
+++ b/client/src/components/time-ticket-task-modal/time-ticket-task-modal.container.jsx
@@ -90,7 +90,12 @@ export function TimeTickeTaskModalContainer({ currentUser, technician, timeTicke
if (actions?.refetch) actions.refetch();
toggleModalVisible();
} else if (handleFinish === false) {
- form.setFieldsValue({ timetickets: data.ticketsToInsert });
+ form.setFieldsValue({
+ timetickets: (data.ticketsToInsert || []).map((ticket) => ({
+ ...ticket,
+ payoutamount: Number(ticket.productivehrs || 0) * Number(ticket.rate || 0)
+ }))
+ });
setUnassignedHours(data.unassignedHours);
} else {
notification.error({
@@ -101,7 +106,9 @@ export function TimeTickeTaskModalContainer({ currentUser, technician, timeTicke
}
} catch (error) {
notification.error({
- title: t("timetickets.errors.creating", { message: error.message })
+ title: t("timetickets.errors.creating", {
+ message: error.response?.data?.error || error.message
+ })
});
} finally {
setLoading(false);
diff --git a/client/src/components/tt-approvals-list/tt-approvals-list.component.jsx b/client/src/components/tt-approvals-list/tt-approvals-list.component.jsx
index e1b591414..63090257f 100644
--- a/client/src/components/tt-approvals-list/tt-approvals-list.component.jsx
+++ b/client/src/components/tt-approvals-list/tt-approvals-list.component.jsx
@@ -130,7 +130,15 @@ export function TtApprovalsListComponent({
key: "memo",
sorter: (a, b) => alphaSort(a.memo, b.memo),
sortOrder: state.sortedInfo.columnKey === "memo" && state.sortedInfo.order,
- render: (text, record) => (record.clockon || record.clockoff ? t(record.memo) : record.memo)
+ render: (text, record) => (record.memo?.startsWith("timetickets.labels") ? t(record.memo) : record.memo)
+ },
+ {
+ title: t("timetickets.fields.task_name"),
+ dataIndex: "task_name",
+ key: "task_name",
+ sorter: (a, b) => alphaSort(a.task_name, b.task_name),
+ sortOrder: state.sortedInfo.columnKey === "task_name" && state.sortedInfo.order,
+ render: (text, record) => record.task_name || ""
},
{
title: t("timetickets.fields.clockon"),
@@ -140,12 +148,12 @@ export function TtApprovalsListComponent({
render: (text, record) => {record.clockon}
},
{
- title: "Pay",
+ title: t("timetickets.fields.pay"),
dataIndex: "pay",
key: "pay",
render: (text, record) =>
- Dinero({ amount: Math.round(record.rate * 100) })
- .multiply(record.flat_rate ? record.productivehrs : record.actualhrs)
+ Dinero({ amount: Math.round((record.rate || 0) * 100) })
+ .multiply(record.flat_rate ? record.productivehrs || 0 : record.actualhrs || 0)
.toFormat("$0.00")
}
];
@@ -184,7 +192,7 @@ export function TtApprovalsListComponent({
{
- return key.split(".").reduce((o, x) => {
- return typeof o == "undefined" || o === null ? o : o[x];
- }, obj);
-};
-
exports.calculatelabor = async function (req, res) {
- const { jobid, calculateOnly } = req.body;
+ const { jobid } = req.body;
logger.log("job-payroll-calculate-labor", "DEBUG", req.user.email, jobid, null);
const BearerToken = req.BearerToken;
@@ -41,23 +30,19 @@ exports.calculatelabor = async function (req, res) {
Object.keys(employeeHash).forEach((employeeIdKey) => {
//At the employee level.
Object.keys(employeeHash[employeeIdKey]).forEach((laborTypeKey) => {
- //At the labor level
- Object.keys(employeeHash[employeeIdKey][laborTypeKey]).forEach((rateKey) => {
- //At the rate level.
- const expectedHours = employeeHash[employeeIdKey][laborTypeKey][rateKey];
- //Will the following line fail? Probably if it doesn't exist.
- const claimedHours = get(ticketHash, `${employeeIdKey}.${laborTypeKey}.${rateKey}`);
- if (claimedHours) {
- delete ticketHash[employeeIdKey][laborTypeKey][rateKey];
- }
+ const expected = employeeHash[employeeIdKey][laborTypeKey];
+ const claimed = ticketHash?.[employeeIdKey]?.[laborTypeKey];
- totals.push({
- employeeid: employeeIdKey,
- rate: rateKey,
- mod_lbr_ty: laborTypeKey,
- expectedHours,
- claimedHours: claimedHours || 0
- });
+ if (claimed) {
+ delete ticketHash[employeeIdKey][laborTypeKey];
+ }
+
+ totals.push({
+ employeeid: employeeIdKey,
+ rate: expected.rate,
+ mod_lbr_ty: laborTypeKey,
+ expectedHours: expected.hours,
+ claimedHours: claimed?.hours || 0
});
});
});
@@ -65,23 +50,14 @@ exports.calculatelabor = async function (req, res) {
Object.keys(ticketHash).forEach((employeeIdKey) => {
//At the employee level.
Object.keys(ticketHash[employeeIdKey]).forEach((laborTypeKey) => {
- //At the labor level
- Object.keys(ticketHash[employeeIdKey][laborTypeKey]).forEach((rateKey) => {
- //At the rate level.
- const expectedHours = 0;
- //Will the following line fail? Probably if it doesn't exist.
- const claimedHours = get(ticketHash, `${employeeIdKey}.${laborTypeKey}.${rateKey}`);
- if (claimedHours) {
- delete ticketHash[employeeIdKey][laborTypeKey][rateKey];
- }
+ const claimed = ticketHash[employeeIdKey][laborTypeKey];
- totals.push({
- employeeid: employeeIdKey,
- rate: rateKey,
- mod_lbr_ty: laborTypeKey,
- expectedHours,
- claimedHours: claimedHours || 0
- });
+ totals.push({
+ employeeid: employeeIdKey,
+ rate: claimed.rate,
+ mod_lbr_ty: laborTypeKey,
+ expectedHours: 0,
+ claimedHours: claimed.hours || 0
});
});
});
@@ -101,6 +77,6 @@ exports.calculatelabor = async function (req, res) {
jobid: jobid,
error
});
- res.status(503).send();
+ res.status(400).json({ error: error.message });
}
};
diff --git a/server/payroll/claim-task.js b/server/payroll/claim-task.js
index 35c07cb35..6e130a5f1 100644
--- a/server/payroll/claim-task.js
+++ b/server/payroll/claim-task.js
@@ -1,11 +1,42 @@
-const Dinero = require("dinero.js");
const queries = require("../graphql-client/queries");
const logger = require("../utils/logger");
-const { CalculateExpectedHoursForJob } = require("./pay-all");
+const { CalculateExpectedHoursForJob, RoundPayrollHours } = require("./pay-all");
const moment = require("moment");
-// Dinero.defaultCurrency = "USD";
-// Dinero.globalLocale = "en-CA";
-Dinero.globalRoundingMode = "HALF_EVEN";
+
+const normalizePercent = (value) => Math.round((Number(value || 0) + Number.EPSILON) * 10000) / 10000;
+
+const getTaskPresetAllocationError = (taskPresets = []) => {
+ const totalsByLaborType = {};
+
+ taskPresets.forEach((taskPreset) => {
+ const percent = normalizePercent(taskPreset?.percent);
+
+ if (!percent) {
+ return;
+ }
+
+ const laborTypes = Array.isArray(taskPreset?.hourstype) ? taskPreset.hourstype : [];
+
+ laborTypes.forEach((laborType) => {
+ if (!laborType) {
+ return;
+ }
+
+ totalsByLaborType[laborType] = normalizePercent((totalsByLaborType[laborType] || 0) + percent);
+ });
+ });
+
+ const overAllocatedType = Object.entries(totalsByLaborType).find(([, total]) => total > 100);
+
+ if (!overAllocatedType) {
+ return null;
+ }
+
+ const [laborType, total] = overAllocatedType;
+ return `Task preset percentages for labor type ${laborType} total ${total}% and cannot exceed 100%.`;
+};
+
+exports.GetTaskPresetAllocationError = getTaskPresetAllocationError;
exports.claimtask = async function (req, res) {
const { jobid, task, calculateOnly, employee } = req.body;
@@ -21,12 +52,25 @@ exports.claimtask = async function (req, res) {
id: jobid
});
- const theTaskPreset = job.bodyshop.md_tasks_presets.presets.find((tp) => tp.name === task);
+ const taskPresets = job.bodyshop?.md_tasks_presets?.presets || [];
+ const taskPresetAllocationError = getTaskPresetAllocationError(taskPresets);
+ if (taskPresetAllocationError) {
+ res.status(400).json({ success: false, error: taskPresetAllocationError });
+ return;
+ }
+
+ const theTaskPreset = taskPresets.find((tp) => tp.name === task);
if (!theTaskPreset) {
res.status(400).json({ success: false, error: "Provided task preset not found." });
return;
}
+ const taskAlreadyCompleted = (job.completed_tasks || []).some((completedTask) => completedTask?.name === task);
+ if (taskAlreadyCompleted) {
+ res.status(400).json({ success: false, error: "Provided task preset has already been completed for this job." });
+ return;
+ }
+
//Get all of the assignments that are filtered.
const { assignmentHash, employeeHash } = CalculateExpectedHoursForJob(job, theTaskPreset.hourstype);
const ticketsToInsert = [];
@@ -35,32 +79,37 @@ exports.claimtask = async function (req, res) {
Object.keys(employeeHash).forEach((employeeIdKey) => {
//At the employee level.
Object.keys(employeeHash[employeeIdKey]).forEach((laborTypeKey) => {
- //At the labor level
- Object.keys(employeeHash[employeeIdKey][laborTypeKey]).forEach((rateKey) => {
- //At the rate level.
- const expectedHours = employeeHash[employeeIdKey][laborTypeKey][rateKey] * (theTaskPreset.percent / 100);
+ const expected = employeeHash[employeeIdKey][laborTypeKey];
+ const expectedHours = RoundPayrollHours(expected.hours * (theTaskPreset.percent / 100));
- ticketsToInsert.push({
- task_name: task,
- jobid: job.id,
- bodyshopid: job.bodyshop.id,
- employeeid: employeeIdKey,
- productivehrs: expectedHours,
- rate: rateKey,
- ciecacode: laborTypeKey,
- flat_rate: true,
- cost_center: job.bodyshop.md_responsibility_centers.defaults.costs[laborTypeKey],
- memo: `*Flagged Task* ${theTaskPreset.memo}`
- });
+ ticketsToInsert.push({
+ task_name: task,
+ jobid: job.id,
+ bodyshopid: job.bodyshop.id,
+ employeeid: employeeIdKey,
+ productivehrs: expectedHours,
+ rate: expected.rate,
+ ciecacode: laborTypeKey,
+ flat_rate: true,
+ created_by: employee?.name || req.user.email,
+ payout_context: {
+ ...(expected.payoutContext || {}),
+ generated_by: req.user.email,
+ generated_at: new Date().toISOString(),
+ generated_from: "claimtask",
+ task_name: task
+ },
+ cost_center: job.bodyshop.md_responsibility_centers.defaults.costs[laborTypeKey],
+ memo: `*Flagged Task* ${theTaskPreset.memo}`
});
});
});
if (!calculateOnly) {
//Insert the time ticekts if we're not just calculating them.
- const insertResult = await client.request(queries.INSERT_TIME_TICKETS, {
+ await client.request(queries.INSERT_TIME_TICKETS, {
timetickets: ticketsToInsert.filter((ticket) => ticket.productivehrs !== 0)
});
- const updateResult = await client.request(queries.UPDATE_JOB, {
+ await client.request(queries.UPDATE_JOB, {
jobId: job.id,
job: {
status: theTaskPreset.nextstatus,
@@ -82,6 +131,6 @@ exports.claimtask = async function (req, res) {
jobid: jobid,
error
});
- res.status(503).send();
+ res.status(400).json({ success: false, error: error.message });
}
};
diff --git a/server/payroll/pay-all.js b/server/payroll/pay-all.js
index 6a7147ca1..0e2083d7e 100644
--- a/server/payroll/pay-all.js
+++ b/server/payroll/pay-all.js
@@ -1,15 +1,196 @@
const Dinero = require("dinero.js");
const queries = require("../graphql-client/queries");
-const rdiff = require("recursive-diff");
-
const logger = require("../utils/logger");
-// Dinero.defaultCurrency = "USD";
-// Dinero.globalLocale = "en-CA";
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, calculateOnly } = req.body;
+ const { jobid } = req.body;
logger.log("job-payroll-pay-all", "DEBUG", req.user.email, jobid, null);
const BearerToken = req.BearerToken;
@@ -22,253 +203,183 @@ exports.payall = async function (req, res) {
id: jobid
});
- //iterate over each ticket, building a hash of team -> employee to calculate total assigned hours.
-
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;
}
- //Calculate how much time each tech should have by labor type.
- //Doing this order creates a diff of changes on the ticket hash to make it the same as the employee hash.
- const recursiveDiff = rdiff.getDiff(ticketHash, employeeHash, true);
-
const ticketsToInsert = [];
+ const employeeIds = getAllKeys(employeeHash, ticketHash);
- recursiveDiff.forEach((diff) => {
- //Every iteration is what we would need to insert into the time ticket hash
- //so that it would match the employee hash exactly.
- const path = diffParser(diff);
+ employeeIds.forEach((employeeId) => {
+ const expectedByLabor = employeeHash[employeeId] || {};
+ const claimedByLabor = ticketHash[employeeId] || {};
- if (diff.op === "add") {
- // console.log(Object.keys(diff.val));
- if (typeof diff.val === "object" && Object.keys(diff.val).length > 1) {
- //Multiple values to add.
- Object.keys(diff.val).forEach((key) => {
- // console.log("Hours", diff.val[key][Object.keys(diff.val[key])[0]]);
- // console.log("Rate", Object.keys(diff.val[key])[0]);
- ticketsToInsert.push({
- task_name: "Pay All",
- jobid: job.id,
- bodyshopid: job.bodyshop.id,
- employeeid: path.employeeid,
- productivehrs: diff.val[key][Object.keys(diff.val[key])[0]],
- rate: Object.keys(diff.val[key])[0],
- ciecacode: key,
- cost_center: job.bodyshop.md_responsibility_centers.defaults.costs[key],
- flat_rate: true,
- memo: `Add unflagged hours. (${req.user.email})`
- });
- });
- } else {
- //Only the 1 value to add.
- ticketsToInsert.push({
- task_name: "Pay All",
- jobid: job.id,
- bodyshopid: job.bodyshop.id,
- employeeid: path.employeeid,
- productivehrs: path.hours,
- rate: path.rate,
- ciecacode: path.mod_lbr_ty,
- flat_rate: true,
- cost_center: job.bodyshop.md_responsibility_centers.defaults.costs[path.mod_lbr_ty],
- memo: `Add unflagged hours. (${req.user.email})`
- });
+ 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;
}
- } else if (diff.op === "update") {
- //An old ticket amount isn't sufficient
- //We can't modify the existing ticket, it might already be committed. So let's add a new one instead.
+
+ 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: path.employeeid,
- productivehrs: diff.val - diff.oldVal,
- rate: path.rate,
- ciecacode: path.mod_lbr_ty,
+ employeeid: employeeId,
+ productivehrs: deltaHours,
+ rate: effectiveRate,
+ ciecacode: laborType,
+ cost_center: job.bodyshop.md_responsibility_centers.defaults.costs[laborType],
flat_rate: true,
- cost_center: job.bodyshop.md_responsibility_centers.defaults.costs[path.mod_lbr_ty],
- memo: `Adjust flagged hours per assignment. (${req.user.email})`
+ created_by: req.user.email,
+ payout_context: payoutContext,
+ memo: buildPayAllMemo({
+ deltaHours,
+ hasExpected: Boolean(expected),
+ hasClaimed: Boolean(claimed),
+ userEmail: req.user.email
+ })
});
- } else {
- //Has to be a delete
- if (typeof diff.oldVal === "object" && Object.keys(diff.oldVal).length > 1) {
- //Multiple oldValues to add.
- Object.keys(diff.oldVal).forEach((key) => {
- ticketsToInsert.push({
- task_name: "Pay All",
- jobid: job.id,
- bodyshopid: job.bodyshop.id,
- employeeid: path.employeeid,
- productivehrs: diff.oldVal[key][Object.keys(diff.oldVal[key])[0]] * -1,
- rate: Object.keys(diff.oldVal[key])[0],
- ciecacode: key,
- cost_center: job.bodyshop.md_responsibility_centers.defaults.costs[key],
- flat_rate: true,
- memo: `Remove flagged hours per assignment. (${req.user.email})`
- });
- });
- } else {
- //Only the 1 value to add.
- ticketsToInsert.push({
- task_name: "Pay All",
- jobid: job.id,
- bodyshopid: job.bodyshop.id,
- employeeid: path.employeeid,
- productivehrs: path.hours * -1,
- rate: path.rate,
- ciecacode: path.mod_lbr_ty,
- cost_center: job.bodyshop.md_responsibility_centers.defaults.costs[path.mod_lbr_ty],
- flat_rate: true,
- memo: `Remove flagged hours per assignment. (${req.user.email})`
- });
- }
- }
+ });
});
- const insertResult = await client.request(queries.INSERT_TIME_TICKETS, {
- timetickets: ticketsToInsert.filter((ticket) => ticket.productivehrs !== 0)
- });
+ const filteredTickets = ticketsToInsert.filter((ticket) => ticket.productivehrs !== 0);
- res.json(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: jobid,
+ jobid,
error: JSON.stringify(error)
});
res.status(400).json({ error: error.message });
}
};
-function diffParser(diff) {
- const type = typeof diff.oldVal;
- let mod_lbr_ty, rate, hours;
-
- if (diff.path.length === 1) {
- if (diff.op === "add") {
- mod_lbr_ty = Object.keys(diff.val)[0];
- rate = Object.keys(diff.val[mod_lbr_ty])[0];
- // hours = diff.oldVal[mod_lbr_ty][rate];
- } else {
- mod_lbr_ty = Object.keys(diff.oldVal)[0];
- rate = Object.keys(diff.oldVal[mod_lbr_ty])[0];
- // hours = diff.oldVal[mod_lbr_ty][rate];
- }
- } else if (diff.path.length === 2) {
- mod_lbr_ty = diff.path[1];
- if (diff.op === "add") {
- rate = Object.keys(diff.val)[0];
- } else {
- rate = Object.keys(diff.oldVal)[0];
- }
- } else if (diff.path.length === 3) {
- mod_lbr_ty = diff.path[1];
- rate = diff.path[2];
- //hours = 0;
- }
-
- //Set the hours
- if (typeof diff.val === "number" && diff.val !== null && diff.val !== undefined) {
- hours = diff.val;
- } else if (diff.val !== null && diff.val !== undefined) {
- if (diff.path.length === 1) {
- hours = diff.val[Object.keys(diff.val)[0]][Object.keys(diff.val[Object.keys(diff.val)[0]])];
- } else {
- hours = diff.val[Object.keys(diff.val)[0]];
- }
- } else if (typeof diff.oldVal === "number" && diff.oldVal !== null && diff.oldVal !== undefined) {
- hours = diff.oldVal;
- } else {
- hours = diff.oldVal[Object.keys(diff.oldVal)[0]];
- }
-
- const ret = {
- multiVal: false,
- employeeid: diff.path[0], // Always True
- mod_lbr_ty,
- rate,
- hours
- };
- return ret;
-}
-
function CalculateExpectedHoursForJob(job, filterToLbrTypes) {
const assignmentHash = { unassigned: 0 };
- const employeeHash = {}; // employeeid => Cieca labor type => rate => hours. Contains how many hours each person should be paid.
+ const employeeHash = {}; // employeeid => Cieca labor type => { hours, rate, payoutContext }
+ const laborTypeFilter = Array.isArray(filterToLbrTypes) ? filterToLbrTypes : null;
+
job.joblines
.filter((jobline) => {
- if (!filterToLbrTypes) return true;
- else {
- return (
- filterToLbrTypes.includes(jobline.mod_lbr_ty) ||
- (jobline.convertedtolbr && filterToLbrTypes.includes(jobline.convertedtolbr_data.mod_lbr_ty))
- );
+ 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) => {
- if (jobline.convertedtolbr) {
- // Line has been converte to labor. Temporarily re-assign the hours.
- jobline.mod_lbr_ty = jobline.convertedtolbr_data.mod_lbr_ty;
- jobline.mod_lb_hrs += jobline.convertedtolbr_data.mod_lb_hrs;
+ 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.mod_lb_hrs != 0) {
- //Check if the line is assigned. If not, keep track of it as an unassigned line by type.
- if (jobline.assigned_team === null) {
- assignmentHash.unassigned = assignmentHash.unassigned + jobline.mod_lb_hrs;
- } else {
- //Line is assigned.
- if (!assignmentHash[jobline.assigned_team]) {
- assignmentHash[jobline.assigned_team] = 0;
- }
- assignmentHash[jobline.assigned_team] = assignmentHash[jobline.assigned_team] + jobline.mod_lb_hrs;
- //Create the assignment breakdown.
- const theTeam = job.bodyshop.employee_teams.find((team) => team.id === jobline.assigned_team);
+ if (jobline.assigned_team === null) {
+ assignmentHash.unassigned = roundHours(assignmentHash.unassigned + laborHours);
+ return;
+ }
- theTeam.employee_team_members.forEach((tm) => {
- //Figure out how many hours they are owed at this line, and at what rate.
+ const theTeam = job.bodyshop.employee_teams.find((team) => team.id === jobline.assigned_team);
- if (!employeeHash[tm.employee.id]) {
- employeeHash[tm.employee.id] = {};
- }
- if (!employeeHash[tm.employee.id][jobline.mod_lbr_ty]) {
- employeeHash[tm.employee.id][jobline.mod_lbr_ty] = {};
- }
- if (!employeeHash[tm.employee.id][jobline.mod_lbr_ty][tm.labor_rates[jobline.mod_lbr_ty]]) {
- employeeHash[tm.employee.id][jobline.mod_lbr_ty][tm.labor_rates[jobline.mod_lbr_ty]] = 0;
- }
+ if (!theTeam) {
+ assignmentHash.unassigned = roundHours(assignmentHash.unassigned + laborHours);
+ return;
+ }
- const hoursOwed = (tm.percentage * jobline.mod_lb_hrs) / 100;
- employeeHash[tm.employee.id][jobline.mod_lbr_ty][tm.labor_rates[jobline.mod_lbr_ty]] =
- employeeHash[tm.employee.id][jobline.mod_lbr_ty][tm.labor_rates[jobline.mod_lbr_ty]] + hoursOwed;
- });
+ 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 => rate => hours.
- //Calculate how much each employee has been paid so far.
+ 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] = {};
+ ticketHash[ticket.employeeid][ticket.ciecacode] = {
+ hours: 0,
+ rate: roundCurrency(ticket.rate),
+ payoutContext: ticket.payout_context || null
+ };
}
- if (!ticketHash[ticket.employeeid][ticket.ciecacode][ticket.rate]) {
- ticketHash[ticket.employeeid][ticket.ciecacode][ticket.rate] = 0;
+
+ 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;
}
- ticketHash[ticket.employeeid][ticket.ciecacode][ticket.rate] =
- ticketHash[ticket.employeeid][ticket.ciecacode][ticket.rate] + ticket.productivehrs;
});
+
return ticketHash;
}
+exports.BuildPayoutDetails = BuildPayoutDetails;
exports.CalculateExpectedHoursForJob = CalculateExpectedHoursForJob;
exports.CalculateTicketsHoursForJob = CalculateTicketsHoursForJob;
+exports.RoundPayrollHours = roundHours;
diff --git a/server/payroll/payroll.test.js b/server/payroll/payroll.test.js
new file mode 100644
index 000000000..1e3735df2
--- /dev/null
+++ b/server/payroll/payroll.test.js
@@ -0,0 +1,367 @@
+import { beforeEach, describe, expect, it, vi } from "vitest";
+import mockRequire from "mock-require";
+
+const logMock = vi.fn();
+
+let payAllModule;
+let claimTaskModule;
+
+const buildBaseJob = (overrides = {}) => ({
+ id: "job-1",
+ completed_tasks: [],
+ rate_laa: 100,
+ bodyshop: {
+ id: "shop-1",
+ md_responsibility_centers: {
+ defaults: {
+ costs: {
+ LAA: "Body"
+ }
+ }
+ },
+ md_tasks_presets: {
+ presets: []
+ },
+ employee_teams: []
+ },
+ joblines: [],
+ timetickets: [],
+ ...overrides
+});
+
+const buildReqRes = ({ job, body = {}, userEmail = "payroll@example.com" }) => {
+ const client = {
+ setHeaders: vi.fn().mockReturnThis(),
+ request: vi.fn().mockResolvedValueOnce({ jobs_by_pk: job })
+ };
+
+ const req = {
+ body: {
+ jobid: job.id,
+ ...body
+ },
+ user: {
+ email: userEmail
+ },
+ BearerToken: "Bearer test",
+ userGraphQLClient: client
+ };
+
+ const res = {
+ json: vi.fn(),
+ status: vi.fn().mockReturnThis()
+ };
+
+ return { client, req, res };
+};
+
+beforeEach(() => {
+ vi.resetModules();
+ vi.clearAllMocks();
+ mockRequire.stopAll();
+ mockRequire("../utils/logger", { log: logMock });
+ payAllModule = require("./pay-all");
+ claimTaskModule = require("./claim-task");
+});
+
+describe("payroll payout helpers", () => {
+ it("defaults team members to hourly payout when no payout method is stored", () => {
+ const { effectiveRate, payoutContext } = payAllModule.BuildPayoutDetails(
+ {},
+ {
+ labor_rates: {
+ LAA: 27.5
+ },
+ employee: {
+ id: "emp-1"
+ }
+ },
+ "LAA"
+ );
+
+ expect(effectiveRate).toBe(27.5);
+ expect(payoutContext).toEqual(
+ expect.objectContaining({
+ payout_type: "hourly",
+ payout_method: "hourly",
+ cut_percent_applied: null,
+ source_labor_rate: null,
+ source_labor_type: "LAA",
+ effective_rate: 27.5
+ })
+ );
+ });
+
+ it("calculates commission payout rates from the raw job labor sale rate", () => {
+ const { effectiveRate, payoutContext } = payAllModule.BuildPayoutDetails(
+ {
+ rate_laa: 120
+ },
+ {
+ payout_method: "commission",
+ commission_rates: {
+ LAA: 35
+ },
+ employee: {
+ id: "emp-1"
+ }
+ },
+ "LAA"
+ );
+
+ expect(effectiveRate).toBe(42);
+ expect(payoutContext).toEqual(
+ expect.objectContaining({
+ payout_type: "cut",
+ payout_method: "commission",
+ cut_percent_applied: 35,
+ source_labor_rate: 120,
+ source_labor_type: "LAA",
+ effective_rate: 42
+ })
+ );
+ });
+
+ it("uses Dinero half-even rounding for stored hourly rates", () => {
+ const { effectiveRate, payoutContext } = payAllModule.BuildPayoutDetails(
+ {},
+ {
+ labor_rates: {
+ LAA: 10.005
+ },
+ employee: {
+ id: "emp-1"
+ }
+ },
+ "LAA"
+ );
+
+ expect(effectiveRate).toBe(10);
+ expect(payoutContext.effective_rate).toBe(10);
+ });
+
+ it("throws a useful error when commission configuration is incomplete", () => {
+ expect(() =>
+ payAllModule.BuildPayoutDetails(
+ {
+ rate_laa: 100
+ },
+ {
+ payout_method: "commission",
+ commission_rates: {},
+ employee: {
+ first_name: "Jane",
+ last_name: "Doe"
+ }
+ },
+ "LAA"
+ )
+ ).toThrow("Missing commission percent for Jane Doe on labor type LAA.");
+ });
+});
+
+describe("payroll routes", () => {
+ it("aggregates claimed hours across prior ticket rates and inserts the remaining delta at the current rate", async () => {
+ const job = buildBaseJob({
+ bodyshop: {
+ id: "shop-1",
+ md_responsibility_centers: {
+ defaults: {
+ costs: {
+ LAA: "Body"
+ }
+ }
+ },
+ md_tasks_presets: {
+ presets: []
+ },
+ employee_teams: [
+ {
+ id: "team-1",
+ employee_team_members: [
+ {
+ percentage: 100,
+ payout_method: "commission",
+ commission_rates: {
+ LAA: 40
+ },
+ labor_rates: {
+ LAA: 30
+ },
+ employee: {
+ id: "emp-1",
+ first_name: "Jane",
+ last_name: "Doe"
+ }
+ }
+ ]
+ }
+ ]
+ },
+ joblines: [
+ {
+ mod_lbr_ty: "LAA",
+ mod_lb_hrs: 10,
+ assigned_team: "team-1",
+ convertedtolbr: false
+ }
+ ],
+ timetickets: [
+ {
+ employeeid: "emp-1",
+ ciecacode: "LAA",
+ productivehrs: 2,
+ rate: 30,
+ payout_context: {
+ payout_method: "hourly"
+ }
+ },
+ {
+ employeeid: "emp-1",
+ ciecacode: "LAA",
+ productivehrs: 3,
+ rate: 35,
+ payout_context: {
+ payout_method: "commission"
+ }
+ }
+ ]
+ });
+
+ const { client, req, res } = buildReqRes({ job });
+ client.request.mockResolvedValueOnce({ insert_timetickets: { affected_rows: 1 } });
+
+ await payAllModule.payall(req, res);
+
+ expect(client.request).toHaveBeenCalledTimes(2);
+
+ const insertedTickets = client.request.mock.calls[1][1].timetickets;
+ expect(insertedTickets).toHaveLength(1);
+ expect(insertedTickets[0]).toEqual(
+ expect.objectContaining({
+ task_name: "Pay All",
+ employeeid: "emp-1",
+ productivehrs: 5,
+ rate: 40,
+ ciecacode: "LAA",
+ cost_center: "Body",
+ created_by: "payroll@example.com"
+ })
+ );
+ expect(insertedTickets[0].payout_context).toEqual(
+ expect.objectContaining({
+ payout_method: "commission",
+ cut_percent_applied: 40,
+ source_labor_rate: 100,
+ generated_from: "payall",
+ task_name: "Pay All",
+ used_ticket_fallback: false
+ })
+ );
+ expect(res.json).toHaveBeenCalledWith(insertedTickets);
+ });
+
+ it("rejects duplicate claim-task submissions for completed presets", async () => {
+ const job = buildBaseJob({
+ completed_tasks: [{ name: "Disassembly" }],
+ bodyshop: {
+ id: "shop-1",
+ md_responsibility_centers: {
+ defaults: {
+ costs: {
+ LAA: "Body"
+ }
+ }
+ },
+ md_tasks_presets: {
+ presets: [
+ {
+ name: "Disassembly",
+ hourstype: ["LAA"],
+ percent: 50,
+ nextstatus: "In Progress",
+ memo: "Flag disassembly"
+ }
+ ]
+ },
+ employee_teams: []
+ }
+ });
+
+ const { client, req, res } = buildReqRes({
+ job,
+ body: {
+ task: "Disassembly",
+ calculateOnly: false,
+ employee: {
+ name: "Jane Doe",
+ employeeid: "emp-1"
+ }
+ }
+ });
+
+ await claimTaskModule.claimtask(req, res);
+
+ expect(client.request).toHaveBeenCalledTimes(1);
+ expect(res.status).toHaveBeenCalledWith(400);
+ expect(res.json).toHaveBeenCalledWith({
+ success: false,
+ error: "Provided task preset has already been completed for this job."
+ });
+ });
+
+ it("rejects claim-task when task presets over-allocate the same labor type", async () => {
+ const job = buildBaseJob({
+ bodyshop: {
+ id: "shop-1",
+ md_responsibility_centers: {
+ defaults: {
+ costs: {
+ LAA: "Body"
+ }
+ }
+ },
+ md_tasks_presets: {
+ presets: [
+ {
+ name: "Body Prep",
+ hourstype: ["LAA"],
+ percent: 60,
+ nextstatus: "Prep",
+ memo: "Prep body work"
+ },
+ {
+ name: "Body Prime",
+ hourstype: ["LAA"],
+ percent: 50,
+ nextstatus: "Prime",
+ memo: "Prime body work"
+ }
+ ]
+ },
+ employee_teams: []
+ }
+ });
+
+ const { client, req, res } = buildReqRes({
+ job,
+ body: {
+ task: "Body Prep",
+ calculateOnly: true,
+ employee: {
+ name: "Jane Doe",
+ employeeid: "emp-1"
+ }
+ }
+ });
+
+ await claimTaskModule.claimtask(req, res);
+
+ expect(client.request).toHaveBeenCalledTimes(1);
+ expect(res.status).toHaveBeenCalledWith(400);
+ expect(res.json).toHaveBeenCalledWith({
+ success: false,
+ error: "Task preset percentages for labor type LAA total 110% and cannot exceed 100%."
+ });
+ });
+});