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