fix(04-05): add missing financial-report-service.ts to git
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
557
src/lib/services/financial-report-service.ts
Normal file
557
src/lib/services/financial-report-service.ts
Normal file
@@ -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<TrialBalanceReport> {
|
||||
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<string, AccountType>(
|
||||
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<IncomeStatementReport> {
|
||||
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<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),
|
||||
});
|
||||
}
|
||||
|
||||
// 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<BalanceSheetReport> {
|
||||
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<string, { debit: Prisma.Decimal; credit: Prisma.Decimal }>();
|
||||
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<string, { debit: Prisma.Decimal; credit: Prisma.Decimal }>();
|
||||
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<AccountEntriesReport> {
|
||||
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<string, Date> = {};
|
||||
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,
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user