From 5b1b4ee0e147a448655d13be48fa459c82d00e49 Mon Sep 17 00:00:00 2001 From: kevin-asprec Date: Thu, 5 Mar 2026 17:23:16 +0800 Subject: [PATCH] feat(05-01): add Dashboard API route and integration tests - GET /api/dashboard with read Report permission (ADMIN, OFFICE_STAFF) - Optional startDate/endDate query params for cash flow date range - 6 integration tests verifying all metric methods against seeded data - Tests cover revenue, overdue, subscriber status, cash flow, collectors - Comprehensive cleanup order for dashboard test data Co-Authored-By: Claude Opus 4.6 --- src/app/api/dashboard/route.ts | 49 +++ src/lib/__tests__/dashboard-service.test.ts | 445 ++++++++++++++++++++ 2 files changed, 494 insertions(+) create mode 100644 src/app/api/dashboard/route.ts create mode 100644 src/lib/__tests__/dashboard-service.test.ts diff --git a/src/app/api/dashboard/route.ts b/src/app/api/dashboard/route.ts new file mode 100644 index 0000000..e071678 --- /dev/null +++ b/src/app/api/dashboard/route.ts @@ -0,0 +1,49 @@ +/** + * GET /api/dashboard + * + * Returns all dashboard metrics for the ISP owner's business health overview. + * Aggregates revenue, overdue, subscriber status, cash flow, and collector metrics. + * + * Query params: + * startDate? - ISO date string for cash flow start (default: start of current month) + * endDate? - ISO date string for cash flow end (default: end of current month) + * + * Access: ADMIN and OFFICE_STAFF (read Report permission). + */ +import { NextRequest, NextResponse } from "next/server"; +import { withPermission } from "@/lib/middleware/authorize"; +import { withTenantContext } from "@/lib/prisma-tenant"; +import { DashboardService } from "@/lib/services/dashboard-service"; + +export const GET = withPermission("read", "Report")( + async (req: NextRequest, { user }) => { + if (!user.tenantId) { + return NextResponse.json( + { error: "No tenant context -- super-admins must use the admin API" }, + { status: 400 } + ); + } + + const { searchParams } = new URL(req.url); + const startDateParam = searchParams.get("startDate"); + const endDateParam = searchParams.get("endDate"); + + const tenantPrisma = withTenantContext(user.tenantId); + + try { + const summary = await DashboardService.getDashboardSummary( + tenantPrisma, + user.tenantId, + { + startDate: startDateParam ? new Date(startDateParam) : undefined, + endDate: endDateParam ? new Date(endDateParam) : undefined, + } + ); + return NextResponse.json(summary); + } catch (err) { + const message = + err instanceof Error ? err.message : "Failed to load dashboard"; + return NextResponse.json({ error: message }, { status: 500 }); + } + } +); diff --git a/src/lib/__tests__/dashboard-service.test.ts b/src/lib/__tests__/dashboard-service.test.ts new file mode 100644 index 0000000..a8fc236 --- /dev/null +++ b/src/lib/__tests__/dashboard-service.test.ts @@ -0,0 +1,445 @@ +/** + * Dashboard Service Integration Tests + * + * Tests the DashboardService metric aggregation methods against a live + * PostgreSQL database with seeded data. + * + * Setup: + * - Create tenant, admin user, collector user + * - Seed COA and expense categories + * - Create subscriber (active), suspended subscriber + * - Generate invoice (overdue), record payment + * - Create collection via collector, create PENDING remittance + * - Create expense (POSTED, creates JE lines for cash flow) + * + * CLEANUP ORDER: + * expenses -> vendors -> expenseCategories (custom) -> collectionAllocations -> + * collections -> remittances -> paymentAllocations -> payments -> + * invoiceLines -> invoices -> journalEntryLines -> + * null reversesEntryId -> journalEntries -> zoneAssignments -> + * subscribers -> zones -> servicePlans -> accountingPeriods -> accounts -> + * ticketCategories -> expenseCategories (system) -> users -> tenant + */ + +import { prisma } from "@/lib/prisma"; +import { withTenantContext } from "@/lib/prisma-tenant"; +import { seedChartOfAccounts } from "@/lib/accounting/seed-coa"; +import { DashboardService } from "@/lib/services/dashboard-service"; +import { JournalEntryService } from "@/lib/accounting/journal-entry-service"; +import { + Prisma, + Role, + TenantStatus, + BillingType, + InvoiceStatus, + PaymentMethod, + PaymentStatus, + JournalEntrySource, + CollectionStatus, + RemittanceStatus, + SubscriberStatus, + ExpensePaymentMethod, +} from "@prisma/client"; + +// --------------------------------------------------------------------------- +// Shared test state +// --------------------------------------------------------------------------- + +const TS = Date.now(); + +let tenantId: string; +let adminUserId: string; +let collectorUserId: string; + +// Entity IDs +let subscriberActiveId: string; +let subscriberSuspendedId: string; +let servicePlanId: string; +let zoneId: string; +let invoiceId: string; +let paymentId: string; +let collectionId: string; + +// Account IDs +let cashAccountId: string; // 1010 +let bankAccountId: string; // 1020 +let cashInTransitId: string; // 1030 +let arAccountId: string; // 1100 +let revenueAccountId: string; // 4010 +let expenseAccountId: string; // 5040 + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function tp() { + return withTenantContext(tenantId); +} + +// --------------------------------------------------------------------------- +// Setup / Teardown +// --------------------------------------------------------------------------- + +beforeAll(async () => { + // Create tenant + const tenant = await prisma.tenant.create({ + data: { + name: `Dashboard Test ${TS}`, + slug: `dashboard-test-${TS}`, + ownerEmail: `dashboard-${TS}@test.example`, + status: TenantStatus.ACTIVE, + }, + }); + tenantId = tenant.id; + + // Seed COA + await prisma.$transaction(async (tx) => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + await seedChartOfAccounts(tx as any, tenantId); + }); + + // Seed ticket categories (required for cleanup) + await prisma.ticketCategory.createMany({ + data: [{ name: "Test Category", tenantId }], + }); + + // Seed expense categories + const defaultExpenseCategories = [ + { name: "Internet Bandwidth", accountCode: "5040", isSystemCategory: true, tenantId }, + { name: "Equipment & Supplies", accountCode: "5030", isSystemCategory: true, tenantId }, + { name: "Salary & Wages", accountCode: "5010", isSystemCategory: true, tenantId }, + { name: "Technician Compensation", accountCode: "5020", isSystemCategory: true, tenantId }, + { name: "Office Supplies", accountCode: "5050", isSystemCategory: true, tenantId }, + { name: "Utilities", accountCode: "5060", isSystemCategory: true, tenantId }, + { name: "Fuel & Transportation", accountCode: "5080", isSystemCategory: true, tenantId }, + { name: "Rent", accountCode: "5085", isSystemCategory: true, tenantId }, + { name: "Other", accountCode: "5090", isSystemCategory: true, tenantId }, + ]; + await prisma.expenseCategory.createMany({ data: defaultExpenseCategories }); + + // Look up account IDs + const accounts = await prisma.account.findMany({ + where: { tenantId, code: { in: ["1010", "1020", "1030", "1100", "4010", "5040"] } }, + select: { id: true, code: true }, + }); + const accountMap = new Map(accounts.map((a) => [a.code, a.id])); + cashAccountId = accountMap.get("1010")!; + bankAccountId = accountMap.get("1020")!; + cashInTransitId = accountMap.get("1030")!; + arAccountId = accountMap.get("1100")!; + revenueAccountId = accountMap.get("4010")!; + expenseAccountId = accountMap.get("5040")!; + + // Create admin user + const admin = await prisma.user.create({ + data: { + email: `dash-admin-${TS}@test.example`, + passwordHash: "hashed", + firstName: "Dash", + lastName: "Admin", + tenantId, + roles: [Role.ADMIN], + isActive: true, + }, + }); + adminUserId = admin.id; + + // Create collector user + const collector = await prisma.user.create({ + data: { + email: `dash-collector-${TS}@test.example`, + passwordHash: "hashed", + firstName: "Dash", + lastName: "Collector", + tenantId, + roles: [Role.COLLECTOR], + isActive: true, + }, + }); + collectorUserId = collector.id; + + // Create zone + zone assignment for collector + const zone = await prisma.zone.create({ + data: { tenantId, name: `Dash Zone ${TS}` }, + }); + zoneId = zone.id; + + await prisma.zoneAssignment.create({ + data: { tenantId, userId: collectorUserId, zoneId }, + }); + + // Create service plan + const plan = await prisma.servicePlan.create({ + data: { + tenantId, + name: `Dash Plan ${TS}`, + speed: "50 Mbps", + monthlyPrice: new Prisma.Decimal(1500), + billingType: BillingType.POSTPAID, + }, + }); + servicePlanId = plan.id; + + // Create active subscriber in the zone + const activeSub = await prisma.subscriber.create({ + data: { + tenantId, + accountNumber: `SUB-DASH-A-${TS}`, + firstName: "Active", + lastName: "Subscriber", + address: "123 Active St", + servicePlanId, + zoneId, + billingDay: 15, + status: SubscriberStatus.ACTIVE, + } as Record, + }); + subscriberActiveId = activeSub.id; + + // Create suspended subscriber + const suspSub = await prisma.subscriber.create({ + data: { + tenantId, + accountNumber: `SUB-DASH-S-${TS}`, + firstName: "Suspended", + lastName: "Subscriber", + address: "456 Suspended St", + servicePlanId, + billingDay: 15, + status: SubscriberStatus.SUSPENDED, + } as Record, + }); + subscriberSuspendedId = suspSub.id; + + // Create overdue invoice for the active subscriber (past due date) + const invoice = await prisma.invoice.create({ + data: { + tenantId, + invoiceNumber: `INV-DASH-${TS}`, + subscriberId: subscriberActiveId, + periodStart: new Date("2026-01-01"), + periodEnd: new Date("2026-01-31"), + dueDate: new Date("2026-02-15"), + subtotal: new Prisma.Decimal(1500), + totalAmount: new Prisma.Decimal(1500), + amountPaid: new Prisma.Decimal(0), + status: InvoiceStatus.OVERDUE, + } as Record, + }); + invoiceId = invoice.id; + + // Record a payment today (for revenue metrics) + const payment = await prisma.payment.create({ + data: { + tenantId, + subscriberId: subscriberActiveId, + amount: new Prisma.Decimal(500), + paymentMethod: PaymentMethod.CASH, + paymentDate: new Date(), + idempotencyKey: `pay-dash-${TS}`, + recordedById: adminUserId, + status: PaymentStatus.COMPLETED, + } as Record, + }); + paymentId = payment.id; + + // Create a payment JE (for revenue: DR Cash, CR AR) + await JournalEntryService.createEntry({ + tenantPrisma: tp(), + tenantId, + date: new Date(), + description: "Payment received for dashboard test", + source: JournalEntrySource.SYSTEM, + referenceType: "Payment", + referenceId: paymentId, + createdById: adminUserId, + lines: [ + { accountId: cashAccountId, debit: 500, credit: 0, description: "Cash received" }, + { accountId: arAccountId, debit: 0, credit: 500, description: "AR reduced" }, + ], + }); + + // Create a revenue JE (for cash flow: DR Cash, CR Revenue) + await JournalEntryService.createEntry({ + tenantPrisma: tp(), + tenantId, + date: new Date(), + description: "Monthly subscription revenue", + source: JournalEntrySource.SYSTEM, + referenceType: "Invoice", + referenceId: invoiceId, + createdById: adminUserId, + lines: [ + { accountId: arAccountId, debit: 1500, credit: 0, description: "AR for invoice" }, + { accountId: revenueAccountId, debit: 0, credit: 1500, description: "Subscription revenue" }, + ], + }); + + // Create an expense JE (for cash flow money out: DR Expense, CR Bank) + await JournalEntryService.createEntry({ + tenantPrisma: tp(), + tenantId, + date: new Date(), + description: "Bandwidth expense for dashboard test", + source: JournalEntrySource.SYSTEM, + referenceType: "Expense", + referenceId: "expense-dash-" + TS, + createdById: adminUserId, + lines: [ + { accountId: expenseAccountId, debit: 800, credit: 0, description: "Bandwidth cost" }, + { accountId: bankAccountId, debit: 0, credit: 800, description: "Bank payment" }, + ], + }); + + // Create a collection today (for collector summary) + const collection = await prisma.collection.create({ + data: { + tenantId, + collectorId: collectorUserId, + subscriberId: subscriberActiveId, + amount: new Prisma.Decimal(300), + collectionDate: new Date(), + status: CollectionStatus.COMPLETED, + } as Record, + }); + collectionId = collection.id; + + // Create a PENDING remittance (for unverified count) + await prisma.remittance.create({ + data: { + tenantId, + collectorId: collectorUserId, + remittanceDate: new Date(), + collectedTotal: new Prisma.Decimal(300), + status: RemittanceStatus.PENDING, + } as Record, + }); +}, 60000); + +afterAll(async () => { + // Cleanup in comprehensive order + await prisma.expense.deleteMany({ where: { tenantId } }); + await prisma.vendor.deleteMany({ where: { tenantId } }); + await prisma.expenseCategory.deleteMany({ where: { tenantId, isSystemCategory: false } }); + 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.zoneAssignment.deleteMany({ where: { tenantId } }); + await prisma.subscriber.deleteMany({ where: { tenantId } }); + await prisma.zone.deleteMany({ where: { tenantId } }); + await prisma.servicePlan.deleteMany({ where: { tenantId } }); + await prisma.accountingPeriod.deleteMany({ where: { tenantId } }); + await prisma.account.deleteMany({ where: { tenantId } }); + await prisma.ticketCategory.deleteMany({ where: { tenantId } }); + await prisma.expenseCategory.deleteMany({ where: { tenantId } }); + await prisma.user.deleteMany({ where: { tenantId } }); + await prisma.tenant.deleteMany({ where: { id: tenantId } }); +}); + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe("getRevenueMetrics", () => { + it("returns today's revenue from COMPLETED payments", async () => { + const result = await DashboardService.getRevenueMetrics(tp(), tenantId); + + // We created a 500 payment today + expect(result.revenueToday.toNumber()).toBeGreaterThanOrEqual(500); + expect(result.revenueThisMonth.toNumber()).toBeGreaterThanOrEqual(500); + }); +}); + +describe("getOverdueMetrics", () => { + it("counts overdue invoices and outstanding amount", async () => { + const result = await DashboardService.getOverdueMetrics(tp(), tenantId); + + // We created 1 overdue invoice with 1500 total, 0 amountPaid + expect(result.overdueCount).toBe(1); + expect(result.totalOutstanding.toNumber()).toBe(1500); + }); +}); + +describe("getSubscriberMetrics", () => { + it("returns correct status breakdown", async () => { + const result = await DashboardService.getSubscriberMetrics(tp(), tenantId); + + expect(result.active).toBe(1); + expect(result.suspended).toBe(1); + expect(result.cancelled).toBe(0); + expect(result.total).toBe(2); + }); +}); + +describe("getCashFlowSummary", () => { + it("computes money in and money out from JE lines", async () => { + const now = new Date(); + const startDate = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), 1)); + const endDate = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth() + 1, 0, 23, 59, 59, 999)); + + const result = await DashboardService.getCashFlowSummary( + tp(), + tenantId, + startDate, + endDate + ); + + // Revenue JE: 1500 credit to 4010 (money in) + // Expense JE: 800 debit to 5040 (money out) + expect(result.moneyIn.toNumber()).toBe(1500); + expect(result.moneyOut.toNumber()).toBe(800); + expect(result.netCashFlow.toNumber()).toBe(700); + }); +}); + +describe("getCollectorSummary", () => { + it("returns today's collections and unverified remittance count", async () => { + const result = await DashboardService.getCollectorSummary(tp(), tenantId); + + // We created 1 collection of 300 today + expect(result.collectionsToday.toNumber()).toBeGreaterThanOrEqual(300); + // We created 1 PENDING remittance + expect(result.unverifiedRemittances).toBeGreaterThanOrEqual(1); + }); +}); + +describe("getDashboardSummary", () => { + it("aggregates all metrics into a single object", async () => { + const result = await DashboardService.getDashboardSummary(tp(), tenantId); + + // Revenue + expect(result.revenue).toBeDefined(); + expect(result.revenue.revenueToday).toBeDefined(); + expect(result.revenue.revenueThisMonth).toBeDefined(); + + // Overdue + expect(result.overdue).toBeDefined(); + expect(result.overdue.overdueCount).toBeGreaterThanOrEqual(1); + expect(result.overdue.totalOutstanding.toNumber()).toBeGreaterThanOrEqual(0); + + // Subscribers + expect(result.subscribers).toBeDefined(); + expect(result.subscribers.total).toBeGreaterThanOrEqual(2); + + // Cash Flow + expect(result.cashFlow).toBeDefined(); + expect(result.cashFlow.moneyIn).toBeDefined(); + expect(result.cashFlow.moneyOut).toBeDefined(); + expect(result.cashFlow.netCashFlow).toBeDefined(); + + // Collectors + expect(result.collectors).toBeDefined(); + expect(result.collectors.collectionsToday).toBeDefined(); + expect(typeof result.collectors.unverifiedRemittances).toBe("number"); + }); +});