feat(05-05): collection/remittance and ticket/job-order e2e tests
- Collection workflow: field collection -> FIFO allocation -> remittance -> verification -> balanced JEs - Ticket workflow: create -> job order -> IN_PROGRESS -> COMPLETED -> auto-resolve -> close - Dashboard reflects collection activity (collectionsToday, unverifiedRemittances) - Trial balance balanced after every workflow - 16 tests total across 3 critical business processes (INFRA-04) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -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<string, unknown>,
|
||||
});
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user