From f0d2725dc2ce140aedd5eb7940c647c85e9c1f64 Mon Sep 17 00:00:00 2001 From: kevin-asprec Date: Thu, 5 Mar 2026 18:25:28 +0800 Subject: [PATCH] feat(05-07): add inventory/expense and portal ticket E2E workflows - Workflow 4: inventory receiving with balanced JE (DR 1200 / CR 2010) - Workflow 4: expense recording with auto-post JE (DR 5040 / CR 1010) - Workflow 4: trial balance verification after inventory/expense txns - Workflow 5: portal ticket creation via createPortalTicket service - Workflow 5: staff queue visibility and shadow user verification - 21 total E2E tests (up from 16), all passing Co-Authored-By: Claude Opus 4.6 --- .../integration/e2e-workflows.test.ts | 238 +++++++++++++++++- 1 file changed, 236 insertions(+), 2 deletions(-) diff --git a/src/lib/__tests__/integration/e2e-workflows.test.ts b/src/lib/__tests__/integration/e2e-workflows.test.ts index 5fa0a51..31c00e1 100644 --- a/src/lib/__tests__/integration/e2e-workflows.test.ts +++ b/src/lib/__tests__/integration/e2e-workflows.test.ts @@ -1,7 +1,7 @@ /** * End-to-End Workflow Integration Tests * - * Tests the three critical ISP business processes end-to-end, exercising + * Tests the five critical ISP business processes end-to-end, exercising * multiple services in sequence to prove they integrate correctly and * produce accurate accounting entries. * @@ -10,6 +10,8 @@ * Workflow 1: Subscriber Registration -> Invoice Generation -> Payment Recording * Workflow 2: Collector Collection -> Remittance Verification * Workflow 3: Ticket Creation -> Job Order -> Completion -> Auto-Resolve + * Workflow 4: Inventory Receiving -> Expense Recording -> Trial Balance + * Workflow 5: Portal Ticket Submission -> Staff Queue * * CLEANUP ORDER (comprehensive, covering all subsystems): * ticketComments -> jobOrders -> tickets -> ticketCategories -> @@ -38,12 +40,15 @@ import { createRemittance, verifyRemittance, } from "@/lib/services/remittance-service"; -import { createTicket, transitionTicketStatus } from "@/lib/services/ticket-service"; +import { createTicket, transitionTicketStatus, listTickets } from "@/lib/services/ticket-service"; import { createJobOrder, updateJobOrderStatus, } from "@/lib/services/job-order-service"; import { DashboardService } from "@/lib/services/dashboard-service"; +import { InventoryService } from "@/lib/services/inventory-service"; +import { ExpenseService } from "@/lib/services/expense-service"; +import { createPortalTicket } from "@/lib/services/portal-ticket-service"; import { Prisma, Role, @@ -54,6 +59,10 @@ import { TicketSource, TicketStatus, JobOrderStatus, + ItemTrackingType, + MovementType, + LocationType, + ExpensePaymentMethod, } from "@prisma/client"; // --------------------------------------------------------------------------- @@ -683,3 +692,228 @@ describe("E2E: Ticket to Job Order Resolution", () => { expect(closed.closedAt).not.toBeNull(); }); }); + +// ============================================================================= +// WORKFLOW 4: Inventory Receiving -> Expense Recording -> Trial Balance +// ============================================================================= + +describe("E2E: Inventory Receiving and Expense Recording", () => { + let inventoryItemId: string; + let expenseCategoryId: string; + let vendorId: string; + + test("1. Receives inventory items and creates balanced journal entries", async () => { + // Register an inventory item (batch type - e.g., fiber cables) + const item = await InventoryService.registerItem(tp(), tenantId, { + name: `Fiber Patch Cord ${TS}`, + itemType: "CABLE", + trackingType: ItemTrackingType.BATCH, + purchaseCost: 250, + purchaseDate: new Date(), + }); + + inventoryItemId = item.id; + expect(item.name).toContain("Fiber Patch Cord"); + expect(item.trackingType).toBe("BATCH"); + + // Record a RECEIVED movement (10 units into warehouse) + const movement = await InventoryService.recordMovement(tp(), tenantId, { + inventoryItemId: item.id, + movementType: MovementType.RECEIVED, + quantity: 10, + condition: "NEW", + toLocationType: LocationType.WAREHOUSE, + toLocationId: "main-warehouse", + notes: "Initial stock purchase", + performedById: adminUserId, + unitCost: 250, + }); + + // Movement created + expect(movement.id).toBeDefined(); + expect(movement.movementType).toBe("RECEIVED"); + expect(movement.quantity).toBe(10); + + // JE should have been created (DR 1200 Equipment Inventory, CR 2010 AP) + expect(movement.journalEntryId).not.toBeNull(); + + // Verify the JE is balanced via trial balance + const trialBalance = await JournalEntryService.getTrialBalance({ + tenantPrisma: tp(), + }); + const inventoryLine = trialBalance.find((l) => l.accountCode === "1200"); + const apLine = trialBalance.find((l) => l.accountCode === "2010"); + + // Equipment Inventory should have debit balance of 2500 (10 x 250) + expect(inventoryLine!.debitBalance.toNumber()).toBe(2500); + // AP should have credit balance of 2500 + expect(apLine!.creditBalance.toNumber()).toBe(2500); + + // Verify total debits === total credits + 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), + ); + expect(totalDebits.toNumber()).toBe(totalCredits.toNumber()); + }); + + test("2. Records an expense with automatic journal entry", async () => { + // Create a vendor + const vendor = await prisma.vendor.create({ + data: { + tenantId, + name: `ISP Bandwidth Provider ${TS}`, + contactPerson: "Vendor Contact", + email: `vendor-${TS}@test.example`, + } as Record, + }); + vendorId = vendor.id; + + // Create an expense category mapped to account 5040 (Bandwidth/Connectivity) + const category = await ExpenseService.createCategory(tp(), tenantId, { + name: `Bandwidth Cost ${TS}`, + description: "Monthly bandwidth expense", + accountCode: "5040", + }); + expenseCategoryId = category.id; + + // Create an expense (auto-posts since requireApproval defaults to false) + const expense = await ExpenseService.createExpense(tp(), tenantId, { + categoryId: expenseCategoryId, + vendorId, + amount: 5000, + expenseDate: new Date(), + description: "Monthly bandwidth for March 2026", + paymentMethod: ExpensePaymentMethod.CASH, + createdById: adminUserId, + }); + + // Expense created and auto-posted + expect(expense.id).toBeDefined(); + expect(expense.status).toBe("POSTED"); + expect(expense.journalEntryId).not.toBeNull(); + expect(new Prisma.Decimal(expense.amount).toNumber()).toBe(5000); + + // Verify the JE is balanced (DR 5040 Expense, CR 1010 Cash) + const trialBalance = await JournalEntryService.getTrialBalance({ + tenantPrisma: tp(), + }); + const expenseLine = trialBalance.find((l) => l.accountCode === "5040"); + // Expense account should have debit balance of 5000 + expect(expenseLine!.debitBalance.toNumber()).toBe(5000); + + // Total debits === total credits + 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), + ); + expect(totalDebits.toNumber()).toBe(totalCredits.toNumber()); + }); + + test("3. Trial balance remains balanced after inventory and expense transactions", 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 inventory + expense workflows + expect(totalDebits.toNumber()).toBe(totalCredits.toNumber()); + // Should have meaningful balances (not all zeros) + expect(totalDebits.toNumber()).toBeGreaterThan(0); + }); +}); + +// ============================================================================= +// WORKFLOW 5: Portal Ticket Submission -> Staff Queue +// ============================================================================= + +describe("E2E: Portal Ticket Submission to Staff Queue", () => { + let portalTicketId: string; + let portalSubscriberId: string; + let portalSubscriberAccountNumber: string; + + test("1. Subscriber creates portal ticket and it appears in staff ticket queue", async () => { + // Create a subscriber for the portal workflow + const sub = await createSubscriber(tp(), { + firstName: "Portal", + lastName: "Subscriber", + address: "789 Portal St", + servicePlanId, + zoneId, + }); + portalSubscriberId = sub.id; + portalSubscriberAccountNumber = sub.accountNumber; + + // Create a portal ticket as the subscriber + const ticket = await createPortalTicket(tp(), tenantId, portalSubscriberId, { + categoryId: ticketCategoryId, + subject: "Internet speed is slow", + description: "My download speed is only 10 Mbps instead of 50 Mbps.", + }); + + portalTicketId = ticket.id; + + // Ticket created with OPEN status and source=SUBSCRIBER + expect(ticket.status).toBe("OPEN"); + expect(ticket.source).toBe("SUBSCRIBER"); + expect(ticket.ticketNumber).toMatch(/^TKT-\d{4,}$/); + expect(ticket.subject).toBe("Internet speed is slow"); + expect(ticket.subscriberId).toBe(portalSubscriberId); + + // Verify ticket appears in staff ticket queue (listTickets = staff perspective) + const staffQueue = await listTickets(tp()); + const found = staffQueue.tickets.find( + (t: { id: string }) => t.id === portalTicketId, + ); + expect(found).toBeDefined(); + expect(found!.source).toBe("SUBSCRIBER"); + }); + + test("2. Portal ticket has correct subscriber association", async () => { + // Load the ticket to check createdById + const ticket = await tp().ticket.findFirst({ + where: { id: portalTicketId }, + include: { + createdBy: { select: { id: true, email: true, roles: true } }, + }, + }); + + expect(ticket).not.toBeNull(); + + // createdById links to the shadow portal user + expect(ticket!.createdBy).not.toBeNull(); + // Shadow user has CLIENT role + expect(ticket!.createdBy.roles).toContain(Role.CLIENT); + // Shadow user email follows portal convention + expect(ticket!.createdBy.email).toBe( + `portal-${portalSubscriberAccountNumber}@portal.local`, + ); + + // Verify the shadow user exists in the database + const shadowUser = await prisma.user.findFirst({ + where: { + email: `portal-${portalSubscriberAccountNumber}@portal.local`, + tenantId, + }, + }); + expect(shadowUser).not.toBeNull(); + expect(shadowUser!.roles).toContain(Role.CLIENT); + expect(shadowUser!.isActive).toBe(true); + }); +});