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:
kevin-asprec
2026-03-05 15:13:26 +08:00
parent df3e36b7a2
commit 0316bb211f

View 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,
};
}
}