From 0316bb211f35250d6393886d1dd4f7fec54a0dce Mon Sep 17 00:00:00 2001 From: kevin-asprec Date: Thu, 5 Mar 2026 15:13:26 +0800 Subject: [PATCH] fix(04-05): add missing financial-report-service.ts to git Co-Authored-By: Claude Opus 4.6 --- src/lib/services/financial-report-service.ts | 557 +++++++++++++++++++ 1 file changed, 557 insertions(+) create mode 100644 src/lib/services/financial-report-service.ts diff --git a/src/lib/services/financial-report-service.ts b/src/lib/services/financial-report-service.ts new file mode 100644 index 0000000..9302809 --- /dev/null +++ b/src/lib/services/financial-report-service.ts @@ -0,0 +1,557 @@ +// ============================================================================= +// FinancialReportService — Trial Balance, Income Statement, Balance Sheet +// ============================================================================= +// +// All three reports are derived entirely from POSTED journal entry lines. +// No stored balances — every figure is computed from immutable JE history. +// +// Reports: +// - Trial Balance: all account balances, verifies debits = credits +// - Income Statement: revenue - expenses for a date range +// - Balance Sheet: assets = liabilities + equity as of a date +// - Drill-down: underlying JE lines for any account in a period +// ============================================================================= + +import { Prisma, JournalEntryStatus, NormalBalance, AccountType } from "@prisma/client"; +import { JournalEntryService, TrialBalanceLine } from "@/lib/accounting/journal-entry-service"; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +type TenantPrismaClient = any; + +export interface EnhancedTrialBalanceLine extends TrialBalanceLine { + accountType: AccountType; +} + +export interface TrialBalanceReport { + asOfDate: Date | null; + lines: EnhancedTrialBalanceLine[]; + totalDebits: Prisma.Decimal; + totalCredits: Prisma.Decimal; + isBalanced: boolean; +} + +export interface AccountLine { + code: string; + name: string; + balance: Prisma.Decimal; +} + +export interface IncomeStatementReport { + startDate: Date; + endDate: Date; + revenue: { + accounts: AccountLine[]; + total: Prisma.Decimal; + }; + expenses: { + accounts: AccountLine[]; + total: Prisma.Decimal; + }; + netIncome: Prisma.Decimal; +} + +export interface BalanceSheetReport { + asOfDate: Date; + assets: { + accounts: AccountLine[]; + total: Prisma.Decimal; + }; + liabilities: { + accounts: AccountLine[]; + total: Prisma.Decimal; + }; + equity: { + accounts: AccountLine[]; + total: Prisma.Decimal; + netIncome: Prisma.Decimal; + }; + totalAssets: Prisma.Decimal; + totalLiabilitiesAndEquity: Prisma.Decimal; + isBalanced: boolean; +} + +export interface AccountEntryLine { + entryNumber: string; + date: Date; + description: string; + debit: Prisma.Decimal; + credit: Prisma.Decimal; + runningBalance: Prisma.Decimal; + referenceType: string | null; +} + +export interface AccountEntriesReport { + accountCode: string; + accountName: string; + entries: AccountEntryLine[]; +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +/** + * Check if an account code is a category header (ends in "000" like 1000, 2000, etc.) + */ +function isHeaderAccount(code: string): boolean { + return code.endsWith("000"); +} + +/** + * Compare two Decimal values as integer cents to avoid floating point issues. + */ +function equalsInCents(a: Prisma.Decimal, b: Prisma.Decimal): boolean { + const aCents = Math.round(a.toNumber() * 100); + const bCents = Math.round(b.toNumber() * 100); + return aCents === bCents; +} + +// --------------------------------------------------------------------------- +// FinancialReportService +// --------------------------------------------------------------------------- + +export class FinancialReportService { + /** + * Trial Balance — all account balances as of a date. + * + * Delegates to JournalEntryService.getTrialBalance for the core data, + * then enhances with accountType and computed totals. + * + * isBalanced is the self-verifying invariant: totalDebits === totalCredits. + */ + static async getTrialBalance( + tenantPrisma: TenantPrismaClient, + options: { asOfDate?: Date } = {} + ): Promise { + const { asOfDate } = options; + + // Get base trial balance from JournalEntryService + const lines = await JournalEntryService.getTrialBalance({ + tenantPrisma, + asOfDate, + }); + + // Fetch account types for enhancement + const accounts = await tenantPrisma.account.findMany({ + select: { id: true, accountType: true }, + }); + const accountTypeMap = new Map( + accounts.map((a: { id: string; accountType: AccountType }) => [a.id, a.accountType]) + ); + + // Enhance lines with accountType + const enhancedLines: EnhancedTrialBalanceLine[] = lines.map((line) => ({ + ...line, + accountType: accountTypeMap.get(line.accountId) ?? AccountType.ASSET, + })); + + // Compute totals + let totalDebits = new Prisma.Decimal(0); + let totalCredits = new Prisma.Decimal(0); + for (const line of enhancedLines) { + totalDebits = totalDebits.plus(line.debitBalance); + totalCredits = totalCredits.plus(line.creditBalance); + } + + return { + asOfDate: asOfDate ?? null, + lines: enhancedLines, + totalDebits, + totalCredits, + isBalanced: equalsInCents(totalDebits, totalCredits), + }; + } + + /** + * Income Statement — revenue minus expenses for a date range. + * + * Queries JE lines for REVENUE (4xxx) and EXPENSE (5xxx) accounts + * within the date range. Groups by account, computes section totals + * and net income. + * + * Only includes leaf accounts (excludes category headers like 4000, 5000). + */ + static async getIncomeStatement( + tenantPrisma: TenantPrismaClient, + options: { startDate: Date; endDate: Date } + ): Promise { + const { startDate, endDate } = options; + + // Fetch revenue and expense accounts (leaf only — exclude headers) + const accounts = await tenantPrisma.account.findMany({ + where: { + accountType: { in: [AccountType.REVENUE, AccountType.EXPENSE] }, + }, + select: { + id: true, + code: true, + name: true, + accountType: true, + normalBalance: true, + }, + orderBy: { code: "asc" }, + }); + + // Filter out header accounts + const leafAccounts = accounts.filter( + (a: { code: string }) => !isHeaderAccount(a.code) + ); + + // 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: startDate, + lte: endDate, + }, + }, + }, + _sum: { + debit: true, + credit: true, + }, + }); + + const aggregateMap = new Map(); + 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), + }); + } + + // Build revenue and expense sections + const revenueAccounts: AccountLine[] = []; + const expenseAccounts: AccountLine[] = []; + + for (const account of leafAccounts) { + const agg = aggregateMap.get(account.id); + if (!agg) continue; // No activity in period — skip + + let balance: Prisma.Decimal; + if (account.normalBalance === NormalBalance.CREDIT) { + // Revenue: normal CREDIT balance = credit - debit + balance = agg.credit.minus(agg.debit); + } else { + // Expense: normal DEBIT balance = debit - credit + balance = agg.debit.minus(agg.credit); + } + + // Skip zero balances + if (balance.equals(new Prisma.Decimal(0))) continue; + + const line: AccountLine = { + code: account.code, + name: account.name, + balance, + }; + + if (account.accountType === AccountType.REVENUE) { + revenueAccounts.push(line); + } else { + expenseAccounts.push(line); + } + } + + // Compute totals + const revenueTotal = revenueAccounts.reduce( + (sum, a) => sum.plus(a.balance), + new Prisma.Decimal(0) + ); + const expenseTotal = expenseAccounts.reduce( + (sum, a) => sum.plus(a.balance), + new Prisma.Decimal(0) + ); + const netIncome = revenueTotal.minus(expenseTotal); + + return { + startDate, + endDate, + revenue: { accounts: revenueAccounts, total: revenueTotal }, + expenses: { accounts: expenseAccounts, total: expenseTotal }, + netIncome, + }; + } + + /** + * Balance Sheet — assets = liabilities + equity as of a date. + * + * Queries JE lines for ASSET, LIABILITY, and EQUITY accounts + * as of the given date. Includes computed Net Income line in equity. + * + * Only includes leaf accounts with non-zero balances. + */ + static async getBalanceSheet( + tenantPrisma: TenantPrismaClient, + options: { asOfDate: Date } + ): Promise { + const { asOfDate } = options; + + // Fetch balance sheet accounts (leaf only) + const bsAccounts = await tenantPrisma.account.findMany({ + where: { + accountType: { in: [AccountType.ASSET, AccountType.LIABILITY, AccountType.EQUITY] }, + }, + select: { + id: true, + code: true, + name: true, + accountType: true, + normalBalance: true, + }, + orderBy: { code: "asc" }, + }); + + const leafBsAccounts = bsAccounts.filter( + (a: { code: string }) => !isHeaderAccount(a.code) + ); + + // Aggregate JE lines grouped by account up to asOfDate + const bsAggregates = await tenantPrisma.journalEntryLine.groupBy({ + by: ["accountId"], + where: { + accountId: { in: leafBsAccounts.map((a: { id: string }) => a.id) }, + journalEntry: { + status: JournalEntryStatus.POSTED, + date: { lte: asOfDate }, + }, + }, + _sum: { + debit: true, + credit: true, + }, + }); + + const bsAggMap = new Map(); + for (const agg of bsAggregates) { + bsAggMap.set(agg.accountId, { + debit: new Prisma.Decimal(agg._sum.debit ?? 0), + credit: new Prisma.Decimal(agg._sum.credit ?? 0), + }); + } + + // Build sections + const assetAccounts: AccountLine[] = []; + const liabilityAccounts: AccountLine[] = []; + const equityAccounts: AccountLine[] = []; + + for (const account of leafBsAccounts) { + const agg = bsAggMap.get(account.id); + if (!agg) continue; + + let balance: Prisma.Decimal; + if (account.normalBalance === NormalBalance.DEBIT) { + balance = agg.debit.minus(agg.credit); + } else { + balance = agg.credit.minus(agg.debit); + } + + // Skip zero balances + if (balance.equals(new Prisma.Decimal(0))) continue; + + const line: AccountLine = { + code: account.code, + name: account.name, + balance, + }; + + if (account.accountType === AccountType.ASSET) { + assetAccounts.push(line); + } else if (account.accountType === AccountType.LIABILITY) { + liabilityAccounts.push(line); + } else { + equityAccounts.push(line); + } + } + + // Compute net income for the period up to asOfDate + // (revenue - expenses, same as income statement but from beginning of time) + const revenueExpenseAccounts = await tenantPrisma.account.findMany({ + where: { + accountType: { in: [AccountType.REVENUE, AccountType.EXPENSE] }, + }, + select: { id: true, accountType: true, normalBalance: true, code: true }, + }); + + const leafRevenueExpense = revenueExpenseAccounts.filter( + (a: { code: string }) => !isHeaderAccount(a.code) + ); + + const reAggregates = await tenantPrisma.journalEntryLine.groupBy({ + by: ["accountId"], + where: { + accountId: { in: leafRevenueExpense.map((a: { id: string }) => a.id) }, + journalEntry: { + status: JournalEntryStatus.POSTED, + date: { lte: asOfDate }, + }, + }, + _sum: { + debit: true, + credit: true, + }, + }); + + const reAggMap = new Map(); + for (const agg of reAggregates) { + reAggMap.set(agg.accountId, { + debit: new Prisma.Decimal(agg._sum.debit ?? 0), + credit: new Prisma.Decimal(agg._sum.credit ?? 0), + }); + } + + let totalRevenue = new Prisma.Decimal(0); + let totalExpenses = new Prisma.Decimal(0); + + for (const account of leafRevenueExpense) { + const agg = reAggMap.get(account.id); + if (!agg) continue; + + if (account.accountType === AccountType.REVENUE) { + totalRevenue = totalRevenue.plus(agg.credit.minus(agg.debit)); + } else { + totalExpenses = totalExpenses.plus(agg.debit.minus(agg.credit)); + } + } + + const netIncome = totalRevenue.minus(totalExpenses); + + // Compute section totals + const assetsTotal = assetAccounts.reduce( + (sum, a) => sum.plus(a.balance), + new Prisma.Decimal(0) + ); + const liabilitiesTotal = liabilityAccounts.reduce( + (sum, a) => sum.plus(a.balance), + new Prisma.Decimal(0) + ); + const equityAccountsTotal = equityAccounts.reduce( + (sum, a) => sum.plus(a.balance), + new Prisma.Decimal(0) + ); + + // Total equity includes equity accounts + net income + const equityTotal = equityAccountsTotal.plus(netIncome); + const totalLiabilitiesAndEquity = liabilitiesTotal.plus(equityTotal); + + return { + asOfDate, + assets: { accounts: assetAccounts, total: assetsTotal }, + liabilities: { accounts: liabilityAccounts, total: liabilitiesTotal }, + equity: { + accounts: equityAccounts, + total: equityTotal, + netIncome, + }, + totalAssets: assetsTotal, + totalLiabilitiesAndEquity, + isBalanced: equalsInCents(assetsTotal, totalLiabilitiesAndEquity), + }; + } + + /** + * Drill-down: all POSTED JE lines for a specific account within a date range. + * + * Includes parent JE details and computes a running balance ordered by + * date ASC, then createdAt ASC for same-date entries. + */ + static async getAccountEntries( + tenantPrisma: TenantPrismaClient, + options: { accountId: string; startDate?: Date; endDate?: Date } + ): Promise { + const { accountId, startDate, endDate } = options; + + // Fetch account info + const account = await tenantPrisma.account.findFirst({ + where: { id: accountId }, + select: { id: true, code: true, name: true, normalBalance: true }, + }); + + if (!account) { + throw new Error(`Account not found: ${accountId}`); + } + + // Build date filter + const dateConditions: Record = {}; + if (startDate) dateConditions.gte = startDate; + if (endDate) dateConditions.lte = endDate; + const dateFilter = + Object.keys(dateConditions).length > 0 ? { date: dateConditions } : {}; + + // Fetch JE lines with parent JE details + const lines = await tenantPrisma.journalEntryLine.findMany({ + where: { + accountId, + journalEntry: { + status: JournalEntryStatus.POSTED, + ...dateFilter, + }, + }, + include: { + journalEntry: { + select: { + entryNumber: true, + date: true, + description: true, + source: true, + referenceType: true, + createdAt: true, + }, + }, + }, + orderBy: [ + { journalEntry: { date: "asc" } }, + { journalEntry: { createdAt: "asc" } }, + ], + }); + + // Build entries with running balance + let runningBalance = new Prisma.Decimal(0); + const entries: AccountEntryLine[] = lines.map( + (line: { + debit: Prisma.Decimal; + credit: Prisma.Decimal; + journalEntry: { + entryNumber: string; + date: Date; + description: string; + referenceType: string | null; + }; + }) => { + const debit = new Prisma.Decimal(line.debit); + const credit = new Prisma.Decimal(line.credit); + + // Update running balance based on normal balance direction + if (account.normalBalance === NormalBalance.DEBIT) { + runningBalance = runningBalance.plus(debit).minus(credit); + } else { + runningBalance = runningBalance.plus(credit).minus(debit); + } + + return { + entryNumber: line.journalEntry.entryNumber, + date: line.journalEntry.date, + description: line.journalEntry.description, + debit, + credit, + runningBalance: new Prisma.Decimal(runningBalance.toString()), + referenceType: line.journalEntry.referenceType, + }; + } + ); + + return { + accountCode: account.code, + accountName: account.name, + entries, + }; + } +}