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
|
* 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
|
* multiple services in sequence to prove they integrate correctly and
|
||||||
* produce accurate accounting entries.
|
* produce accurate accounting entries.
|
||||||
*
|
*
|
||||||
@@ -10,6 +10,8 @@
|
|||||||
* Workflow 1: Subscriber Registration -> Invoice Generation -> Payment Recording
|
* Workflow 1: Subscriber Registration -> Invoice Generation -> Payment Recording
|
||||||
* Workflow 2: Collector Collection -> Remittance Verification
|
* Workflow 2: Collector Collection -> Remittance Verification
|
||||||
* Workflow 3: Ticket Creation -> Job Order -> Completion -> Auto-Resolve
|
* 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):
|
* CLEANUP ORDER (comprehensive, covering all subsystems):
|
||||||
* ticketComments -> jobOrders -> tickets -> ticketCategories ->
|
* ticketComments -> jobOrders -> tickets -> ticketCategories ->
|
||||||
@@ -38,12 +40,15 @@ import {
|
|||||||
createRemittance,
|
createRemittance,
|
||||||
verifyRemittance,
|
verifyRemittance,
|
||||||
} from "@/lib/services/remittance-service";
|
} from "@/lib/services/remittance-service";
|
||||||
import { createTicket, transitionTicketStatus } from "@/lib/services/ticket-service";
|
import { createTicket, transitionTicketStatus, listTickets } from "@/lib/services/ticket-service";
|
||||||
import {
|
import {
|
||||||
createJobOrder,
|
createJobOrder,
|
||||||
updateJobOrderStatus,
|
updateJobOrderStatus,
|
||||||
} from "@/lib/services/job-order-service";
|
} from "@/lib/services/job-order-service";
|
||||||
import { DashboardService } from "@/lib/services/dashboard-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 {
|
import {
|
||||||
Prisma,
|
Prisma,
|
||||||
Role,
|
Role,
|
||||||
@@ -54,6 +59,10 @@ import {
|
|||||||
TicketSource,
|
TicketSource,
|
||||||
TicketStatus,
|
TicketStatus,
|
||||||
JobOrderStatus,
|
JobOrderStatus,
|
||||||
|
ItemTrackingType,
|
||||||
|
MovementType,
|
||||||
|
LocationType,
|
||||||
|
ExpensePaymentMethod,
|
||||||
} from "@prisma/client";
|
} from "@prisma/client";
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -683,3 +692,228 @@ describe("E2E: Ticket to Job Order Resolution", () => {
|
|||||||
expect(closed.closedAt).not.toBeNull();
|
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