Files
bodyshop/server/payroll/payroll.test.js

466 lines
11 KiB
JavaScript

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.");
});
it("throws a useful error when an hourly payout rate is missing", () => {
expect(() =>
payAllModule.BuildPayoutDetails(
{},
{
labor_rates: {},
employee: {
first_name: "John",
last_name: "Smith"
}
},
"LAB"
)
).toThrow("Missing hourly payout rate for John Smith on labor type LAB.");
});
});
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%."
});
});
it("rejects claim-task when an assigned team member is missing the hourly rate for the selected labor type", async () => {
const job = buildBaseJob({
bodyshop: {
id: "shop-1",
md_responsibility_centers: {
defaults: {
costs: {
LAB: "Body"
}
}
},
md_tasks_presets: {
presets: [
{
name: "Teardown",
hourstype: ["LAB"],
percent: 100,
nextstatus: "In Progress",
memo: "Teardown"
}
]
},
employee_teams: [
{
id: "team-1",
employee_team_members: [
{
percentage: 50,
labor_rates: {
LAB: 45
},
employee: {
id: "emp-1",
first_name: "Configured",
last_name: "Tech"
}
},
{
percentage: 50,
labor_rates: {},
employee: {
id: "emp-2",
first_name: "Missing",
last_name: "Rate"
}
}
]
}
]
},
joblines: [
{
mod_lbr_ty: "LAB",
mod_lb_hrs: 4.4,
assigned_team: "team-1",
convertedtolbr: false
}
]
});
const { client, req, res } = buildReqRes({
job,
body: {
task: "Teardown",
calculateOnly: true,
employee: {
name: "Dave",
email: "dave@rome.test"
}
}
});
await claimTaskModule.claimtask(req, res);
expect(client.request).toHaveBeenCalledTimes(1);
expect(res.status).toHaveBeenCalledWith(400);
expect(res.json).toHaveBeenCalledWith({
success: false,
error: "Missing hourly payout rate for Missing Rate on labor type LAB."
});
});
});