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);
|
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