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:
kevin-asprec
2026-03-05 17:23:16 +08:00
parent 539564dbd4
commit 5b1b4ee0e1
2 changed files with 494 additions and 0 deletions

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

View 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");
});
});