feature/IO-3587-Comission-Cut - Implement
This commit is contained in:
367
server/payroll/payroll.test.js
Normal file
367
server/payroll/payroll.test.js
Normal file
@@ -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%."
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user