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:
kevin-asprec
2026-03-05 17:42:13 +08:00
parent abf9ee0eae
commit 24824271a2

View File

@@ -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();
});
});