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:
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user