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 <noreply@anthropic.com>
This commit is contained in:
49
src/app/api/dashboard/route.ts
Normal file
49
src/app/api/dashboard/route.ts
Normal file
@@ -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 });
|
||||
}
|
||||
}
|
||||
);
|
||||
445
src/lib/__tests__/dashboard-service.test.ts
Normal file
445
src/lib/__tests__/dashboard-service.test.ts
Normal file
@@ -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<string, unknown>,
|
||||
});
|
||||
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<string, unknown>,
|
||||
});
|
||||
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<string, unknown>,
|
||||
});
|
||||
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<string, unknown>,
|
||||
});
|
||||
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<string, unknown>,
|
||||
});
|
||||
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<string, unknown>,
|
||||
});
|
||||
}, 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");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user