- technician-service.ts: createTechnicianProfile, updateTechnicianProfile, getTechnicianProfile, getTechnicianProfileByUserId, listTechnicians - compensation-service.ts: getCompensationSummary (all 3 models), getTechnicianCompensationDetail (job-by-job) - 6 API routes: GET/POST /api/technicians, GET/PUT /api/technicians/[id], GET /api/technicians/[id]/compensation, GET/POST /api/job-type-rates, PUT /api/job-type-rates/[id], GET /api/reports/compensation - Added TechnicianProfile and JobTypeRate to CASL AppSubjects; OFFICE_STAFF read access - 27 integration tests: all 3 models, missing rate defaults to 0, only COMPLETED count, date range filter, summary+detail reports, cross-tenant isolation — all green
827 lines
28 KiB
TypeScript
827 lines
28 KiB
TypeScript
/**
|
|
* Compensation Service Integration Tests
|
|
*
|
|
* Tests all three compensation models and edge cases:
|
|
* - PER_JOB: sum of rates for completed jobs
|
|
* - SALARY: monthlySalary only (no per-job bonuses)
|
|
* - HYBRID: monthlySalary + sum of rates
|
|
* - Missing job type rate defaults to 0 (not error)
|
|
* - Only COMPLETED jobs count (PENDING, IN_PROGRESS, CANCELLED excluded)
|
|
* - CANCELLED jobs excluded
|
|
* - Date range filter (jobs outside period not counted)
|
|
* - Summary returns all technicians with correct totals
|
|
* - Detail returns job-by-job breakdown
|
|
* - Technician with no completed jobs = 0 job bonus
|
|
* - Cross-tenant isolation
|
|
*
|
|
* CLEANUP ORDER:
|
|
* jobOrders -> tickets -> ticketCategories -> jobTypeRates -> technicianProfiles
|
|
* -> zones -> subscribers -> servicePlans -> users -> tenant
|
|
*
|
|
* These tests require a live PostgreSQL database connection.
|
|
*/
|
|
|
|
import { prisma } from "@/lib/prisma";
|
|
import { withTenantContext } from "@/lib/prisma-tenant";
|
|
import { BillingType, CompensationModel, JobOrderStatus, TenantStatus } from "@prisma/client";
|
|
import {
|
|
createTechnicianProfile,
|
|
listTechnicians,
|
|
updateTechnicianProfile,
|
|
getTechnicianProfile,
|
|
getTechnicianProfileByUserId,
|
|
} from "@/lib/services/technician-service";
|
|
import {
|
|
getCompensationSummary,
|
|
getTechnicianCompensationDetail,
|
|
} from "@/lib/services/compensation-service";
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Shared state
|
|
// ---------------------------------------------------------------------------
|
|
|
|
const TS = Date.now();
|
|
|
|
let tenantId: string;
|
|
let tenantBId: string;
|
|
let adminUserId: string;
|
|
|
|
// Technician users
|
|
let techAUserId: string; // HYBRID model
|
|
let techBUserId: string; // PER_JOB model
|
|
let techCUserId: string; // SALARY model
|
|
|
|
// Profiles
|
|
let profileAId: string;
|
|
let profileBId: string;
|
|
let profileCId: string;
|
|
|
|
// Job type rates
|
|
let installRateId: string;
|
|
let repairRateId: string;
|
|
|
|
// Subscriber + ticket for creating job orders
|
|
let subscriberId: string;
|
|
let ticketId: string;
|
|
let ticketCategoryId: string;
|
|
let planId: string;
|
|
let zoneId: string;
|
|
|
|
// Counters for unique order numbers
|
|
let joCounter = 0;
|
|
let ticketCounter = 0;
|
|
|
|
function tA() {
|
|
return withTenantContext(tenantId);
|
|
}
|
|
|
|
function tB() {
|
|
return withTenantContext(tenantBId);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Helpers
|
|
// ---------------------------------------------------------------------------
|
|
|
|
async function createJobOrder(
|
|
assignedToId: string,
|
|
jobType: string,
|
|
status: JobOrderStatus,
|
|
completedAt?: Date
|
|
) {
|
|
joCounter++;
|
|
const orderNumber = `JO-COMP-${TS}-${joCounter}`;
|
|
|
|
const jo = await prisma.jobOrder.create({
|
|
data: {
|
|
tenantId,
|
|
orderNumber,
|
|
ticketId,
|
|
jobType,
|
|
description: `Test job ${joCounter}`,
|
|
assignedToId,
|
|
createdById: adminUserId,
|
|
status,
|
|
scheduledDate: null,
|
|
startedAt: status !== JobOrderStatus.PENDING ? new Date() : null,
|
|
completedAt: completedAt ?? (status === JobOrderStatus.COMPLETED ? new Date() : null),
|
|
cancelledAt: status === JobOrderStatus.CANCELLED ? new Date() : null,
|
|
outcomeNotes: status === JobOrderStatus.COMPLETED ? "Work complete" : null,
|
|
},
|
|
});
|
|
|
|
return jo;
|
|
}
|
|
|
|
async function createTicket(): Promise<string> {
|
|
ticketCounter++;
|
|
const t = await prisma.ticket.create({
|
|
data: {
|
|
tenantId,
|
|
ticketNumber: `TKT-COMP-${TS}-${ticketCounter}`,
|
|
subject: `Test Ticket ${ticketCounter}`,
|
|
description: "Test",
|
|
categoryId: ticketCategoryId,
|
|
priority: "MEDIUM",
|
|
status: "OPEN",
|
|
source: "STAFF",
|
|
createdById: adminUserId,
|
|
},
|
|
});
|
|
return t.id;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Setup
|
|
// ---------------------------------------------------------------------------
|
|
|
|
beforeAll(async () => {
|
|
// Tenant A
|
|
const tenant = await prisma.tenant.create({
|
|
data: {
|
|
name: `Compensation Test Tenant ${TS}`,
|
|
slug: `comp-${TS}`,
|
|
ownerEmail: `comp-${TS}@test.example`,
|
|
status: TenantStatus.ACTIVE,
|
|
},
|
|
});
|
|
tenantId = tenant.id;
|
|
|
|
// Tenant B (for isolation)
|
|
const tenantB = await prisma.tenant.create({
|
|
data: {
|
|
name: `Compensation Test Tenant B ${TS}`,
|
|
slug: `comp-b-${TS}`,
|
|
ownerEmail: `comp-b-${TS}@test.example`,
|
|
status: TenantStatus.ACTIVE,
|
|
},
|
|
});
|
|
tenantBId = tenantB.id;
|
|
|
|
// Admin user
|
|
const admin = await prisma.user.create({
|
|
data: {
|
|
tenantId,
|
|
email: `comp-admin-${TS}@test.example`,
|
|
passwordHash: "hashed",
|
|
firstName: "Comp",
|
|
lastName: "Admin",
|
|
roles: ["ADMIN"],
|
|
isActive: true,
|
|
},
|
|
});
|
|
adminUserId = admin.id;
|
|
|
|
// Zone
|
|
const zone = await prisma.zone.create({
|
|
data: {
|
|
tenantId,
|
|
name: `Zone Comp ${TS}`,
|
|
isActive: true,
|
|
},
|
|
});
|
|
zoneId = zone.id;
|
|
|
|
// Service plan
|
|
const plan = await prisma.servicePlan.create({
|
|
data: {
|
|
tenantId,
|
|
name: `Comp Plan ${TS}`,
|
|
speed: "50 Mbps",
|
|
monthlyPrice: 49.99,
|
|
billingType: BillingType.POSTPAID,
|
|
isActive: true,
|
|
},
|
|
});
|
|
planId = plan.id;
|
|
|
|
// Subscriber
|
|
const sub = await prisma.subscriber.create({
|
|
data: {
|
|
tenantId,
|
|
accountNumber: `COMP-SUB-${TS}`,
|
|
firstName: "Comp",
|
|
lastName: "Subscriber",
|
|
address: "123 Comp St",
|
|
servicePlanId: planId,
|
|
status: "ACTIVE",
|
|
billingDay: 15,
|
|
creditBalance: 0,
|
|
},
|
|
});
|
|
subscriberId = sub.id;
|
|
|
|
// Ticket category
|
|
const cat = await prisma.ticketCategory.create({
|
|
data: {
|
|
tenantId,
|
|
name: `Comp Category ${TS}`,
|
|
isActive: true,
|
|
},
|
|
});
|
|
ticketCategoryId = cat.id;
|
|
|
|
// Initial ticket
|
|
ticketId = await createTicket();
|
|
|
|
// Technician users
|
|
const techA = await prisma.user.create({
|
|
data: {
|
|
tenantId,
|
|
email: `comp-tech-a-${TS}@test.example`,
|
|
passwordHash: "hashed",
|
|
firstName: "TechA",
|
|
lastName: "Hybrid",
|
|
roles: ["TECHNICIAN"],
|
|
isActive: true,
|
|
},
|
|
});
|
|
techAUserId = techA.id;
|
|
|
|
const techB = await prisma.user.create({
|
|
data: {
|
|
tenantId,
|
|
email: `comp-tech-b-${TS}@test.example`,
|
|
passwordHash: "hashed",
|
|
firstName: "TechB",
|
|
lastName: "PerJob",
|
|
roles: ["TECHNICIAN"],
|
|
isActive: true,
|
|
},
|
|
});
|
|
techBUserId = techB.id;
|
|
|
|
const techC = await prisma.user.create({
|
|
data: {
|
|
tenantId,
|
|
email: `comp-tech-c-${TS}@test.example`,
|
|
passwordHash: "hashed",
|
|
firstName: "TechC",
|
|
lastName: "Salary",
|
|
roles: ["TECHNICIAN"],
|
|
isActive: true,
|
|
},
|
|
});
|
|
techCUserId = techC.id;
|
|
|
|
// Create technician profiles
|
|
const profileA = await createTechnicianProfile(tA(), tenantId, {
|
|
userId: techAUserId,
|
|
compensationModel: CompensationModel.HYBRID,
|
|
monthlySalary: 2000,
|
|
skills: ["fiber", "installation"],
|
|
zoneId,
|
|
});
|
|
profileAId = profileA.id;
|
|
|
|
const profileB = await createTechnicianProfile(tA(), tenantId, {
|
|
userId: techBUserId,
|
|
compensationModel: CompensationModel.PER_JOB,
|
|
skills: ["repair"],
|
|
});
|
|
profileBId = profileB.id;
|
|
|
|
const profileC = await createTechnicianProfile(tA(), tenantId, {
|
|
userId: techCUserId,
|
|
compensationModel: CompensationModel.SALARY,
|
|
monthlySalary: 3500,
|
|
});
|
|
profileCId = profileC.id;
|
|
|
|
// Job type rates
|
|
const installRate = await prisma.jobTypeRate.create({
|
|
data: {
|
|
tenantId,
|
|
jobType: "Installation",
|
|
rate: 150,
|
|
isActive: true,
|
|
},
|
|
});
|
|
installRateId = installRate.id;
|
|
|
|
const repairRate = await prisma.jobTypeRate.create({
|
|
data: {
|
|
tenantId,
|
|
jobType: "Repair",
|
|
rate: 75,
|
|
isActive: true,
|
|
},
|
|
});
|
|
repairRateId = repairRate.id;
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Teardown
|
|
// ---------------------------------------------------------------------------
|
|
|
|
afterAll(async () => {
|
|
for (const tid of [tenantId, tenantBId]) {
|
|
if (!tid) continue;
|
|
// 1. Job orders (FK to tickets and users)
|
|
await prisma.jobOrder.deleteMany({ where: { tenantId: tid } }).catch(() => {});
|
|
// 2. Tickets
|
|
await prisma.ticket.deleteMany({ where: { tenantId: tid } }).catch(() => {});
|
|
// 3. Ticket categories
|
|
await prisma.ticketCategory.deleteMany({ where: { tenantId: tid } }).catch(() => {});
|
|
// 4. Job type rates
|
|
await prisma.jobTypeRate.deleteMany({ where: { tenantId: tid } }).catch(() => {});
|
|
// 5. Technician profiles
|
|
await prisma.technicianProfile.deleteMany({ where: { tenantId: tid } }).catch(() => {});
|
|
// 6. Zone assignments
|
|
await prisma.zoneAssignment.deleteMany({ where: { tenantId: tid } }).catch(() => {});
|
|
// 7. Subscribers
|
|
await prisma.subscriber.deleteMany({ where: { tenantId: tid } }).catch(() => {});
|
|
// 8. Zones
|
|
await prisma.zone.deleteMany({ where: { tenantId: tid } }).catch(() => {});
|
|
// 9. Service plans
|
|
await prisma.servicePlan.deleteMany({ where: { tenantId: tid } }).catch(() => {});
|
|
// 10. Users
|
|
await prisma.user.deleteMany({ where: { tenantId: tid } }).catch(() => {});
|
|
// 11. Tenant
|
|
await prisma.tenant.delete({ where: { id: tid } }).catch(() => {});
|
|
}
|
|
await prisma.$disconnect();
|
|
});
|
|
|
|
// ===========================================================================
|
|
// TECHNICIAN SERVICE TESTS
|
|
// ===========================================================================
|
|
|
|
describe("createTechnicianProfile", () => {
|
|
it("creates a profile for a TECHNICIAN user", async () => {
|
|
const profile = await getTechnicianProfile(tA(), profileAId);
|
|
expect(profile).not.toBeNull();
|
|
expect(profile!.userId).toBe(techAUserId);
|
|
expect(profile!.compensationModel).toBe(CompensationModel.HYBRID);
|
|
expect(Number(profile!.monthlySalary)).toBe(2000);
|
|
expect(profile!.skills).toContain("fiber");
|
|
expect(profile!.zoneId).toBe(zoneId);
|
|
});
|
|
|
|
it("throws if user does not have TECHNICIAN role", async () => {
|
|
await expect(
|
|
createTechnicianProfile(tA(), tenantId, {
|
|
userId: adminUserId,
|
|
compensationModel: CompensationModel.PER_JOB,
|
|
})
|
|
).rejects.toThrow(/TECHNICIAN role/i);
|
|
});
|
|
|
|
it("throws if profile already exists for user", async () => {
|
|
await expect(
|
|
createTechnicianProfile(tA(), tenantId, {
|
|
userId: techAUserId,
|
|
compensationModel: CompensationModel.PER_JOB,
|
|
})
|
|
).rejects.toThrow(/already exists/i);
|
|
});
|
|
|
|
it("throws if SALARY model has no monthlySalary", async () => {
|
|
// Create a separate technician user for this test
|
|
const extraTech = await prisma.user.create({
|
|
data: {
|
|
tenantId,
|
|
email: `comp-extra-${TS}@test.example`,
|
|
passwordHash: "hashed",
|
|
firstName: "Extra",
|
|
lastName: "Tech",
|
|
roles: ["TECHNICIAN"],
|
|
isActive: true,
|
|
},
|
|
});
|
|
|
|
await expect(
|
|
createTechnicianProfile(tA(), tenantId, {
|
|
userId: extraTech.id,
|
|
compensationModel: CompensationModel.SALARY,
|
|
// No monthlySalary
|
|
})
|
|
).rejects.toThrow(/monthlySalary is required/i);
|
|
|
|
// Cleanup
|
|
await prisma.user.delete({ where: { id: extraTech.id } }).catch(() => {});
|
|
});
|
|
});
|
|
|
|
describe("listTechnicians", () => {
|
|
it("returns all technicians", async () => {
|
|
const result = await listTechnicians(tA());
|
|
// Should have at least our 3 technicians
|
|
expect(result.total).toBeGreaterThanOrEqual(3);
|
|
const profileIds = result.profiles.map((p: { id: string }) => p.id);
|
|
expect(profileIds).toContain(profileAId);
|
|
expect(profileIds).toContain(profileBId);
|
|
expect(profileIds).toContain(profileCId);
|
|
});
|
|
|
|
it("filters by activeOnly", async () => {
|
|
// All profiles are active by default
|
|
const result = await listTechnicians(tA(), { activeOnly: true });
|
|
expect(result.total).toBeGreaterThanOrEqual(3);
|
|
});
|
|
|
|
it("filters by zoneId", async () => {
|
|
const result = await listTechnicians(tA(), { zoneId });
|
|
const profileIds = result.profiles.map((p: { id: string }) => p.id);
|
|
expect(profileIds).toContain(profileAId); // techA is in this zone
|
|
expect(profileIds).not.toContain(profileBId); // techB has no zone
|
|
});
|
|
});
|
|
|
|
describe("updateTechnicianProfile", () => {
|
|
it("updates phone and skills", async () => {
|
|
const updated = await updateTechnicianProfile(tA(), profileBId, {
|
|
phone: "555-1234",
|
|
skills: ["repair", "maintenance"],
|
|
});
|
|
expect(updated.phone).toBe("555-1234");
|
|
expect(updated.skills).toContain("maintenance");
|
|
});
|
|
|
|
it("deactivates a profile", async () => {
|
|
await updateTechnicianProfile(tA(), profileBId, { isActive: false });
|
|
const profile = await getTechnicianProfile(tA(), profileBId);
|
|
expect(profile!.isActive).toBe(false);
|
|
// Reactivate for other tests
|
|
await updateTechnicianProfile(tA(), profileBId, { isActive: true });
|
|
});
|
|
});
|
|
|
|
describe("getTechnicianProfileByUserId", () => {
|
|
it("finds profile by userId", async () => {
|
|
const profile = await getTechnicianProfileByUserId(tA(), techCUserId);
|
|
expect(profile).not.toBeNull();
|
|
expect(profile!.id).toBe(profileCId);
|
|
expect(profile!.compensationModel).toBe(CompensationModel.SALARY);
|
|
});
|
|
|
|
it("returns null for user without profile", async () => {
|
|
const profile = await getTechnicianProfileByUserId(tA(), adminUserId);
|
|
expect(profile).toBeNull();
|
|
});
|
|
});
|
|
|
|
// ===========================================================================
|
|
// COMPENSATION SERVICE TESTS
|
|
// ===========================================================================
|
|
|
|
describe("PER_JOB compensation model", () => {
|
|
it("sums rates for all completed jobs in period", async () => {
|
|
// TechB (PER_JOB): 2x Installation ($150 each) + 1x Repair ($75)
|
|
const periodStart = new Date("2026-02-01T00:00:00Z");
|
|
const periodEnd = new Date("2026-02-28T23:59:59Z");
|
|
|
|
await createJobOrder(techBUserId, "Installation", JobOrderStatus.COMPLETED, new Date("2026-02-10T10:00:00Z"));
|
|
await createJobOrder(techBUserId, "Installation", JobOrderStatus.COMPLETED, new Date("2026-02-15T10:00:00Z"));
|
|
await createJobOrder(techBUserId, "Repair", JobOrderStatus.COMPLETED, new Date("2026-02-20T10:00:00Z"));
|
|
|
|
const summary = await getCompensationSummary(tA(), { periodStart, periodEnd, technicianProfileId: profileBId });
|
|
const techSummary = summary.technicians.find((t: { technicianProfileId: string }) => t.technicianProfileId === profileBId);
|
|
|
|
expect(techSummary).toBeDefined();
|
|
expect(Number(techSummary!.baseSalary)).toBe(0);
|
|
expect(Number(techSummary!.jobBonusTotal)).toBe(375); // 150 + 150 + 75
|
|
expect(Number(techSummary!.totalCompensation)).toBe(375);
|
|
expect(techSummary!.completedJobCount).toBe(3);
|
|
});
|
|
});
|
|
|
|
describe("SALARY compensation model", () => {
|
|
it("returns monthlySalary only, regardless of completed jobs", async () => {
|
|
const periodStart = new Date("2026-02-01T00:00:00Z");
|
|
const periodEnd = new Date("2026-02-28T23:59:59Z");
|
|
|
|
// Create a completed job for techC — but SALARY model should ignore it
|
|
await createJobOrder(techCUserId, "Installation", JobOrderStatus.COMPLETED, new Date("2026-02-10T10:00:00Z"));
|
|
|
|
const summary = await getCompensationSummary(tA(), { periodStart, periodEnd, technicianProfileId: profileCId });
|
|
const techSummary = summary.technicians.find((t: { technicianProfileId: string }) => t.technicianProfileId === profileCId);
|
|
|
|
expect(techSummary).toBeDefined();
|
|
expect(Number(techSummary!.baseSalary)).toBe(3500);
|
|
expect(Number(techSummary!.jobBonusTotal)).toBe(0); // SALARY model has no per-job bonuses
|
|
expect(Number(techSummary!.totalCompensation)).toBe(3500);
|
|
});
|
|
});
|
|
|
|
describe("HYBRID compensation model", () => {
|
|
it("sums base salary + per-job rates", async () => {
|
|
const periodStart = new Date("2026-02-01T00:00:00Z");
|
|
const periodEnd = new Date("2026-02-28T23:59:59Z");
|
|
|
|
// TechA (HYBRID, $2000 salary): 1x Installation ($150)
|
|
await createJobOrder(techAUserId, "Installation", JobOrderStatus.COMPLETED, new Date("2026-02-12T10:00:00Z"));
|
|
|
|
const summary = await getCompensationSummary(tA(), { periodStart, periodEnd, technicianProfileId: profileAId });
|
|
const techSummary = summary.technicians.find((t: { technicianProfileId: string }) => t.technicianProfileId === profileAId);
|
|
|
|
expect(techSummary).toBeDefined();
|
|
expect(Number(techSummary!.baseSalary)).toBe(2000);
|
|
expect(Number(techSummary!.jobBonusTotal)).toBeGreaterThanOrEqual(150); // at least 1 installation
|
|
expect(Number(techSummary!.totalCompensation)).toBe(
|
|
Number(techSummary!.baseSalary) + Number(techSummary!.jobBonusTotal)
|
|
);
|
|
});
|
|
});
|
|
|
|
describe("Missing job type rate defaults to 0", () => {
|
|
it("returns 0 bonus for unknown job type (not an error)", async () => {
|
|
const periodStart = new Date("2026-03-01T00:00:00Z");
|
|
const periodEnd = new Date("2026-03-31T23:59:59Z");
|
|
|
|
// Create job with a type that has NO rate configured
|
|
await createJobOrder(techBUserId, "UnknownJobType-no-rate", JobOrderStatus.COMPLETED, new Date("2026-03-05T10:00:00Z"));
|
|
|
|
const summary = await getCompensationSummary(tA(), { periodStart, periodEnd, technicianProfileId: profileBId });
|
|
const techSummary = summary.technicians.find((t: { technicianProfileId: string }) => t.technicianProfileId === profileBId);
|
|
|
|
// Should not throw; unknown job type = 0 bonus
|
|
expect(techSummary).toBeDefined();
|
|
// The unknown job type contributes $0
|
|
const unknownJobDetail = await getTechnicianCompensationDetail(tA(), {
|
|
technicianProfileId: profileBId,
|
|
periodStart,
|
|
periodEnd,
|
|
});
|
|
const unknownJob = unknownJobDetail.jobs.find((j: { jobType: string }) => j.jobType === "UnknownJobType-no-rate");
|
|
expect(unknownJob).toBeDefined();
|
|
expect(Number(unknownJob!.rate)).toBe(0);
|
|
});
|
|
});
|
|
|
|
describe("Only COMPLETED jobs count", () => {
|
|
it("excludes PENDING, IN_PROGRESS, and CANCELLED jobs", async () => {
|
|
const periodStart = new Date("2026-03-01T00:00:00Z");
|
|
const periodEnd = new Date("2026-03-31T23:59:59Z");
|
|
|
|
// Only the COMPLETED job at the start (from previous test) and explicitly add more here
|
|
// Add PENDING, IN_PROGRESS, CANCELLED jobs — none should count
|
|
await createJobOrder(techBUserId, "Installation", JobOrderStatus.PENDING);
|
|
await createJobOrder(techBUserId, "Installation", JobOrderStatus.IN_PROGRESS);
|
|
await createJobOrder(techBUserId, "Installation", JobOrderStatus.CANCELLED);
|
|
|
|
const summary = await getCompensationSummary(tA(), { periodStart, periodEnd, technicianProfileId: profileBId });
|
|
const techSummary = summary.technicians.find((t: { technicianProfileId: string }) => t.technicianProfileId === profileBId);
|
|
|
|
// completedJobCount should only include COMPLETED jobs (the unknown type one from previous test)
|
|
// PENDING/IN_PROGRESS/CANCELLED are excluded
|
|
expect(techSummary).toBeDefined();
|
|
// Verify the count equals the number of COMPLETED jobs only
|
|
const allJobsInPeriod = await prisma.jobOrder.findMany({
|
|
where: {
|
|
tenantId,
|
|
assignedToId: techBUserId,
|
|
completedAt: { gte: periodStart, lte: periodEnd },
|
|
status: JobOrderStatus.COMPLETED,
|
|
},
|
|
});
|
|
expect(techSummary!.completedJobCount).toBe(allJobsInPeriod.length);
|
|
});
|
|
|
|
it("CANCELLED jobs do not generate bonuses", async () => {
|
|
const periodStart = new Date("2026-03-01T00:00:00Z");
|
|
const periodEnd = new Date("2026-03-31T23:59:59Z");
|
|
|
|
const detail = await getTechnicianCompensationDetail(tA(), {
|
|
technicianProfileId: profileBId,
|
|
periodStart,
|
|
periodEnd,
|
|
});
|
|
|
|
// All jobs in detail should be COMPLETED (by definition, as we only fetch COMPLETED)
|
|
for (const job of detail.jobs) {
|
|
expect(job.completedAt).not.toBeNull();
|
|
}
|
|
});
|
|
});
|
|
|
|
describe("Date range filter", () => {
|
|
it("excludes jobs outside the period", async () => {
|
|
// Jobs from Feb 2026 (already created above)
|
|
// Check March period only returns March jobs
|
|
const marchStart = new Date("2026-03-01T00:00:00Z");
|
|
const marchEnd = new Date("2026-03-31T23:59:59Z");
|
|
|
|
const detail = await getTechnicianCompensationDetail(tA(), {
|
|
technicianProfileId: profileBId,
|
|
periodStart: marchStart,
|
|
periodEnd: marchEnd,
|
|
});
|
|
|
|
// All returned jobs should be within March
|
|
for (const job of detail.jobs) {
|
|
expect(job.completedAt.getTime()).toBeGreaterThanOrEqual(marchStart.getTime());
|
|
expect(job.completedAt.getTime()).toBeLessThanOrEqual(marchEnd.getTime());
|
|
}
|
|
});
|
|
|
|
it("February jobs not included in March report", async () => {
|
|
const marchStart = new Date("2026-03-01T00:00:00Z");
|
|
const marchEnd = new Date("2026-03-31T23:59:59Z");
|
|
|
|
const detail = await getTechnicianCompensationDetail(tA(), {
|
|
technicianProfileId: profileBId,
|
|
periodStart: marchStart,
|
|
periodEnd: marchEnd,
|
|
});
|
|
|
|
// Feb jobs (completedAt in Feb) should not appear
|
|
const febJobs = detail.jobs.filter((j: { completedAt: Date }) => j.completedAt < marchStart);
|
|
expect(febJobs).toHaveLength(0);
|
|
});
|
|
});
|
|
|
|
describe("Summary report returns all technicians", () => {
|
|
it("includes all technicians with correct totals", async () => {
|
|
const periodStart = new Date("2026-02-01T00:00:00Z");
|
|
const periodEnd = new Date("2026-02-28T23:59:59Z");
|
|
|
|
const summary = await getCompensationSummary(tA(), { periodStart, periodEnd });
|
|
|
|
// Should include all 3 profiles
|
|
const profileIds = summary.technicians.map((t: { technicianProfileId: string }) => t.technicianProfileId);
|
|
expect(profileIds).toContain(profileAId);
|
|
expect(profileIds).toContain(profileBId);
|
|
expect(profileIds).toContain(profileCId);
|
|
|
|
// grandTotal should be sum of all technician totals
|
|
const sumOfTotals = summary.technicians.reduce(
|
|
(sum: number, t: { totalCompensation: { toNumber: () => number } }) => sum + t.totalCompensation.toNumber(),
|
|
0
|
|
);
|
|
expect(Number(summary.grandTotal)).toBeCloseTo(sumOfTotals, 2);
|
|
});
|
|
});
|
|
|
|
describe("Detail report returns job-by-job breakdown", () => {
|
|
it("returns each completed job with rate", async () => {
|
|
const periodStart = new Date("2026-02-01T00:00:00Z");
|
|
const periodEnd = new Date("2026-02-28T23:59:59Z");
|
|
|
|
const detail = await getTechnicianCompensationDetail(tA(), {
|
|
technicianProfileId: profileBId,
|
|
periodStart,
|
|
periodEnd,
|
|
});
|
|
|
|
// Should include job-by-job detail
|
|
expect(detail.jobs.length).toBeGreaterThan(0);
|
|
for (const job of detail.jobs) {
|
|
expect(job.jobOrderId).toBeDefined();
|
|
expect(job.orderNumber).toBeDefined();
|
|
expect(job.jobType).toBeDefined();
|
|
expect(job.completedAt).toBeDefined();
|
|
expect(job.rate).toBeDefined();
|
|
expect(job.ticketId).toBeDefined();
|
|
expect(job.ticketNumber).toBeDefined();
|
|
}
|
|
|
|
// Verify totals consistent
|
|
const sumRates = detail.jobs.reduce(
|
|
(sum: number, j: { rate: { toNumber: () => number } }) => sum + j.rate.toNumber(),
|
|
0
|
|
);
|
|
expect(Number(detail.jobBonusTotal)).toBeCloseTo(sumRates, 2);
|
|
});
|
|
|
|
it("throws if technician profile not found", async () => {
|
|
await expect(
|
|
getTechnicianCompensationDetail(tA(), {
|
|
technicianProfileId: "non-existent-profile-id",
|
|
periodStart: new Date("2026-02-01"),
|
|
periodEnd: new Date("2026-02-28"),
|
|
})
|
|
).rejects.toThrow(/not found/i);
|
|
});
|
|
});
|
|
|
|
describe("Technician with no completed jobs", () => {
|
|
it("returns 0 job bonus for technician with no completed jobs in period", async () => {
|
|
// Create a brand new technician with no jobs
|
|
const newTech = await prisma.user.create({
|
|
data: {
|
|
tenantId,
|
|
email: `comp-new-tech-${TS}@test.example`,
|
|
passwordHash: "hashed",
|
|
firstName: "NoJobs",
|
|
lastName: "Tech",
|
|
roles: ["TECHNICIAN"],
|
|
isActive: true,
|
|
},
|
|
});
|
|
|
|
const newProfile = await createTechnicianProfile(tA(), tenantId, {
|
|
userId: newTech.id,
|
|
compensationModel: CompensationModel.PER_JOB,
|
|
});
|
|
|
|
const periodStart = new Date("2026-02-01T00:00:00Z");
|
|
const periodEnd = new Date("2026-02-28T23:59:59Z");
|
|
|
|
const summary = await getCompensationSummary(tA(), {
|
|
periodStart,
|
|
periodEnd,
|
|
technicianProfileId: newProfile.id,
|
|
});
|
|
|
|
const techSummary = summary.technicians.find(
|
|
(t: { technicianProfileId: string }) => t.technicianProfileId === newProfile.id
|
|
);
|
|
|
|
expect(techSummary).toBeDefined();
|
|
expect(Number(techSummary!.jobBonusTotal)).toBe(0);
|
|
expect(Number(techSummary!.baseSalary)).toBe(0);
|
|
expect(Number(techSummary!.totalCompensation)).toBe(0);
|
|
expect(techSummary!.completedJobCount).toBe(0);
|
|
|
|
// Cleanup
|
|
await prisma.technicianProfile.delete({ where: { id: newProfile.id } }).catch(() => {});
|
|
await prisma.user.delete({ where: { id: newTech.id } }).catch(() => {});
|
|
});
|
|
});
|
|
|
|
describe("Cross-tenant isolation", () => {
|
|
it("Tenant B technicians do not appear in Tenant A summary", async () => {
|
|
// Create a technician in Tenant B
|
|
const techB_user = await prisma.user.create({
|
|
data: {
|
|
tenantId: tenantBId,
|
|
email: `comp-tech-b-isolation-${TS}@test.example`,
|
|
passwordHash: "hashed",
|
|
firstName: "TenantB",
|
|
lastName: "Tech",
|
|
roles: ["TECHNICIAN"],
|
|
isActive: true,
|
|
},
|
|
});
|
|
|
|
const profileB = await createTechnicianProfile(tB(), tenantBId, {
|
|
userId: techB_user.id,
|
|
compensationModel: CompensationModel.PER_JOB,
|
|
});
|
|
|
|
const periodStart = new Date("2026-02-01T00:00:00Z");
|
|
const periodEnd = new Date("2026-02-28T23:59:59Z");
|
|
|
|
const summaryA = await getCompensationSummary(tA(), { periodStart, periodEnd });
|
|
|
|
// Tenant B profile should not appear in Tenant A's report
|
|
const profileIds = summaryA.technicians.map((t: { technicianProfileId: string }) => t.technicianProfileId);
|
|
expect(profileIds).not.toContain(profileB.id);
|
|
|
|
// Cleanup Tenant B technician
|
|
await prisma.technicianProfile.delete({ where: { id: profileB.id } }).catch(() => {});
|
|
await prisma.user.delete({ where: { id: techB_user.id } }).catch(() => {});
|
|
});
|
|
|
|
it("job type rates are tenant-scoped", async () => {
|
|
// Tenant B should not see Tenant A's rates
|
|
const tenantBRates = await tB().jobTypeRate.findMany({});
|
|
const tenantAInstallRate = await tA().jobTypeRate.findFirst({ where: { id: installRateId } });
|
|
|
|
// Tenant B has no rates (we only created rates for Tenant A)
|
|
const tenantBRateIds = tenantBRates.map((r: { id: string }) => r.id);
|
|
expect(tenantBRateIds).not.toContain(installRateId);
|
|
expect(tenantAInstallRate).not.toBeNull();
|
|
});
|
|
});
|
|
|
|
describe("Compensation detail baseSalary and jobBonusTotal", () => {
|
|
it("HYBRID detail includes both baseSalary and job breakdown", async () => {
|
|
const periodStart = new Date("2026-02-01T00:00:00Z");
|
|
const periodEnd = new Date("2026-02-28T23:59:59Z");
|
|
|
|
const detail = await getTechnicianCompensationDetail(tA(), {
|
|
technicianProfileId: profileAId,
|
|
periodStart,
|
|
periodEnd,
|
|
});
|
|
|
|
expect(detail.compensationModel).toBe(CompensationModel.HYBRID);
|
|
expect(Number(detail.baseSalary)).toBe(2000);
|
|
expect(detail.jobs.length).toBeGreaterThan(0);
|
|
expect(Number(detail.totalCompensation)).toBe(
|
|
Number(detail.baseSalary) + Number(detail.jobBonusTotal)
|
|
);
|
|
});
|
|
|
|
it("SALARY detail shows salary with no jobs counted for bonus", async () => {
|
|
const periodStart = new Date("2026-02-01T00:00:00Z");
|
|
const periodEnd = new Date("2026-02-28T23:59:59Z");
|
|
|
|
const detail = await getTechnicianCompensationDetail(tA(), {
|
|
technicianProfileId: profileCId,
|
|
periodStart,
|
|
periodEnd,
|
|
});
|
|
|
|
expect(detail.compensationModel).toBe(CompensationModel.SALARY);
|
|
expect(Number(detail.baseSalary)).toBe(3500);
|
|
expect(Number(detail.jobBonusTotal)).toBe(0);
|
|
// Jobs array may have entries, but their rate should be 0 for SALARY model
|
|
for (const job of detail.jobs) {
|
|
expect(Number(job.rate)).toBe(0);
|
|
}
|
|
});
|
|
});
|