diff --git a/src/lib/__tests__/integration/e2e-workflows.test.ts b/src/lib/__tests__/integration/e2e-workflows.test.ts new file mode 100644 index 0000000..6edaf0f --- /dev/null +++ b/src/lib/__tests__/integration/e2e-workflows.test.ts @@ -0,0 +1,463 @@ +/** + * End-to-End Workflow Integration Tests + * + * Tests the three critical ISP business processes end-to-end, exercising + * multiple services in sequence to prove they integrate correctly and + * produce accurate accounting entries. + * + * INFRA-04: Prove the system works as a coherent whole, not just isolated units. + * + * Workflow 1: Subscriber Registration -> Invoice Generation -> Payment Recording + * Workflow 2: Collector Collection -> Remittance Verification + * Workflow 3: Ticket Creation -> Job Order -> Completion -> Auto-Resolve + * + * CLEANUP ORDER (comprehensive, covering all subsystems): + * ticketComments -> jobOrders -> tickets -> ticketCategories -> + * collectionAllocations -> collections -> remittances -> + * paymentAllocations -> payments -> invoiceLines -> invoices -> + * journalEntryLines -> null reversesEntryId -> journalEntries -> + * stockMovements -> inventoryItems -> expenses -> vendors -> + * expenseCategories (custom) -> subscribers -> servicePlans -> + * tenantSettings -> accountingPeriods -> accounts -> + * zoneAssignments -> zones -> technicianProfiles -> jobTypeRates -> + * expenseCategories (system) -> users -> tenant + */ + +import { prisma } from "@/lib/prisma"; +import { withTenantContext } from "@/lib/prisma-tenant"; +import { seedChartOfAccounts } from "@/lib/accounting/seed-coa"; +import { JournalEntryService } from "@/lib/accounting/journal-entry-service"; +import { createSubscriber } from "@/lib/services/subscriber-service"; +import { + generateInvoiceForSubscriber, + computeBillingPeriod, +} from "@/lib/services/billing-service"; +import { recordPayment } from "@/lib/services/payment-service"; +import { recordCollection } from "@/lib/services/collector-service"; +import { + createRemittance, + verifyRemittance, +} from "@/lib/services/remittance-service"; +import { createTicket, transitionTicketStatus } from "@/lib/services/ticket-service"; +import { + createJobOrder, + updateJobOrderStatus, +} from "@/lib/services/job-order-service"; +import { DashboardService } from "@/lib/services/dashboard-service"; +import { + Prisma, + Role, + TenantStatus, + BillingType, + PaymentMethod, + TicketPriority, + TicketSource, + TicketStatus, + JobOrderStatus, +} from "@prisma/client"; + +// --------------------------------------------------------------------------- +// Shared test state +// --------------------------------------------------------------------------- + +const TS = Date.now(); + +let tenantId: string; +let adminUserId: string; +let officeStaffUserId: string; +let collectorUserId: string; +let technicianUserId: string; +let servicePlanId: string; +let zoneId: string; +let ticketCategoryId: string; + +// Billing workflow state +let subscriberId: string; +let subscriberAccountNumber: string; +let invoice1Id: string; +let invoice2Id: string; + +// Collection workflow state +let collectionSubscriberId: string; +let collectionInvoiceId: string; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function tp() { + return withTenantContext(tenantId); +} + +let invoiceCounter = 0; + +/** + * Helper to create a raw invoice for tests that need one without going + * through the full billing service (e.g., collection workflow). + */ +async function createRawInvoice( + subscriberIdArg: string, + amount: number, + status = "SENT" as const, +) { + invoiceCounter++; + const periodStart = new Date(Date.UTC(2025, 0, invoiceCounter)); + return prisma.invoice.create({ + data: { + tenantId, + invoiceNumber: `INV-E2E-${TS}-${invoiceCounter}`, + subscriberId: subscriberIdArg, + periodStart, + periodEnd: new Date(Date.UTC(2025, 0, invoiceCounter + 30)), + dueDate: new Date(Date.UTC(2025, 1, invoiceCounter)), + subtotal: new Prisma.Decimal(amount), + totalAmount: new Prisma.Decimal(amount), + amountPaid: new Prisma.Decimal(0), + status, + } as Record, + }); +} + +// --------------------------------------------------------------------------- +// Setup / Teardown +// --------------------------------------------------------------------------- + +beforeAll(async () => { + // 1. Create tenant + const tenant = await prisma.tenant.create({ + data: { + name: `E2E Workflow Test ${TS}`, + slug: `e2e-workflow-${TS}`, + ownerEmail: `e2e-${TS}@test.example`, + status: TenantStatus.ACTIVE, + }, + }); + tenantId = tenant.id; + + // 2. Seed COA + await prisma.$transaction(async (tx) => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + await seedChartOfAccounts(tx as any, tenantId); + }); + + // 3. Seed ticket categories + const cat = await prisma.ticketCategory.create({ + data: { name: "No Connection", tenantId, isActive: true }, + }); + ticketCategoryId = cat.id; + + // 4. Create users + const admin = await prisma.user.create({ + data: { + email: `e2e-admin-${TS}@test.example`, + passwordHash: "hashed", + firstName: "E2E", + lastName: "Admin", + tenantId, + roles: [Role.ADMIN], + isActive: true, + }, + }); + adminUserId = admin.id; + + const officeStaff = await prisma.user.create({ + data: { + email: `e2e-office-${TS}@test.example`, + passwordHash: "hashed", + firstName: "E2E", + lastName: "OfficeStaff", + tenantId, + roles: [Role.OFFICE_STAFF], + isActive: true, + }, + }); + officeStaffUserId = officeStaff.id; + + const collector = await prisma.user.create({ + data: { + email: `e2e-collector-${TS}@test.example`, + passwordHash: "hashed", + firstName: "E2E", + lastName: "Collector", + tenantId, + roles: [Role.COLLECTOR], + isActive: true, + }, + }); + collectorUserId = collector.id; + + const technician = await prisma.user.create({ + data: { + email: `e2e-tech-${TS}@test.example`, + passwordHash: "hashed", + firstName: "E2E", + lastName: "Technician", + tenantId, + roles: [Role.TECHNICIAN], + isActive: true, + }, + }); + technicianUserId = technician.id; + + // 5. Create service plan + const plan = await prisma.servicePlan.create({ + data: { + tenantId, + name: `Fiber 50 Mbps ${TS}`, + speed: "50 Mbps", + monthlyPrice: new Prisma.Decimal(1500), + billingType: BillingType.POSTPAID, + }, + }); + servicePlanId = plan.id; + + // 6. Create zone and assign collector + const zone = await prisma.zone.create({ + data: { tenantId, name: `Zone Alpha ${TS}` }, + }); + zoneId = zone.id; + + await prisma.zoneAssignment.create({ + data: { tenantId, userId: collectorUserId, zoneId }, + }); +}, 60000); + +afterAll(async () => { + // Comprehensive cleanup order covering all subsystems + await prisma.ticketComment.deleteMany({ where: { tenantId } }); + await prisma.jobOrder.deleteMany({ where: { tenantId } }); + await prisma.ticket.deleteMany({ where: { tenantId } }); + await prisma.ticketCategory.deleteMany({ where: { tenantId } }); + await prisma.collectionAllocation.deleteMany({ where: { tenantId } }); + await prisma.collection.deleteMany({ where: { tenantId } }); + await prisma.remittance.deleteMany({ where: { tenantId } }); + await prisma.paymentAllocation.deleteMany({ where: { tenantId } }); + await prisma.payment.deleteMany({ where: { tenantId } }); + await prisma.invoiceLine.deleteMany({ where: { tenantId } }); + await prisma.invoice.deleteMany({ where: { tenantId } }); + await prisma.journalEntryLine.deleteMany({ where: { tenantId } }); + await prisma.journalEntry.updateMany({ + where: { tenantId, reversesEntryId: { not: null } }, + data: { reversesEntryId: null }, + }); + await prisma.journalEntry.deleteMany({ where: { tenantId } }); + await prisma.stockMovement.deleteMany({ where: { tenantId } }); + await prisma.inventoryItem.deleteMany({ where: { tenantId } }); + await prisma.expense.deleteMany({ where: { tenantId } }); + await prisma.vendor.deleteMany({ where: { tenantId } }); + await prisma.expenseCategory.deleteMany({ + where: { tenantId, isSystemCategory: false }, + }); + await prisma.subscriber.deleteMany({ where: { tenantId } }); + await prisma.servicePlan.deleteMany({ where: { tenantId } }); + await prisma.tenantSettings.deleteMany({ where: { tenantId } }); + await prisma.accountingPeriod.deleteMany({ where: { tenantId } }); + await prisma.account.deleteMany({ where: { tenantId } }); + await prisma.zoneAssignment.deleteMany({ where: { tenantId } }); + await prisma.zone.deleteMany({ where: { tenantId } }); + await prisma.technicianProfile.deleteMany({ where: { tenantId } }); + await prisma.jobTypeRate.deleteMany({ where: { tenantId } }); + await prisma.expenseCategory.deleteMany({ where: { tenantId } }); + await prisma.user.deleteMany({ where: { tenantId } }); + await prisma.tenant.deleteMany({ where: { id: tenantId } }); +}); + +// ============================================================================= +// WORKFLOW 1: Subscriber Registration -> Invoice -> Payment -> Balanced Books +// ============================================================================= + +describe("E2E: Billing Workflow", () => { + test("1. Register a new subscriber", async () => { + const subscriber = await createSubscriber(tp(), { + firstName: "Juan", + lastName: "dela Cruz", + address: "123 Mango St, Barangay 1", + servicePlanId, + zoneId, + }); + + subscriberId = subscriber.id; + subscriberAccountNumber = subscriber.accountNumber; + + // Subscriber created with ACTIVE status + expect(subscriber.status).toBe("ACTIVE"); + // Account number generated with SUB-NNNN format + expect(subscriber.accountNumber).toMatch(/^SUB-\d{4,}$/); + // Correct plan assigned + expect(subscriber.servicePlanId).toBe(servicePlanId); + expect(subscriber.servicePlan.monthlyPrice.toNumber()).toBe(1500); + }); + + test("2. Generate invoice for subscriber", async () => { + // Load subscriber with plan for billing service + const subscriber = await tp().subscriber.findFirst({ + where: { id: subscriberId }, + include: { servicePlan: true }, + }); + expect(subscriber).not.toBeNull(); + + const period = computeBillingPeriod(new Date(), subscriber!.billingDay); + + const result = await generateInvoiceForSubscriber( + tp(), + tenantId, + subscriber!, + period, + adminUserId, + ); + + expect(result).not.toBeNull(); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const invoice = (result as any).invoice; + invoice1Id = invoice.id; + + // Invoice created with correct amount matching plan price + expect(new Prisma.Decimal(invoice.totalAmount).toNumber()).toBe(1500); + // Invoice has SENT status (unpaid) + expect(invoice.status).toBe("SENT"); + + // Verify JE posted: DR 1100 AR, CR 4010 Service Revenue + const trialBalance = await JournalEntryService.getTrialBalance({ + tenantPrisma: tp(), + }); + const arLine = trialBalance.find((l) => l.accountCode === "1100"); + const revLine = trialBalance.find((l) => l.accountCode === "4010"); + // AR should have a debit balance of 1500 + expect(arLine!.debitBalance.toNumber()).toBe(1500); + // Revenue should have a credit balance of 1500 + expect(revLine!.creditBalance.toNumber()).toBe(1500); + }); + + test("3. Record full payment against invoice", async () => { + const paymentResult = await recordPayment(tp(), tenantId, { + subscriberId, + amount: 1500, + paymentMethod: PaymentMethod.CASH, + paymentDate: new Date(), + idempotencyKey: `e2e-full-pay-${TS}`, + recordedById: adminUserId, + }); + + // Payment created with COMPLETED status + expect(paymentResult.payment.status).toBe("COMPLETED"); + expect(paymentResult.idempotent).toBe(false); + // Full amount allocated + expect(paymentResult.allocations.length).toBe(1); + expect(paymentResult.allocations[0].amount.toNumber()).toBe(1500); + + // Invoice status = PAID + const invoice = await tp().invoice.findFirst({ + where: { id: invoice1Id }, + }); + expect(invoice!.status).toBe("PAID"); + expect(new Prisma.Decimal(invoice!.amountPaid).toNumber()).toBe(1500); + + // JE posted: DR 1010 Cash, CR 1100 AR + const trialBalance = await JournalEntryService.getTrialBalance({ + tenantPrisma: tp(), + }); + const cashLine = trialBalance.find((l) => l.accountCode === "1010"); + // Cash should have debit balance of 1500 + expect(cashLine!.debitBalance.toNumber()).toBe(1500); + // AR should now be zero (1500 debit from invoice - 1500 credit from payment) + const arLine = trialBalance.find((l) => l.accountCode === "1100"); + expect(arLine!.debitBalance.toNumber()).toBe(0); + expect(arLine!.creditBalance.toNumber()).toBe(0); + }); + + test("4. Record partial payment on second invoice", async () => { + // Generate second invoice + const subscriber = await tp().subscriber.findFirst({ + where: { id: subscriberId }, + include: { servicePlan: true }, + }); + + // Use a different period to avoid idempotency skip + const period = { + periodStart: new Date(Date.UTC(2025, 5, 15)), + periodEnd: new Date(Date.UTC(2025, 6, 14)), + dueDate: new Date(Date.UTC(2025, 7, 14)), + }; + + const result2 = await generateInvoiceForSubscriber( + tp(), + tenantId, + subscriber!, + period, + adminUserId, + ); + expect(result2).not.toBeNull(); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + invoice2Id = (result2 as any).invoice.id; + + // Record partial payment (50%) + const paymentResult = await recordPayment(tp(), tenantId, { + subscriberId, + amount: 750, + paymentMethod: PaymentMethod.CASH, + paymentDate: new Date(), + idempotencyKey: `e2e-partial-pay-${TS}`, + recordedById: adminUserId, + }); + + expect(paymentResult.payment.status).toBe("COMPLETED"); + expect(paymentResult.allocations[0].amount.toNumber()).toBe(750); + + // Invoice status = PARTIAL, amountPaid = 750 + const invoice2 = await tp().invoice.findFirst({ + where: { id: invoice2Id }, + }); + expect(invoice2!.status).toBe("PARTIAL"); + expect(new Prisma.Decimal(invoice2!.amountPaid).toNumber()).toBe(750); + + // JE for partial amount is balanced + 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), + ); + expect(totalDebits.toNumber()).toBe(totalCredits.toNumber()); + }); + + test("5. Verify trial balance is balanced after all 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), + ); + + // Self-verifying books: debits === credits + expect(totalDebits.toNumber()).toBe(totalCredits.toNumber()); + // Should have meaningful balances (not all zeros) + expect(totalDebits.toNumber()).toBeGreaterThan(0); + }); + + test("6. Dashboard reflects billing activity", async () => { + // Revenue metrics + const revenue = await DashboardService.getRevenueMetrics(tp(), tenantId); + // revenueToday includes the payments made (1500 + 750 = 2250) + expect(revenue.revenueToday.toNumber()).toBeGreaterThanOrEqual(2250); + + // Subscriber metrics + const subscribers = await DashboardService.getSubscriberMetrics( + tp(), + tenantId, + ); + // Active count includes the registered subscriber + expect(subscribers.active).toBeGreaterThanOrEqual(1); + expect(subscribers.total).toBeGreaterThanOrEqual(1); + }); +});