From 7fabb77ee4b6cf6c5954ebae874924f92357b776 Mon Sep 17 00:00:00 2001 From: kevin-asprec Date: Thu, 5 Mar 2026 17:21:34 +0800 Subject: [PATCH] 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 --- src/lib/services/dashboard-service.ts | 424 ++++++++++++++++++++++++++ 1 file changed, 424 insertions(+) create mode 100644 src/lib/services/dashboard-service.ts diff --git a/src/lib/services/dashboard-service.ts b/src/lib/services/dashboard-service.ts new file mode 100644 index 0000000..849b231 --- /dev/null +++ b/src/lib/services/dashboard-service.ts @@ -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 { + 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 { + const overdueInvoices = await tenantPrisma.invoice.findMany({ + where: { status: InvoiceStatus.OVERDUE }, + select: { + subscriberId: true, + totalAmount: true, + amountPaid: true, + }, + }); + + const uniqueSubscribers = new Set(); + 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 { + 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 { + 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 { + 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 { + 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, + }; + } +}