diff --git a/src/lib/__tests__/integration/e2e-workflows.test.ts b/src/lib/__tests__/integration/e2e-workflows.test.ts index 6edaf0f..5fa0a51 100644 --- a/src/lib/__tests__/integration/e2e-workflows.test.ts +++ b/src/lib/__tests__/integration/e2e-workflows.test.ts @@ -461,3 +461,225 @@ describe("E2E: Billing Workflow", () => { expect(subscribers.total).toBeGreaterThanOrEqual(1); }); }); + +// ============================================================================= +// WORKFLOW 2: Collector Collection -> Remittance Verification -> Balanced Books +// ============================================================================= + +describe("E2E: Collection & Remittance Workflow", () => { + test("1. Collector records field collection", async () => { + // Create a subscriber with an unpaid invoice for the collection workflow + const sub = await prisma.subscriber.create({ + data: { + tenantId, + accountNumber: `SUB-COL-${TS}`, + firstName: "Maria", + lastName: "Santos", + address: "456 Coconut Ave", + servicePlanId, + zoneId, + billingDay: 15, + status: "ACTIVE", + creditBalance: 0, + } as Record, + }); + collectionSubscriberId = sub.id; + + // Create unpaid invoice for this subscriber + const inv = await createRawInvoice(collectionSubscriberId, 1500); + collectionInvoiceId = inv.id; + + // Collector records field collection + const result = await recordCollection(tp(), tenantId, { + collectorId: collectorUserId, + subscriberId: collectionSubscriberId, + amount: 1500, + collectionDate: new Date(), + notes: "Collected at subscriber home", + }); + + // Collection created + expect(result.collection.id).toBeDefined(); + expect(result.collection.status).toBe("COMPLETED"); + // Invoice allocated via FIFO + expect(result.allocations.length).toBe(1); + expect(result.allocations[0].invoiceId).toBe(collectionInvoiceId); + expect(result.allocations[0].amount.toNumber()).toBe(1500); + + // JE posted: DR 1030 Cash in Transit, CR 1100 AR + const trialBalance = await JournalEntryService.getTrialBalance({ + tenantPrisma: tp(), + }); + const transitLine = trialBalance.find((l) => l.accountCode === "1030"); + expect(transitLine!.debitBalance.toNumber()).toBeGreaterThan(0); + }); + + test("2. Collector submits remittance", async () => { + const remittance = await createRemittance(tp(), tenantId, { + collectorId: collectorUserId, + remittanceDate: new Date(), + collectedTotal: 1500, + notes: "End of day remittance", + }); + + // Remittance created with SUBMITTED/PENDING status + expect(remittance.id).toBeDefined(); + expect(remittance.status).toBe("PENDING"); + expect(new Prisma.Decimal(remittance.collectedTotal).toNumber()).toBe(1500); + }); + + test("3. Office staff verifies remittance", async () => { + // Find the PENDING remittance + const pending = await tp().remittance.findFirst({ + where: { collectorId: collectorUserId, status: "PENDING" }, + }); + expect(pending).not.toBeNull(); + + const verified = await verifyRemittance(tp(), tenantId, pending!.id, { + verifiedById: officeStaffUserId, + verifiedTotal: 1500, + notes: "Cash counted and verified", + }); + + // Remittance status = VERIFIED + expect(verified.status).toBe("VERIFIED"); + // Variance = 0 (verified matches declared) + expect(new Prisma.Decimal(verified.variance).toNumber()).toBe(0); + + // JE posted: DR 1010 Cash on Hand, CR 1030 Cash in Transit + const trialBalance = await JournalEntryService.getTrialBalance({ + tenantPrisma: tp(), + }); + const cashLine = trialBalance.find((l) => l.accountCode === "1010"); + // Cash on hand increased by 1500 from remittance verification + expect(cashLine!.debitBalance.toNumber()).toBeGreaterThan(0); + }); + + test("4. Dashboard reflects collection activity", async () => { + const collectorSummary = await DashboardService.getCollectorSummary( + tp(), + tenantId, + ); + + // collectionsToday > 0 (we recorded a collection today) + expect(collectorSummary.collectionsToday.toNumber()).toBeGreaterThan(0); + // unverifiedRemittances = 0 (we verified the only remittance) + expect(collectorSummary.unverifiedRemittances).toBe(0); + }); + + test("5. Trial balance still balanced after collection workflow", async () => { + const trialBalance = await JournalEntryService.getTrialBalance({ + tenantPrisma: tp(), + }); + + const totalDebits = trialBalance.reduce( + (sum, l) => sum.plus(l.debitBalance), + new Prisma.Decimal(0), + ); + const totalCredits = trialBalance.reduce( + (sum, l) => sum.plus(l.creditBalance), + new Prisma.Decimal(0), + ); + + // Books remain balanced after collection + remittance workflow + expect(totalDebits.toNumber()).toBe(totalCredits.toNumber()); + expect(totalDebits.toNumber()).toBeGreaterThan(0); + }); +}); + +// ============================================================================= +// WORKFLOW 3: Ticket Creation -> Job Order -> Completion -> Auto-Resolve +// ============================================================================= + +describe("E2E: Ticket to Job Order Resolution", () => { + let ticketId: string; + let jobOrderId: string; + + test("1. Staff creates ticket from client call", async () => { + const ticket = await createTicket(tp(), tenantId, { + subject: "No internet connection since morning", + description: "Subscriber reports complete loss of connectivity. Router lights are blinking.", + categoryId: ticketCategoryId, + priority: TicketPriority.HIGH, + subscriberId, + createdById: officeStaffUserId, + source: TicketSource.STAFF, + }); + + ticketId = ticket.id; + + // Ticket created with OPEN status + expect(ticket.status).toBe("OPEN"); + // Sequential ticket number in TKT-NNNN format + expect(ticket.ticketNumber).toMatch(/^TKT-\d{4,}$/); + // Correct data saved + expect(ticket.subject).toBe("No internet connection since morning"); + expect(ticket.source).toBe("STAFF"); + }); + + test("2. Convert ticket to job order", async () => { + const jobOrder = await createJobOrder(tp(), tenantId, { + ticketId, + jobType: "REPAIR", + description: "Check ONU and fiber connection at subscriber premises", + assignedToId: technicianUserId, + scheduledDate: new Date(), + createdById: officeStaffUserId, + }); + + jobOrderId = jobOrder.id; + + // Job order created with PENDING status + expect(jobOrder.status).toBe("PENDING"); + // Sequential order number in JO-NNNN format + expect(jobOrder.orderNumber).toMatch(/^JO-\d{4,}$/); + // Assigned to the technician + expect(jobOrder.assignedToId).toBe(technicianUserId); + + // Ticket auto-transitions to ASSIGNED + const ticket = await tp().ticket.findFirst({ + where: { id: ticketId }, + }); + expect(ticket!.status).toBe("ASSIGNED"); + }); + + test("3. Technician completes job order", async () => { + // Transition to IN_PROGRESS + const inProgress = await updateJobOrderStatus(tp(), tenantId, jobOrderId, { + status: JobOrderStatus.IN_PROGRESS, + }); + expect(inProgress.status).toBe("IN_PROGRESS"); + + // Transition to COMPLETED with outcome notes + const completed = await updateJobOrderStatus(tp(), tenantId, jobOrderId, { + status: JobOrderStatus.COMPLETED, + outcomeNotes: "Replaced damaged fiber patch cord. Connection restored. Speed test: 52 Mbps.", + }); + expect(completed.status).toBe("COMPLETED"); + }); + + test("4. Ticket auto-resolves when all jobs complete", async () => { + // checkTicketAutoResolve is triggered by updateJobOrderStatus on COMPLETED + const ticket = await tp().ticket.findFirst({ + where: { id: ticketId }, + }); + + // Ticket should be RESOLVED (auto-resolved by checkTicketAutoResolve) + expect(ticket!.status).toBe("RESOLVED"); + // resolvedAt is set + expect(ticket!.resolvedAt).not.toBeNull(); + }); + + test("5. Staff closes resolved ticket", async () => { + const closed = await transitionTicketStatus( + tp(), + ticketId, + TicketStatus.CLOSED, + ); + + // Ticket status = CLOSED + expect(closed.status).toBe("CLOSED"); + // closedAt is set + expect(closed.closedAt).not.toBeNull(); + }); +});