import { beforeEach, describe, expect, it, vi } from "vitest"; import mockRequire from "mock-require"; const logMock = vi.fn(); let payAllModule; let claimTaskModule; let calculateTotalsModule; 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"); calculateTotalsModule = require("./calculate-totals"); }); 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."); }); it("supports commission boundary values of zero and one hundred percent", () => { const zeroPercent = payAllModule.BuildPayoutDetails( { rate_laa: 123.45 }, { payout_method: "commission", commission_rates: { LAA: 0 }, employee: { id: "emp-1" } }, "LAA" ); const fullPercent = payAllModule.BuildPayoutDetails( { rate_laa: 123.45 }, { payout_method: "commission", commission_rates: { LAA: 100 }, employee: { id: "emp-1" } }, "LAA" ); expect(zeroPercent.effectiveRate).toBe(0); expect(zeroPercent.payoutContext.cut_percent_applied).toBe(0); expect(fullPercent.effectiveRate).toBe(123.45); expect(fullPercent.payoutContext.cut_percent_applied).toBe(100); }); it("throws a useful error when the sale rate for a commission labor type is missing", () => { expect(() => payAllModule.BuildPayoutDetails( {}, { payout_method: "commission", commission_rates: { LAA: 35 }, employee: { first_name: "Sam", last_name: "Painter" } }, "LAA" ) ).toThrow("Missing sale rate rate_laa for labor type LAA."); }); it("rejects commission percentages outside the allowed zero-to-one-hundred range", () => { expect(() => payAllModule.BuildPayoutDetails( { rate_laa: 100 }, { payout_method: "commission", commission_rates: { LAA: -5 }, employee: { first_name: "Alex", last_name: "Painter" } }, "LAA" ) ).toThrow("Commission percent for Alex Painter on labor type LAA must be between 0 and 100."); expect(() => payAllModule.BuildPayoutDetails( { rate_laa: 100 }, { payout_method: "commission", commission_rates: { LAA: 105 }, employee: { first_name: "Alex", last_name: "Painter" } }, "LAA" ) ).toThrow("Commission percent for Alex Painter on labor type LAA must be between 0 and 100."); }); }); 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("returns a validation failure when job lines still have unassigned hours", async () => { const job = buildBaseJob({ joblines: [ { mod_lbr_ty: "LAA", mod_lb_hrs: 3.5, assigned_team: null, convertedtolbr: false } ] }); const { client, req, res } = buildReqRes({ job }); await payAllModule.payall(req, res); expect(client.request).toHaveBeenCalledTimes(1); expect(res.json).toHaveBeenCalledWith({ success: false, error: "Not all hours have been assigned." }); }); it("creates separate pay-all tickets for mixed hourly and commission team members across labor types", async () => { const job = buildBaseJob({ rate_laa: 100, rate_lab: 80, bodyshop: { id: "shop-1", md_responsibility_centers: { defaults: { costs: { LAA: "Body", LAB: "Refinish" } } }, md_tasks_presets: { presets: [] }, employee_teams: [ { id: "team-1", employee_team_members: [ { percentage: 50, payout_method: "hourly", labor_rates: { LAA: 30, LAB: 25 }, employee: { id: "emp-hourly", first_name: "Hourly", last_name: "Tech" } }, { percentage: 50, payout_method: "commission", commission_rates: { LAA: 40, LAB: 50 }, labor_rates: { LAA: 0, LAB: 0 }, employee: { id: "emp-commission", first_name: "Commission", last_name: "Tech" } } ] } ] }, joblines: [ { mod_lbr_ty: "LAA", mod_lb_hrs: 4, assigned_team: "team-1", convertedtolbr: false }, { mod_lbr_ty: "LAB", mod_lb_hrs: 2, assigned_team: "team-1", convertedtolbr: false } ] }); const { client, req, res } = buildReqRes({ job }); client.request.mockResolvedValueOnce({ insert_timetickets: { affected_rows: 4 } }); await payAllModule.payall(req, res); expect(client.request).toHaveBeenCalledTimes(2); const insertedTickets = client.request.mock.calls[1][1].timetickets; expect(insertedTickets).toHaveLength(4); expect(insertedTickets).toEqual( expect.arrayContaining([ expect.objectContaining({ employeeid: "emp-hourly", ciecacode: "LAA", productivehrs: 2, rate: 30, cost_center: "Body", payout_context: expect.objectContaining({ payout_method: "hourly", payout_type: "hourly", effective_rate: 30 }) }), expect.objectContaining({ employeeid: "emp-hourly", ciecacode: "LAB", productivehrs: 1, rate: 25, cost_center: "Refinish", payout_context: expect.objectContaining({ payout_method: "hourly", payout_type: "hourly", effective_rate: 25 }) }), expect.objectContaining({ employeeid: "emp-commission", ciecacode: "LAA", productivehrs: 2, rate: 40, cost_center: "Body", payout_context: expect.objectContaining({ payout_method: "commission", payout_type: "cut", cut_percent_applied: 40, source_labor_rate: 100, effective_rate: 40 }) }), expect.objectContaining({ employeeid: "emp-commission", ciecacode: "LAB", productivehrs: 1, rate: 40, cost_center: "Refinish", payout_context: expect.objectContaining({ payout_method: "commission", payout_type: "cut", cut_percent_applied: 50, source_labor_rate: 80, effective_rate: 40 }) }) ]) ); expect(res.json).toHaveBeenCalledWith(insertedTickets); }); it("creates a negative pay-all adjustment at the current commission rate when the remaining expected hours drop below prior claimed hours", async () => { const job = buildBaseJob({ rate_laa: 120, 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 }, employee: { id: "emp-1", first_name: "Current", last_name: "Tech" } } ] } ] }, joblines: [ { mod_lbr_ty: "LAA", mod_lb_hrs: 4, assigned_team: "team-1", convertedtolbr: false } ], timetickets: [ { employeeid: "emp-1", ciecacode: "LAA", productivehrs: 6, rate: 30, payout_context: { payout_method: "hourly", payout_type: "hourly", effective_rate: 30 } } ] }); const { client, req, res } = buildReqRes({ job }); client.request.mockResolvedValueOnce({ insert_timetickets: { affected_rows: 1 } }); await payAllModule.payall(req, res); const insertedTickets = client.request.mock.calls[1][1].timetickets; expect(insertedTickets).toHaveLength(1); expect(insertedTickets[0]).toEqual( expect.objectContaining({ employeeid: "emp-1", productivehrs: -2, rate: 48, ciecacode: "LAA", memo: "Adjust flagged hours per assignment. (payroll@example.com)" }) ); expect(insertedTickets[0].payout_context).toEqual( expect.objectContaining({ payout_method: "commission", payout_type: "cut", cut_percent_applied: 40, source_labor_rate: 120, effective_rate: 48, generated_from: "payall", used_ticket_fallback: false }) ); expect(res.json).toHaveBeenCalledWith(insertedTickets); }); it("uses the current commission sale rate for remaining hours when older commission tickets were created from a lower sale rate", async () => { const job = buildBaseJob({ rate_laa: 120, 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 }, employee: { id: "emp-1", first_name: "Current", last_name: "Tech" } } ] } ] }, joblines: [ { mod_lbr_ty: "LAA", mod_lb_hrs: 5, assigned_team: "team-1", convertedtolbr: false } ], timetickets: [ { employeeid: "emp-1", ciecacode: "LAA", productivehrs: 2, rate: 40, payout_context: { payout_method: "commission", payout_type: "cut", cut_percent_applied: 40, source_labor_rate: 100, effective_rate: 40 } } ] }); const { client, req, res } = buildReqRes({ job }); client.request.mockResolvedValueOnce({ insert_timetickets: { affected_rows: 1 } }); await payAllModule.payall(req, res); const insertedTickets = client.request.mock.calls[1][1].timetickets; expect(insertedTickets).toHaveLength(1); expect(insertedTickets[0]).toEqual( expect.objectContaining({ employeeid: "emp-1", productivehrs: 3, rate: 48, ciecacode: "LAA" }) ); expect(insertedTickets[0].payout_context).toEqual( expect.objectContaining({ payout_method: "commission", cut_percent_applied: 40, source_labor_rate: 120, effective_rate: 48, generated_from: "payall", 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("returns commission-aware claim-task previews and reports unassigned hours", async () => { const job = buildBaseJob({ rate_laa: 120, rate_lab: 80, bodyshop: { id: "shop-1", md_responsibility_centers: { defaults: { costs: { LAA: "Body", LAB: "Refinish" } } }, md_tasks_presets: { presets: [ { name: "Body Prep", hourstype: ["LAA", "LAB"], percent: 50, nextstatus: "Prep", memo: "Prep body work" } ] }, employee_teams: [ { id: "team-1", employee_team_members: [ { percentage: 50, payout_method: "hourly", labor_rates: { LAA: 30, LAB: 25 }, employee: { id: "emp-hourly", first_name: "Hourly", last_name: "Tech" } }, { percentage: 50, payout_method: "commission", commission_rates: { LAA: 40, LAB: 50 }, employee: { id: "emp-commission", first_name: "Commission", last_name: "Tech" } } ] } ] }, joblines: [ { mod_lbr_ty: "LAA", mod_lb_hrs: 4, assigned_team: "team-1", convertedtolbr: false }, { mod_lbr_ty: "LAB", mod_lb_hrs: 1.5, assigned_team: null, convertedtolbr: false } ] }); const { client, req, res } = buildReqRes({ job, body: { task: "Body Prep", calculateOnly: true, employee: { name: "Payroll Manager" } } }); await claimTaskModule.claimtask(req, res); expect(client.request).toHaveBeenCalledTimes(1); expect(res.json).toHaveBeenCalledWith({ unassignedHours: 1.5, ticketsToInsert: expect.arrayContaining([ expect.objectContaining({ task_name: "Body Prep", employeeid: "emp-hourly", productivehrs: 1, rate: 30, ciecacode: "LAA", created_by: "Payroll Manager", payout_context: expect.objectContaining({ payout_method: "hourly", payout_type: "hourly", generated_from: "claimtask", task_name: "Body Prep" }) }), expect.objectContaining({ task_name: "Body Prep", employeeid: "emp-commission", productivehrs: 1, rate: 48, ciecacode: "LAA", created_by: "Payroll Manager", payout_context: expect.objectContaining({ payout_method: "commission", payout_type: "cut", cut_percent_applied: 40, source_labor_rate: 120, generated_from: "claimtask", task_name: "Body Prep" }) }) ]) }); }); 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." }); }); it("locks in the current enhanced-payroll behavior of ignoring lbr_adjustments when calculating labor totals", async () => { const job = buildBaseJob({ lbr_adjustments: { LAA: 2.5 }, 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 }, employee: { id: "emp-1", first_name: "Current", last_name: "Tech" } } ] } ] }, joblines: [ { mod_lbr_ty: "LAA", mod_lb_hrs: 4, assigned_team: "team-1", convertedtolbr: false } ], timetickets: [ { employeeid: "emp-1", ciecacode: "LAA", productivehrs: 1, rate: 48, payout_context: { payout_method: "commission" } } ] }); const { client, req, res } = buildReqRes({ job }); await calculateTotalsModule.calculatelabor(req, res); expect(client.request).toHaveBeenCalledTimes(1); expect(res.json).toHaveBeenCalledWith([ { employeeid: "emp-1", rate: 40, mod_lbr_ty: "LAA", expectedHours: 4, claimedHours: 1 } ]); }); });