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 <noreply@anthropic.com>
This commit is contained in:
kevin-asprec
2026-03-05 18:25:28 +08:00
parent 9fdca2ccf7
commit f0d2725dc2

View File

@@ -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<string, unknown>,
});
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);
});
});