diff --git a/src/app/api/job-type-rates/[id]/route.ts b/src/app/api/job-type-rates/[id]/route.ts new file mode 100644 index 0000000..e352eca --- /dev/null +++ b/src/app/api/job-type-rates/[id]/route.ts @@ -0,0 +1,75 @@ +import { NextRequest, NextResponse } from "next/server"; +import { withPermission } from "@/lib/middleware/authorize"; +import { withTenantContext } from "@/lib/prisma-tenant"; + +/** + * PUT /api/job-type-rates/[id] + * + * Update a job type rate. + * + * Accepts: { rate?, description?, isActive? } + * Note: jobType is immutable (it's part of the unique key). + * + * Requires: update on JobTypeRate subject (ADMIN only). + * + * Response: + * 200 OK — updated job type rate + * 400 Bad Request — validation error + * 404 Not Found — rate not found in tenant scope + * 401 Unauthorized — no session + * 403 Forbidden — insufficient role + */ +export function PUT(req: NextRequest, { params }: { params: Promise<{ id: string }> }) { + return withPermission("update", "JobTypeRate")( + async (req: NextRequest, { user }) => { + if (!user.tenantId) { + return NextResponse.json( + { error: "No tenant context — super-admins must use the admin API" }, + { status: 400 } + ); + } + + const { id } = await params; + + let body: unknown; + try { + body = await req.json(); + } catch { + return NextResponse.json({ error: "Invalid JSON body" }, { status: 400 }); + } + + const { rate, description, isActive } = body as Record; + + const data: Record = {}; + + if (rate !== undefined) { + if (isNaN(Number(rate)) || Number(rate) < 0) { + return NextResponse.json({ error: "rate must be a valid non-negative number" }, { status: 400 }); + } + data.rate = Number(rate); + } + + if (description !== undefined) data.description = description ? String(description).trim() : null; + if (isActive !== undefined) data.isActive = Boolean(isActive); + + const tenantPrisma = withTenantContext(user.tenantId); + + // Verify exists + const existing = await tenantPrisma.jobTypeRate.findFirst({ where: { id } }); + if (!existing) { + return NextResponse.json({ error: "Job type rate not found" }, { status: 404 }); + } + + try { + const updated = await tenantPrisma.jobTypeRate.update({ + where: { id }, + data, + }); + return NextResponse.json(updated); + } catch (err) { + const message = err instanceof Error ? err.message : "Failed to update job type rate"; + return NextResponse.json({ error: message }, { status: 400 }); + } + } + )(req); +} diff --git a/src/app/api/job-type-rates/route.ts b/src/app/api/job-type-rates/route.ts new file mode 100644 index 0000000..869510d --- /dev/null +++ b/src/app/api/job-type-rates/route.ts @@ -0,0 +1,121 @@ +import { NextRequest, NextResponse } from "next/server"; +import { withPermission } from "@/lib/middleware/authorize"; +import { withTenantContext } from "@/lib/prisma-tenant"; + +/** + * GET /api/job-type-rates + * + * List all job type rates for the current tenant. + * + * Query params: activeOnly? (boolean) + * + * Requires: read on JobTypeRate subject. + * + * Response: + * 200 OK — { rates, total } + * 401 Unauthorized — no session + * 403 Forbidden — insufficient role + */ +export function GET(req: NextRequest) { + return withPermission("read", "JobTypeRate")( + async (req: NextRequest, { user }) => { + if (!user.tenantId) { + return NextResponse.json( + { error: "No tenant context — super-admins must use the admin API" }, + { status: 400 } + ); + } + + const url = new URL(req.url); + const activeOnly = url.searchParams.get("activeOnly") === "true"; + + const tenantPrisma = withTenantContext(user.tenantId); + + const where: Record = {}; + if (activeOnly) where.isActive = true; + + const [rates, total] = await Promise.all([ + tenantPrisma.jobTypeRate.findMany({ + where, + orderBy: { jobType: "asc" }, + }), + tenantPrisma.jobTypeRate.count({ where }), + ]); + + return NextResponse.json({ rates, total }); + } + )(req); +} + +/** + * POST /api/job-type-rates + * + * Create a new job type rate. + * + * Accepts: { jobType, rate, description? } + * + * Requires: create on JobTypeRate subject (ADMIN only). + * + * Response: + * 201 Created — created job type rate + * 400 Bad Request — validation error or duplicate jobType + * 401 Unauthorized — no session + * 403 Forbidden — insufficient role + */ +export function POST(req: NextRequest) { + return withPermission("create", "JobTypeRate")( + async (req: NextRequest, { user }) => { + if (!user.tenantId) { + return NextResponse.json( + { error: "No tenant context — super-admins must use the admin API" }, + { status: 400 } + ); + } + + let body: unknown; + try { + body = await req.json(); + } catch { + return NextResponse.json({ error: "Invalid JSON body" }, { status: 400 }); + } + + const { jobType, rate, description } = body as Record; + + if (!jobType || typeof jobType !== "string" || !jobType.trim()) { + return NextResponse.json({ error: "jobType is required" }, { status: 400 }); + } + + if (rate === undefined || rate === null || isNaN(Number(rate))) { + return NextResponse.json({ error: "rate must be a valid number" }, { status: 400 }); + } + + if (Number(rate) < 0) { + return NextResponse.json({ error: "rate must be >= 0" }, { status: 400 }); + } + + const tenantPrisma = withTenantContext(user.tenantId); + + try { + const created = await tenantPrisma.jobTypeRate.create({ + data: { + tenantId: user.tenantId, + jobType: (jobType as string).trim(), + rate: Number(rate), + description: description ? String(description).trim() : null, + isActive: true, + }, + }); + return NextResponse.json(created, { status: 201 }); + } catch (err) { + const message = err instanceof Error ? err.message : "Failed to create job type rate"; + if (message.includes("Unique constraint") || message.includes("P2002")) { + return NextResponse.json( + { error: `A rate for job type "${jobType}" already exists` }, + { status: 400 } + ); + } + return NextResponse.json({ error: message }, { status: 400 }); + } + } + )(req); +} diff --git a/src/app/api/reports/compensation/route.ts b/src/app/api/reports/compensation/route.ts new file mode 100644 index 0000000..cab68a2 --- /dev/null +++ b/src/app/api/reports/compensation/route.ts @@ -0,0 +1,73 @@ +import { NextRequest, NextResponse } from "next/server"; +import { withPermission } from "@/lib/middleware/authorize"; +import { withTenantContext } from "@/lib/prisma-tenant"; +import { getCompensationSummary } from "@/lib/services/compensation-service"; + +/** + * GET /api/reports/compensation + * + * Get compensation summary report for all technicians in a period. + * + * Query params: + * periodStart (required) — ISO date string (inclusive) + * periodEnd (required) — ISO date string (inclusive) + * technicianProfileId? — filter to a single technician + * + * Requires: read on TechnicianProfile subject (ADMIN, OFFICE_STAFF). + * + * Response: + * 200 OK — { periodStart, periodEnd, technicians, grandTotal } + * Each technician: { technicianProfileId, userId, firstName, lastName, + * compensationModel, baseSalary, jobBonusTotal, totalCompensation, completedJobCount } + * 400 Bad Request — missing/invalid period params + * 401 Unauthorized — no session + * 403 Forbidden — insufficient role + */ +export function GET(req: NextRequest) { + return withPermission("read", "TechnicianProfile")( + async (req: NextRequest, { user }) => { + if (!user.tenantId) { + return NextResponse.json( + { error: "No tenant context — super-admins must use the admin API" }, + { status: 400 } + ); + } + + const url = new URL(req.url); + const periodStartParam = url.searchParams.get("periodStart"); + const periodEndParam = url.searchParams.get("periodEnd"); + const technicianProfileId = url.searchParams.get("technicianProfileId") ?? undefined; + + if (!periodStartParam || !periodEndParam) { + return NextResponse.json( + { error: "periodStart and periodEnd query params are required" }, + { status: 400 } + ); + } + + const periodStart = new Date(periodStartParam); + const periodEnd = new Date(periodEndParam); + + if (isNaN(periodStart.getTime()) || isNaN(periodEnd.getTime())) { + return NextResponse.json( + { error: "periodStart and periodEnd must be valid ISO date strings" }, + { status: 400 } + ); + } + + const tenantPrisma = withTenantContext(user.tenantId); + + try { + const summary = await getCompensationSummary(tenantPrisma, { + periodStart, + periodEnd, + technicianProfileId, + }); + return NextResponse.json(summary); + } catch (err) { + const message = err instanceof Error ? err.message : "Failed to generate compensation report"; + return NextResponse.json({ error: message }, { status: 400 }); + } + } + )(req); +} diff --git a/src/app/api/technicians/[id]/compensation/route.ts b/src/app/api/technicians/[id]/compensation/route.ts new file mode 100644 index 0000000..41cb29f --- /dev/null +++ b/src/app/api/technicians/[id]/compensation/route.ts @@ -0,0 +1,75 @@ +import { NextRequest, NextResponse } from "next/server"; +import { withPermission } from "@/lib/middleware/authorize"; +import { withTenantContext } from "@/lib/prisma-tenant"; +import { getTechnicianCompensationDetail } from "@/lib/services/compensation-service"; + +/** + * GET /api/technicians/[id]/compensation + * + * Get compensation detail (job-by-job breakdown) for a specific technician. + * + * Query params: + * periodStart (required) — ISO date string (inclusive) + * periodEnd (required) — ISO date string (inclusive) + * + * Requires: read on TechnicianProfile subject. + * + * Response: + * 200 OK — { technicianProfileId, userId, firstName, lastName, compensationModel, + * periodStart, periodEnd, baseSalary, jobs, jobBonusTotal, totalCompensation } + * 400 Bad Request — missing/invalid period params + * 404 Not Found — profile not found in tenant scope + * 401 Unauthorized — no session + * 403 Forbidden — insufficient role + */ +export function GET(req: NextRequest, { params }: { params: Promise<{ id: string }> }) { + return withPermission("read", "TechnicianProfile")( + async (req: NextRequest, { user }) => { + if (!user.tenantId) { + return NextResponse.json( + { error: "No tenant context — super-admins must use the admin API" }, + { status: 400 } + ); + } + + const { id } = await params; + const url = new URL(req.url); + const periodStartParam = url.searchParams.get("periodStart"); + const periodEndParam = url.searchParams.get("periodEnd"); + + if (!periodStartParam || !periodEndParam) { + return NextResponse.json( + { error: "periodStart and periodEnd query params are required" }, + { status: 400 } + ); + } + + const periodStart = new Date(periodStartParam); + const periodEnd = new Date(periodEndParam); + + if (isNaN(periodStart.getTime()) || isNaN(periodEnd.getTime())) { + return NextResponse.json( + { error: "periodStart and periodEnd must be valid ISO date strings" }, + { status: 400 } + ); + } + + const tenantPrisma = withTenantContext(user.tenantId); + + try { + const detail = await getTechnicianCompensationDetail(tenantPrisma, { + technicianProfileId: id, + periodStart, + periodEnd, + }); + return NextResponse.json(detail); + } catch (err) { + const message = err instanceof Error ? err.message : "Failed to get compensation detail"; + if (message.includes("not found")) { + return NextResponse.json({ error: message }, { status: 404 }); + } + return NextResponse.json({ error: message }, { status: 400 }); + } + } + )(req); +} diff --git a/src/app/api/technicians/[id]/route.ts b/src/app/api/technicians/[id]/route.ts new file mode 100644 index 0000000..6eaf3e6 --- /dev/null +++ b/src/app/api/technicians/[id]/route.ts @@ -0,0 +1,110 @@ +import { NextRequest, NextResponse } from "next/server"; +import { withPermission } from "@/lib/middleware/authorize"; +import { withTenantContext } from "@/lib/prisma-tenant"; +import { + getTechnicianProfile, + updateTechnicianProfile, +} from "@/lib/services/technician-service"; +import { CompensationModel } from "@prisma/client"; + +/** + * GET /api/technicians/[id] + * + * Get a single technician profile by ID. + * + * Requires: read on TechnicianProfile subject. + * + * Response: + * 200 OK — technician profile with user and zone + * 404 Not Found — profile not found in tenant scope + * 401 Unauthorized — no session + * 403 Forbidden — insufficient role + */ +export function GET(req: NextRequest, { params }: { params: Promise<{ id: string }> }) { + return withPermission("read", "TechnicianProfile")( + async (_req: NextRequest, { user }) => { + if (!user.tenantId) { + return NextResponse.json( + { error: "No tenant context — super-admins must use the admin API" }, + { status: 400 } + ); + } + + const { id } = await params; + const tenantPrisma = withTenantContext(user.tenantId); + const profile = await getTechnicianProfile(tenantPrisma, id); + + if (!profile) { + return NextResponse.json({ error: "Technician profile not found" }, { status: 404 }); + } + + return NextResponse.json(profile); + } + )(req); +} + +/** + * PUT /api/technicians/[id] + * + * Update a technician profile. + * + * Accepts: { phone?, skills?, zoneId?, compensationModel?, monthlySalary?, isActive? } + * + * Requires: update on TechnicianProfile subject (ADMIN only). + * + * Response: + * 200 OK — updated technician profile + * 400 Bad Request — validation error + * 404 Not Found — profile not found + * 401 Unauthorized — no session + * 403 Forbidden — insufficient role + */ +export function PUT(req: NextRequest, { params }: { params: Promise<{ id: string }> }) { + return withPermission("update", "TechnicianProfile")( + async (req: NextRequest, { user }) => { + if (!user.tenantId) { + return NextResponse.json( + { error: "No tenant context — super-admins must use the admin API" }, + { status: 400 } + ); + } + + const { id } = await params; + + let body: unknown; + try { + body = await req.json(); + } catch { + return NextResponse.json({ error: "Invalid JSON body" }, { status: 400 }); + } + + const { phone, skills, zoneId, compensationModel, monthlySalary, isActive } = + body as Record; + + const tenantPrisma = withTenantContext(user.tenantId); + + try { + const profile = await updateTechnicianProfile(tenantPrisma, id, { + phone: phone as string | undefined, + skills: Array.isArray(skills) ? (skills as string[]) : undefined, + zoneId: zoneId === null ? null : (zoneId as string | undefined), + compensationModel: compensationModel as CompensationModel | undefined, + monthlySalary: + monthlySalary === null + ? null + : monthlySalary !== undefined + ? Number(monthlySalary) + : undefined, + isActive: isActive !== undefined ? Boolean(isActive) : undefined, + }); + return NextResponse.json(profile); + } catch (err) { + const message = err instanceof Error ? err.message : "Failed to update technician profile"; + if (message.includes("not found")) { + return NextResponse.json({ error: message }, { status: 404 }); + } + return NextResponse.json({ error: message }, { status: 400 }); + } + } + )(req); +} diff --git a/src/app/api/technicians/route.ts b/src/app/api/technicians/route.ts new file mode 100644 index 0000000..e902ed6 --- /dev/null +++ b/src/app/api/technicians/route.ts @@ -0,0 +1,104 @@ +import { NextRequest, NextResponse } from "next/server"; +import { withPermission } from "@/lib/middleware/authorize"; +import { withTenantContext } from "@/lib/prisma-tenant"; +import { + listTechnicians, + createTechnicianProfile, +} from "@/lib/services/technician-service"; +import { CompensationModel } from "@prisma/client"; + +/** + * GET /api/technicians + * + * List technician profiles for the current tenant. + * + * Query params: activeOnly? (boolean), zoneId? (string) + * + * Requires: read on TechnicianProfile subject (ADMIN, OFFICE_STAFF). + * + * Response: + * 200 OK — { profiles, total } + * 401 Unauthorized — no session + * 403 Forbidden — insufficient role + */ +export function GET(req: NextRequest) { + return withPermission("read", "TechnicianProfile")( + async (req: NextRequest, { user }) => { + if (!user.tenantId) { + return NextResponse.json( + { error: "No tenant context — super-admins must use the admin API" }, + { status: 400 } + ); + } + + const url = new URL(req.url); + const activeOnly = url.searchParams.get("activeOnly") === "true"; + const zoneId = url.searchParams.get("zoneId") ?? undefined; + + const tenantPrisma = withTenantContext(user.tenantId); + const result = await listTechnicians(tenantPrisma, { activeOnly, zoneId }); + + return NextResponse.json(result); + } + )(req); +} + +/** + * POST /api/technicians + * + * Create a new technician profile. + * + * Accepts: { userId, phone?, skills?, zoneId?, compensationModel?, monthlySalary? } + * + * Requires: create on TechnicianProfile subject (ADMIN only). + * + * Response: + * 201 Created — created technician profile + * 400 Bad Request — validation error + * 401 Unauthorized — no session + * 403 Forbidden — insufficient role + */ +export function POST(req: NextRequest) { + return withPermission("create", "TechnicianProfile")( + async (req: NextRequest, { user }) => { + if (!user.tenantId) { + return NextResponse.json( + { error: "No tenant context — super-admins must use the admin API" }, + { status: 400 } + ); + } + + let body: unknown; + try { + body = await req.json(); + } catch { + return NextResponse.json({ error: "Invalid JSON body" }, { status: 400 }); + } + + const { userId, phone, skills, zoneId, compensationModel, monthlySalary } = + body as Record; + + if (!userId || typeof userId !== "string") { + return NextResponse.json({ error: "userId is required" }, { status: 400 }); + } + + const tenantPrisma = withTenantContext(user.tenantId); + + try { + const profile = await createTechnicianProfile(tenantPrisma, user.tenantId, { + userId: userId as string, + phone: phone as string | undefined, + skills: Array.isArray(skills) ? (skills as string[]) : undefined, + zoneId: zoneId as string | undefined, + compensationModel: compensationModel as CompensationModel | undefined, + monthlySalary: + monthlySalary !== undefined ? Number(monthlySalary) : undefined, + }); + return NextResponse.json(profile, { status: 201 }); + } catch (err) { + const message = err instanceof Error ? err.message : "Failed to create technician profile"; + return NextResponse.json({ error: message }, { status: 400 }); + } + } + )(req); +} diff --git a/src/lib/__tests__/compensation-service.test.ts b/src/lib/__tests__/compensation-service.test.ts new file mode 100644 index 0000000..565c1fb --- /dev/null +++ b/src/lib/__tests__/compensation-service.test.ts @@ -0,0 +1,826 @@ +/** + * 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 { + 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); + } + }); +}); diff --git a/src/lib/casl/permissions.ts b/src/lib/casl/permissions.ts index 08d1b9a..c05a520 100644 --- a/src/lib/casl/permissions.ts +++ b/src/lib/casl/permissions.ts @@ -57,6 +57,11 @@ export function definePermissionsFor( can("manage", "Ticket"); // Job order management can("manage", "JobOrder"); + // Technician profile management (read/update, not create — admin only for compensation config) + can("read", "TechnicianProfile"); + can("update", "TechnicianProfile"); + // Job type rates (read-only for office staff — admin configures rates) + can("read", "JobTypeRate"); // View financial reports (read-only) can("read", "Report"); // View accounting (read-only, cannot modify Chart of Accounts) diff --git a/src/lib/casl/types.ts b/src/lib/casl/types.ts index 79f338e..86bbd90 100644 --- a/src/lib/casl/types.ts +++ b/src/lib/casl/types.ts @@ -16,6 +16,8 @@ export type AppSubjects = | "Zone" | "Ticket" | "JobOrder" + | "TechnicianProfile" + | "JobTypeRate" | "Inventory" | "Expense" | "Account" diff --git a/src/lib/services/compensation-service.ts b/src/lib/services/compensation-service.ts new file mode 100644 index 0000000..e5e00a8 --- /dev/null +++ b/src/lib/services/compensation-service.ts @@ -0,0 +1,314 @@ +/** + * CompensationService — Compensation calculation and summary report. + * + * ARCHITECTURE: + * Three compensation models: + * PER_JOB: jobBonusTotal = sum of rates for COMPLETED jobs in period + * baseSalary = 0 + * SALARY: baseSalary = monthlySalary (prorated for partial periods if needed — currently full month) + * jobBonusTotal = 0 + * HYBRID: baseSalary = monthlySalary + jobBonusTotal = sum of rates + * + * RULES: + * - Only COMPLETED job orders count (PENDING, IN_PROGRESS, CANCELLED are excluded) + * - Missing job type rate defaults to 0 bonus (not an error) + * - Date range is inclusive on both ends: completedAt >= periodStart AND completedAt <= periodEnd + * + * OUTPUTS: + * - getCompensationSummary: per-technician totals (baseSalary, jobBonusTotal, totalCompensation) + * - getTechnicianCompensationDetail: job-by-job breakdown for one technician + */ + +import { CompensationModel, JobOrderStatus, Prisma } from "@prisma/client"; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +type TenantPrismaClient = any; + +// --------------------------------------------------------------------------- +// Input/output types +// --------------------------------------------------------------------------- + +export interface CompensationPeriod { + periodStart: Date; + periodEnd: Date; + technicianProfileId?: string; +} + +export interface TechnicianCompensationSummary { + technicianProfileId: string; + userId: string; + firstName: string; + lastName: string; + compensationModel: CompensationModel; + baseSalary: Prisma.Decimal; + jobBonusTotal: Prisma.Decimal; + totalCompensation: Prisma.Decimal; + completedJobCount: number; +} + +export interface CompensationSummaryResult { + periodStart: Date; + periodEnd: Date; + technicians: TechnicianCompensationSummary[]; + grandTotal: Prisma.Decimal; +} + +export interface JobCompensationDetail { + jobOrderId: string; + orderNumber: string; + jobType: string; + completedAt: Date; + rate: Prisma.Decimal; + ticketId: string; + ticketNumber: string; +} + +export interface TechnicianCompensationDetailResult { + technicianProfileId: string; + userId: string; + firstName: string; + lastName: string; + compensationModel: CompensationModel; + periodStart: Date; + periodEnd: Date; + baseSalary: Prisma.Decimal; + jobs: JobCompensationDetail[]; + jobBonusTotal: Prisma.Decimal; + totalCompensation: Prisma.Decimal; +} + +// --------------------------------------------------------------------------- +// Internal helpers +// --------------------------------------------------------------------------- + +/** + * Build a rate lookup map from JobTypeRate records. + * jobType -> Decimal rate + * Missing job types default to 0 in calculation (not an error). + */ +function buildRateMap( + rates: Array<{ jobType: string; rate: Prisma.Decimal }> +): Map { + const map = new Map(); + for (const r of rates) { + map.set(r.jobType, new Prisma.Decimal(r.rate)); + } + return map; +} + +/** + * Calculate job bonus for a list of completed jobs using the rate map. + * Missing rates default to 0. + */ +function calculateJobBonus( + completedJobs: Array<{ jobType: string }>, + rateMap: Map +): Prisma.Decimal { + return completedJobs.reduce((sum, job) => { + const rate = rateMap.get(job.jobType) ?? new Prisma.Decimal(0); + return sum.plus(rate); + }, new Prisma.Decimal(0)); +} + +// --------------------------------------------------------------------------- +// getCompensationSummary +// --------------------------------------------------------------------------- + +/** + * Get compensation summary for all technicians (or a specific one) in a period. + * + * For each technician: + * 1. Load their COMPLETED job orders in the period + * 2. Load the tenant's JobTypeRates for the relevant job types + * 3. Calculate jobBonusTotal (missing rate = 0) + * 4. Calculate baseSalary (SALARY/HYBRID only) + * 5. Sum totalCompensation + * + * @param tenantPrisma - Tenant-scoped Prisma client + * @param params - Period and optional technicianProfileId filter + */ +export async function getCompensationSummary( + tenantPrisma: TenantPrismaClient, + params: CompensationPeriod +): Promise { + const { periodStart, periodEnd, technicianProfileId } = params; + + // Load technician profiles (optionally filtered) + const profileWhere: Record = { isActive: true }; + if (technicianProfileId) { + profileWhere.id = technicianProfileId; + } + + const profiles = await tenantPrisma.technicianProfile.findMany({ + where: profileWhere, + include: { + user: { + select: { id: true, firstName: true, lastName: true }, + }, + }, + }); + + // Load all tenant job type rates (active only) + const allRates = await tenantPrisma.jobTypeRate.findMany({ + where: { isActive: true }, + select: { jobType: true, rate: true }, + }); + const rateMap = buildRateMap(allRates); + + const technicians: TechnicianCompensationSummary[] = []; + let grandTotal = new Prisma.Decimal(0); + + for (const profile of profiles) { + // Load COMPLETED job orders in period assigned to this technician + const completedJobs = await tenantPrisma.jobOrder.findMany({ + where: { + assignedToId: profile.userId, + status: JobOrderStatus.COMPLETED, + completedAt: { + gte: periodStart, + lte: periodEnd, + }, + }, + select: { id: true, jobType: true, completedAt: true, orderNumber: true }, + }); + + const model = profile.compensationModel as CompensationModel; + + const baseSalary = + model === CompensationModel.SALARY || model === CompensationModel.HYBRID + ? new Prisma.Decimal(profile.monthlySalary ?? 0) + : new Prisma.Decimal(0); + + const jobBonusTotal = + model === CompensationModel.PER_JOB || model === CompensationModel.HYBRID + ? calculateJobBonus(completedJobs, rateMap) + : new Prisma.Decimal(0); + + const totalCompensation = baseSalary.plus(jobBonusTotal); + grandTotal = grandTotal.plus(totalCompensation); + + technicians.push({ + technicianProfileId: profile.id, + userId: profile.user.id, + firstName: profile.user.firstName, + lastName: profile.user.lastName, + compensationModel: model, + baseSalary, + jobBonusTotal, + totalCompensation, + completedJobCount: completedJobs.length, + }); + } + + return { + periodStart, + periodEnd, + technicians, + grandTotal, + }; +} + +// --------------------------------------------------------------------------- +// getTechnicianCompensationDetail +// --------------------------------------------------------------------------- + +/** + * Get job-by-job compensation breakdown for a single technician in a period. + * + * Returns: + * - Each COMPLETED job order with rate lookup + * - baseSalary for the period + * - jobBonusTotal and totalCompensation + * + * @throws Error if TechnicianProfile not found + */ +export async function getTechnicianCompensationDetail( + tenantPrisma: TenantPrismaClient, + params: { technicianProfileId: string; periodStart: Date; periodEnd: Date } +): Promise { + const { technicianProfileId, periodStart, periodEnd } = params; + + const profile = await tenantPrisma.technicianProfile.findFirst({ + where: { id: technicianProfileId }, + include: { + user: { + select: { id: true, firstName: true, lastName: true }, + }, + }, + }); + + if (!profile) { + throw new Error(`TechnicianProfile not found: ${technicianProfileId}`); + } + + // Load COMPLETED job orders with ticket info for this technician in period + const completedJobs = await tenantPrisma.jobOrder.findMany({ + where: { + assignedToId: profile.userId, + status: JobOrderStatus.COMPLETED, + completedAt: { + gte: periodStart, + lte: periodEnd, + }, + }, + include: { + ticket: { + select: { id: true, ticketNumber: true }, + }, + }, + orderBy: { completedAt: "asc" }, + }); + + // Load all active job type rates + const allRates = await tenantPrisma.jobTypeRate.findMany({ + where: { isActive: true }, + select: { jobType: true, rate: true }, + }); + const rateMap = buildRateMap(allRates); + + const model = profile.compensationModel as CompensationModel; + + const baseSalary = + model === CompensationModel.SALARY || model === CompensationModel.HYBRID + ? new Prisma.Decimal(profile.monthlySalary ?? 0) + : new Prisma.Decimal(0); + + // Build job-by-job detail + let jobBonusTotal = new Prisma.Decimal(0); + const jobs: JobCompensationDetail[] = []; + + for (const jo of completedJobs) { + const rate = + model === CompensationModel.PER_JOB || model === CompensationModel.HYBRID + ? (rateMap.get(jo.jobType) ?? new Prisma.Decimal(0)) + : new Prisma.Decimal(0); + + jobBonusTotal = jobBonusTotal.plus(rate); + + jobs.push({ + jobOrderId: jo.id, + orderNumber: jo.orderNumber, + jobType: jo.jobType, + completedAt: jo.completedAt, + rate, + ticketId: jo.ticket.id, + ticketNumber: jo.ticket.ticketNumber, + }); + } + + const totalCompensation = baseSalary.plus(jobBonusTotal); + + return { + technicianProfileId: profile.id, + userId: profile.user.id, + firstName: profile.user.firstName, + lastName: profile.user.lastName, + compensationModel: model, + periodStart, + periodEnd, + baseSalary, + jobs, + jobBonusTotal, + totalCompensation, + }; +} diff --git a/src/lib/services/technician-service.ts b/src/lib/services/technician-service.ts new file mode 100644 index 0000000..c5425cb --- /dev/null +++ b/src/lib/services/technician-service.ts @@ -0,0 +1,296 @@ +/** + * TechnicianService — Technician profile CRUD. + * + * ARCHITECTURE: + * A TechnicianProfile holds ISP-specific attributes for a user with the TECHNICIAN role. + * Each profile is uniquely tied to one user per tenant (@@unique([tenantId, userId])). + * + * COMPENSATION MODEL: + * PER_JOB: earns rate per completed job type (no base salary) + * SALARY: earns fixed monthly salary only (no per-job bonuses) + * HYBRID: earns fixed monthly salary + per-job bonuses + * + * VALIDATION: + * - userId must belong to a user with TECHNICIAN role + * - One profile per user per tenant (enforced at DB via @@unique) + * - monthlySalary required for SALARY and HYBRID models + */ + +import { CompensationModel } from "@prisma/client"; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +type TenantPrismaClient = any; + +// --------------------------------------------------------------------------- +// Input types +// --------------------------------------------------------------------------- + +export interface CreateTechnicianProfileInput { + userId: string; + phone?: string; + skills?: string[]; + zoneId?: string; + compensationModel?: CompensationModel; + monthlySalary?: number; +} + +export interface UpdateTechnicianProfileInput { + phone?: string; + skills?: string[]; + zoneId?: string | null; + compensationModel?: CompensationModel; + monthlySalary?: number | null; + isActive?: boolean; +} + +export interface ListTechniciansOptions { + activeOnly?: boolean; + zoneId?: string; +} + +// --------------------------------------------------------------------------- +// createTechnicianProfile +// --------------------------------------------------------------------------- + +/** + * Create a new TechnicianProfile for a user. + * + * Validates: + * - userId references an existing user with TECHNICIAN role + * - No existing profile for this user in this tenant + * - monthlySalary provided when compensationModel is SALARY or HYBRID + * + * @throws Error if user not found, lacks TECHNICIAN role, profile already exists, + * or salary missing for salary-based model + */ +export async function createTechnicianProfile( + tenantPrisma: TenantPrismaClient, + tenantId: string, + input: CreateTechnicianProfileInput +) { + const { + userId, + phone, + skills = [], + zoneId, + compensationModel = CompensationModel.PER_JOB, + monthlySalary, + } = input; + + // Validate user exists and has TECHNICIAN role + const user = await tenantPrisma.user.findFirst({ + where: { id: userId }, + select: { id: true, roles: true, firstName: true, lastName: true }, + }); + + if (!user) { + throw new Error(`User not found: ${userId}`); + } + + if (!user.roles.includes("TECHNICIAN")) { + throw new Error( + `User ${user.firstName} ${user.lastName} does not have TECHNICIAN role` + ); + } + + // Validate salary for salary-based models + if ( + (compensationModel === CompensationModel.SALARY || + compensationModel === CompensationModel.HYBRID) && + (monthlySalary === undefined || monthlySalary === null) + ) { + throw new Error( + `monthlySalary is required for compensation model ${compensationModel}` + ); + } + + // Check for existing profile + const existing = await tenantPrisma.technicianProfile.findFirst({ + where: { userId }, + }); + + if (existing) { + throw new Error( + `TechnicianProfile already exists for user ${userId} in this tenant` + ); + } + + return tenantPrisma.technicianProfile.create({ + data: { + tenantId, + userId, + phone: phone ?? null, + skills, + zoneId: zoneId ?? null, + compensationModel, + monthlySalary: monthlySalary !== undefined ? monthlySalary : null, + isActive: true, + }, + include: { + user: { + select: { id: true, firstName: true, lastName: true, email: true, roles: true }, + }, + zone: { + select: { id: true, name: true }, + }, + }, + }); +} + +// --------------------------------------------------------------------------- +// updateTechnicianProfile +// --------------------------------------------------------------------------- + +/** + * Update a TechnicianProfile's fields. + * Does not allow changing userId (identity of the profile). + * + * @throws Error if profile not found + */ +export async function updateTechnicianProfile( + tenantPrisma: TenantPrismaClient, + profileId: string, + input: UpdateTechnicianProfileInput +) { + const { phone, skills, zoneId, compensationModel, monthlySalary, isActive } = input; + + // Validate salary for salary-based models when changing model + if (compensationModel !== undefined) { + const currentProfile = await tenantPrisma.technicianProfile.findFirst({ + where: { id: profileId }, + select: { monthlySalary: true }, + }); + + const effectiveSalary = + monthlySalary !== undefined ? monthlySalary : currentProfile?.monthlySalary; + + if ( + (compensationModel === CompensationModel.SALARY || + compensationModel === CompensationModel.HYBRID) && + (effectiveSalary === undefined || effectiveSalary === null) + ) { + throw new Error( + `monthlySalary is required for compensation model ${compensationModel}` + ); + } + } + + const data: Record = {}; + if (phone !== undefined) data.phone = phone ?? null; + if (skills !== undefined) data.skills = skills; + if (zoneId !== undefined) data.zoneId = zoneId; + if (compensationModel !== undefined) data.compensationModel = compensationModel; + if (monthlySalary !== undefined) data.monthlySalary = monthlySalary; + if (isActive !== undefined) data.isActive = isActive; + + try { + return await tenantPrisma.technicianProfile.update({ + where: { id: profileId }, + data, + include: { + user: { + select: { id: true, firstName: true, lastName: true, email: true, roles: true }, + }, + zone: { + select: { id: true, name: true }, + }, + }, + }); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + if (message.includes("Record to update not found") || message.includes("P2025")) { + throw new Error(`TechnicianProfile not found: ${profileId}`); + } + throw err; + } +} + +// --------------------------------------------------------------------------- +// getTechnicianProfile +// --------------------------------------------------------------------------- + +/** + * Get a single TechnicianProfile by ID. + * Returns null if not found in tenant scope. + */ +export async function getTechnicianProfile( + tenantPrisma: TenantPrismaClient, + profileId: string +) { + return tenantPrisma.technicianProfile.findFirst({ + where: { id: profileId }, + include: { + user: { + select: { id: true, firstName: true, lastName: true, email: true, roles: true }, + }, + zone: { + select: { id: true, name: true }, + }, + }, + }); +} + +// --------------------------------------------------------------------------- +// getTechnicianProfileByUserId +// --------------------------------------------------------------------------- + +/** + * Get a TechnicianProfile by the technician's userId. + * Returns null if not found in tenant scope. + */ +export async function getTechnicianProfileByUserId( + tenantPrisma: TenantPrismaClient, + userId: string +) { + return tenantPrisma.technicianProfile.findFirst({ + where: { userId }, + include: { + user: { + select: { id: true, firstName: true, lastName: true, email: true, roles: true }, + }, + zone: { + select: { id: true, name: true }, + }, + }, + }); +} + +// --------------------------------------------------------------------------- +// listTechnicians +// --------------------------------------------------------------------------- + +/** + * List TechnicianProfiles with optional filters. + * Ordered by user lastName, firstName. + */ +export async function listTechnicians( + tenantPrisma: TenantPrismaClient, + options: ListTechniciansOptions = {} +) { + const { activeOnly = false, zoneId } = options; + + const where: Record = {}; + if (activeOnly) where.isActive = true; + if (zoneId !== undefined) where.zoneId = zoneId; + + const [profiles, total] = await Promise.all([ + tenantPrisma.technicianProfile.findMany({ + where, + include: { + user: { + select: { id: true, firstName: true, lastName: true, email: true, roles: true }, + }, + zone: { + select: { id: true, name: true }, + }, + }, + orderBy: [ + { user: { lastName: "asc" } }, + { user: { firstName: "asc" } }, + ], + }), + tenantPrisma.technicianProfile.count({ where }), + ]); + + return { profiles, total }; +}