feat(05-01): add DashboardService with metric aggregation

- getRevenueMetrics: today + month revenue from COMPLETED payments
- getOverdueMetrics: overdue subscriber count + outstanding total
- getSubscriberMetrics: active/suspended/cancelled status breakdown
- getCashFlowSummary: money in vs out from POSTED JE lines
- getCollectorSummary: today's collections + unverified remittances
- getDashboardSummary: aggregator calling all five methods in parallel

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
kevin-asprec
2026-03-05 17:21:34 +08:00
parent ea58620c7d
commit 7fabb77ee4

View File

@@ -0,0 +1,424 @@
// =============================================================================
// DashboardService — ISP Owner Dashboard Metric Aggregation
// =============================================================================
//
// Aggregates financial and operational metrics for the single-page business
// health overview (DASH-01, DASH-02, DASH-03, DASH-04).
//
// All metrics are derived from existing data — no new stored balances.
// Uses Prisma aggregate/groupBy for efficiency.
//
// Methods:
// - getRevenueMetrics: Revenue collected today and this month
// - getOverdueMetrics: Overdue subscriber count and total outstanding
// - getSubscriberMetrics: Active/suspended/cancelled breakdown
// - getCashFlowSummary: Money in vs money out from JE lines
// - getCollectorSummary: Today's collections and unverified remittances
// - getDashboardSummary: Aggregator calling all five methods
// =============================================================================
import {
Prisma,
PaymentStatus,
InvoiceStatus,
SubscriberStatus,
JournalEntryStatus,
AccountType,
NormalBalance,
CollectionStatus,
RemittanceStatus,
} from "@prisma/client";
// eslint-disable-next-line @typescript-eslint/no-explicit-any
type TenantPrismaClient = any;
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
export interface RevenueMetrics {
revenueToday: Prisma.Decimal;
revenueThisMonth: Prisma.Decimal;
}
export interface OverdueMetrics {
overdueCount: number;
totalOutstanding: Prisma.Decimal;
}
export interface SubscriberMetrics {
active: number;
suspended: number;
cancelled: number;
total: number;
}
export interface CashFlowSummary {
moneyIn: Prisma.Decimal;
moneyOut: Prisma.Decimal;
netCashFlow: Prisma.Decimal;
startDate: Date;
endDate: Date;
}
export interface CollectorSummaryMetrics {
collectionsToday: Prisma.Decimal;
unverifiedRemittances: number;
}
export interface DashboardSummary {
revenue: RevenueMetrics;
overdue: OverdueMetrics;
subscribers: SubscriberMetrics;
cashFlow: CashFlowSummary;
collectors: CollectorSummaryMetrics;
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
/**
* Get UTC start-of-day for a given date.
*/
function startOfDayUTC(date: Date): Date {
const d = new Date(date);
d.setUTCHours(0, 0, 0, 0);
return d;
}
/**
* Get UTC end-of-day for a given date.
*/
function endOfDayUTC(date: Date): Date {
const d = new Date(date);
d.setUTCHours(23, 59, 59, 999);
return d;
}
/**
* Get start of the current month in UTC.
*/
function startOfMonthUTC(date: Date): Date {
const d = new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), 1));
return d;
}
/**
* Get end of the current month in UTC.
*/
function endOfMonthUTC(date: Date): Date {
const d = new Date(
Date.UTC(date.getUTCFullYear(), date.getUTCMonth() + 1, 0, 23, 59, 59, 999)
);
return d;
}
/**
* Check if an account code is a category header (ends in "000").
*/
function isHeaderAccount(code: string): boolean {
return code.endsWith("000");
}
// ---------------------------------------------------------------------------
// DashboardService
// ---------------------------------------------------------------------------
export class DashboardService {
/**
* DASH-01: Revenue collected today and this month.
*
* Queries Payment table for COMPLETED payments, grouped by date range.
* revenueToday: sum where createdAt is today (midnight to now UTC)
* revenueThisMonth: sum where createdAt is current month
*/
static async getRevenueMetrics(
tenantPrisma: TenantPrismaClient,
tenantId: string
): Promise<RevenueMetrics> {
const now = new Date();
const todayStart = startOfDayUTC(now);
const todayEnd = endOfDayUTC(now);
const monthStart = startOfMonthUTC(now);
const monthEnd = endOfMonthUTC(now);
// Revenue today
const todayAgg = await tenantPrisma.payment.aggregate({
where: {
status: PaymentStatus.COMPLETED,
createdAt: { gte: todayStart, lte: todayEnd },
},
_sum: { amount: true },
});
// Revenue this month
const monthAgg = await tenantPrisma.payment.aggregate({
where: {
status: PaymentStatus.COMPLETED,
createdAt: { gte: monthStart, lte: monthEnd },
},
_sum: { amount: true },
});
return {
revenueToday: new Prisma.Decimal(todayAgg._sum.amount ?? 0),
revenueThisMonth: new Prisma.Decimal(monthAgg._sum.amount ?? 0),
};
}
/**
* DASH-02: Overdue subscriber count and total outstanding amount.
*
* Queries Invoice table for OVERDUE status invoices.
* Counts distinct subscriberIds and sums (totalAmount - amountPaid).
*/
static async getOverdueMetrics(
tenantPrisma: TenantPrismaClient,
tenantId: string
): Promise<OverdueMetrics> {
const overdueInvoices = await tenantPrisma.invoice.findMany({
where: { status: InvoiceStatus.OVERDUE },
select: {
subscriberId: true,
totalAmount: true,
amountPaid: true,
},
});
const uniqueSubscribers = new Set<string>();
let totalOutstanding = new Prisma.Decimal(0);
for (const inv of overdueInvoices) {
uniqueSubscribers.add(inv.subscriberId);
const outstanding = new Prisma.Decimal(inv.totalAmount).minus(
new Prisma.Decimal(inv.amountPaid)
);
totalOutstanding = totalOutstanding.plus(outstanding);
}
return {
overdueCount: uniqueSubscribers.size,
totalOutstanding,
};
}
/**
* DASH-03: Subscriber status breakdown.
*
* Queries Subscriber table grouped by status.
*/
static async getSubscriberMetrics(
tenantPrisma: TenantPrismaClient,
tenantId: string
): Promise<SubscriberMetrics> {
const groups = await tenantPrisma.subscriber.groupBy({
by: ["status"],
_count: { id: true },
});
let active = 0;
let suspended = 0;
let cancelled = 0;
for (const group of groups) {
switch (group.status) {
case SubscriberStatus.ACTIVE:
active = group._count.id;
break;
case SubscriberStatus.SUSPENDED:
suspended = group._count.id;
break;
case SubscriberStatus.CANCELLED:
cancelled = group._count.id;
break;
}
}
return {
active,
suspended,
cancelled,
total: active + suspended + cancelled,
};
}
/**
* DASH-04: Cash flow summary — money in vs money out.
*
* Money in: sum of POSTED JE lines on REVENUE accounts (4xxx codes),
* using normal balance logic (credit - debit for CREDIT normal balance).
* Money out: sum of POSTED JE lines on EXPENSE accounts (5xxx codes),
* using normal balance logic (debit - credit for DEBIT normal balance).
*
* Same approach as FinancialReportService.getIncomeStatement.
* Default date range: current month.
*/
static async getCashFlowSummary(
tenantPrisma: TenantPrismaClient,
tenantId: string,
startDate?: Date,
endDate?: Date
): Promise<CashFlowSummary> {
const now = new Date();
const effectiveStart = startDate ?? startOfMonthUTC(now);
const effectiveEnd = endDate ?? endOfMonthUTC(now);
// Fetch revenue and expense leaf accounts
const accounts = await tenantPrisma.account.findMany({
where: {
accountType: { in: [AccountType.REVENUE, AccountType.EXPENSE] },
},
select: {
id: true,
code: true,
accountType: true,
normalBalance: true,
},
});
const leafAccounts = accounts.filter(
(a: { code: string }) => !isHeaderAccount(a.code)
);
if (leafAccounts.length === 0) {
return {
moneyIn: new Prisma.Decimal(0),
moneyOut: new Prisma.Decimal(0),
netCashFlow: new Prisma.Decimal(0),
startDate: effectiveStart,
endDate: effectiveEnd,
};
}
// Aggregate JE lines grouped by account for the date range
const lineAggregates = await tenantPrisma.journalEntryLine.groupBy({
by: ["accountId"],
where: {
accountId: { in: leafAccounts.map((a: { id: string }) => a.id) },
journalEntry: {
status: JournalEntryStatus.POSTED,
date: {
gte: effectiveStart,
lte: effectiveEnd,
},
},
},
_sum: {
debit: true,
credit: true,
},
});
const aggregateMap = new Map<
string,
{ debit: Prisma.Decimal; credit: Prisma.Decimal }
>();
for (const agg of lineAggregates) {
aggregateMap.set(agg.accountId, {
debit: new Prisma.Decimal(agg._sum.debit ?? 0),
credit: new Prisma.Decimal(agg._sum.credit ?? 0),
});
}
let moneyIn = new Prisma.Decimal(0);
let moneyOut = new Prisma.Decimal(0);
for (const account of leafAccounts) {
const agg = aggregateMap.get(account.id);
if (!agg) continue;
let balance: Prisma.Decimal;
if (account.normalBalance === NormalBalance.CREDIT) {
// Revenue: credit - debit
balance = agg.credit.minus(agg.debit);
} else {
// Expense: debit - credit
balance = agg.debit.minus(agg.credit);
}
if (balance.equals(new Prisma.Decimal(0))) continue;
if (account.accountType === AccountType.REVENUE) {
moneyIn = moneyIn.plus(balance);
} else {
moneyOut = moneyOut.plus(balance);
}
}
return {
moneyIn,
moneyOut,
netCashFlow: moneyIn.minus(moneyOut),
startDate: effectiveStart,
endDate: effectiveEnd,
};
}
/**
* Collector summary: today's collections and unverified remittances.
*
* collectionsToday: sum of non-voided Collection amounts where createdAt is today
* unverifiedRemittances: count of Remittance records with status PENDING (not VERIFIED)
*/
static async getCollectorSummary(
tenantPrisma: TenantPrismaClient,
tenantId: string
): Promise<CollectorSummaryMetrics> {
const now = new Date();
const todayStart = startOfDayUTC(now);
const todayEnd = endOfDayUTC(now);
// Sum of non-voided collections today
const collectionAgg = await tenantPrisma.collection.aggregate({
where: {
status: CollectionStatus.COMPLETED,
createdAt: { gte: todayStart, lte: todayEnd },
},
_sum: { amount: true },
});
// Count of unverified remittances (PENDING status)
const unverifiedCount = await tenantPrisma.remittance.count({
where: {
status: RemittanceStatus.PENDING,
},
});
return {
collectionsToday: new Prisma.Decimal(collectionAgg._sum.amount ?? 0),
unverifiedRemittances: unverifiedCount,
};
}
/**
* Aggregator: calls all five metric methods and returns a single dashboard object.
*/
static async getDashboardSummary(
tenantPrisma: TenantPrismaClient,
tenantId: string,
options?: { startDate?: Date; endDate?: Date }
): Promise<DashboardSummary> {
const [revenue, overdue, subscribers, cashFlow, collectors] =
await Promise.all([
DashboardService.getRevenueMetrics(tenantPrisma, tenantId),
DashboardService.getOverdueMetrics(tenantPrisma, tenantId),
DashboardService.getSubscriberMetrics(tenantPrisma, tenantId),
DashboardService.getCashFlowSummary(
tenantPrisma,
tenantId,
options?.startDate,
options?.endDate
),
DashboardService.getCollectorSummary(tenantPrisma, tenantId),
]);
return {
revenue,
overdue,
subscribers,
cashFlow,
collectors,
};
}
}