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:
424
src/lib/services/dashboard-service.ts
Normal file
424
src/lib/services/dashboard-service.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user