test(04-05): comprehensive financial report tests — 16 passing, all 3 reports verified

- Trial Balance: debits = credits (isBalanced), per-account balances, asOfDate filter
- Income Statement: revenue/expense sections, net income = 600, header exclusion, date range
- Balance Sheet: A = L + E (isBalanced), cash = 600, net income in equity, date filter
- Drill-down: running balance, entry metadata (entryNumber, referenceType)
- Edge cases: empty tenant balanced, leaf-only accounts across all reports

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
kevin-asprec
2026-03-05 12:35:50 +08:00
parent 5366e8c1ad
commit 1dd423ca43

View File

@@ -0,0 +1,532 @@
/**
* Financial Report Service Integration Tests
*
* Tests the financial report engine:
* - Trial Balance: totalDebits === totalCredits (self-verifying)
* - Income Statement: revenue - expenses = net income
* - Balance Sheet: assets = liabilities + equity (balanced)
* - Drill-down: account entries with running balance
*
* Setup creates known transactions:
* 1. Invoice: DR 1100 AR, CR 4010 Revenue for 1000.00
* 2. Payment: DR 1010 Cash, CR 1100 AR for 1000.00
* 3. Expense: DR 5040 Internet Bandwidth, CR 1010 Cash for 300.00
* 4. Expense: DR 5090 Other Expense, CR 1010 Cash for 100.00
*
* Known balances:
* - Revenue (4010): 1000
* - Expenses: 400 total (5040=300, 5090=100)
* - Net Income: 600
* - Cash (1010): 600 (1000 received - 300 - 100)
* - AR (1100): 0 (1000 - 1000)
*
* CLEANUP ORDER:
* journalEntryLines -> null reversesEntryId -> journalEntries ->
* accountingPeriods -> accounts -> ticketCategories -> expenseCategories ->
* users -> tenant
*/
import { prisma } from "@/lib/prisma";
import { withTenantContext } from "@/lib/prisma-tenant";
import { seedChartOfAccounts } from "@/lib/accounting/seed-coa";
import { JournalEntryService } from "@/lib/accounting/journal-entry-service";
import { FinancialReportService } from "@/lib/services/financial-report-service";
import { Prisma, Role, TenantStatus, JournalEntrySource } from "@prisma/client";
// ---------------------------------------------------------------------------
// Shared test state
// ---------------------------------------------------------------------------
const TS = Date.now();
let tenantId: string;
let userId: string;
// Account IDs
let cashAccountId: string; // 1010
let arAccountId: string; // 1100
let revenueAccountId: string; // 4010
let bandwidthAccountId: string; // 5040
let otherExpenseAccountId: string; // 5090
// Test dates
const testDate = new Date("2026-02-15T00:00:00Z");
const testStartDate = new Date("2026-02-01T00:00:00Z");
const testEndDate = new Date("2026-02-28T23:59:59Z");
const futureDate = new Date("2026-04-15T00:00:00Z");
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function tp() {
return withTenantContext(tenantId);
}
// ---------------------------------------------------------------------------
// Setup / Teardown
// ---------------------------------------------------------------------------
beforeAll(async () => {
// Create tenant
const tenant = await prisma.tenant.create({
data: {
name: `FinReport Test Tenant ${TS}`,
slug: `finreport-test-${TS}`,
ownerEmail: `finreport-${TS}@test.example`,
status: TenantStatus.ACTIVE,
},
});
tenantId = tenant.id;
// Seed COA
await prisma.$transaction(async (tx) => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
await seedChartOfAccounts(tx as any, tenantId);
});
// Create admin user
const user = await prisma.user.create({
data: {
email: `finreport-admin-${TS}@test.example`,
passwordHash: "hashed",
firstName: "FinReport",
lastName: "Admin",
tenantId,
roles: [Role.ADMIN],
isActive: true,
},
});
userId = user.id;
// Look up account IDs
const accounts = await prisma.account.findMany({
where: { tenantId, code: { in: ["1010", "1100", "4010", "5040", "5090"] } },
select: { id: true, code: true },
});
const accountMap = new Map(accounts.map((a) => [a.code, a.id]));
cashAccountId = accountMap.get("1010")!;
arAccountId = accountMap.get("1100")!;
revenueAccountId = accountMap.get("4010")!;
bandwidthAccountId = accountMap.get("5040")!;
otherExpenseAccountId = accountMap.get("5090")!;
expect(cashAccountId).toBeDefined();
expect(arAccountId).toBeDefined();
expect(revenueAccountId).toBeDefined();
expect(bandwidthAccountId).toBeDefined();
expect(otherExpenseAccountId).toBeDefined();
// Create known transactions via JournalEntryService
const tenantPrisma = tp();
// 1. Invoice: DR 1100 AR 1000, CR 4010 Revenue 1000
await JournalEntryService.createEntry({
tenantPrisma,
tenantId,
date: testDate,
description: "Invoice #1 - Subscription",
lines: [
{ accountId: arAccountId, debit: 1000, credit: 0 },
{ accountId: revenueAccountId, debit: 0, credit: 1000 },
],
source: JournalEntrySource.SYSTEM,
referenceType: "Invoice",
createdById: userId,
});
// 2. Payment: DR 1010 Cash 1000, CR 1100 AR 1000
await JournalEntryService.createEntry({
tenantPrisma,
tenantId,
date: testDate,
description: "Payment received for Invoice #1",
lines: [
{ accountId: cashAccountId, debit: 1000, credit: 0 },
{ accountId: arAccountId, debit: 0, credit: 1000 },
],
source: JournalEntrySource.SYSTEM,
referenceType: "Payment",
createdById: userId,
});
// 3. Expense: DR 5040 Internet Bandwidth 300, CR 1010 Cash 300
await JournalEntryService.createEntry({
tenantPrisma,
tenantId,
date: testDate,
description: "Internet bandwidth payment",
lines: [
{ accountId: bandwidthAccountId, debit: 300, credit: 0 },
{ accountId: cashAccountId, debit: 0, credit: 300 },
],
source: JournalEntrySource.SYSTEM,
referenceType: "Expense",
createdById: userId,
});
// 4. Expense: DR 5090 Other Expense 100, CR 1010 Cash 100
await JournalEntryService.createEntry({
tenantPrisma,
tenantId,
date: testDate,
description: "Miscellaneous office expense",
lines: [
{ accountId: otherExpenseAccountId, debit: 100, credit: 0 },
{ accountId: cashAccountId, debit: 0, credit: 100 },
],
source: JournalEntrySource.SYSTEM,
referenceType: "Expense",
createdById: userId,
});
// 5. Future entry (for date filtering tests): DR 5090 200, CR 1010 200
await JournalEntryService.createEntry({
tenantPrisma,
tenantId,
date: futureDate,
description: "Future expense for date filter test",
lines: [
{ accountId: otherExpenseAccountId, debit: 200, credit: 0 },
{ accountId: cashAccountId, debit: 0, credit: 200 },
],
source: JournalEntrySource.SYSTEM,
referenceType: "Expense",
createdById: userId,
});
}, 30000);
afterAll(async () => {
// Cleanup order:
// journalEntryLines -> null reversesEntryId -> journalEntries ->
// accountingPeriods -> accounts -> ticketCategories -> expenseCategories ->
// users -> tenant
await prisma.journalEntryLine.deleteMany({ where: { tenantId } });
await prisma.journalEntry.updateMany({
where: { tenantId, reversesEntryId: { not: null } },
data: { reversesEntryId: null },
});
await prisma.journalEntry.deleteMany({ where: { tenantId } });
await prisma.accountingPeriod.deleteMany({ where: { tenantId } });
await prisma.account.deleteMany({ where: { tenantId } });
await prisma.ticketCategory.deleteMany({ where: { tenantId } });
await prisma.expenseCategory.deleteMany({ where: { tenantId } });
await prisma.user.deleteMany({ where: { tenantId } });
await prisma.tenant.deleteMany({ where: { id: tenantId } });
});
// ---------------------------------------------------------------------------
// Trial Balance Tests
// ---------------------------------------------------------------------------
describe("Trial Balance", () => {
it("totalDebits equals totalCredits (isBalanced = true)", async () => {
const report = await FinancialReportService.getTrialBalance(tp());
expect(report.isBalanced).toBe(true);
expect(report.totalDebits.equals(report.totalCredits)).toBe(true);
});
it("each account shows correct debit or credit balance", async () => {
const report = await FinancialReportService.getTrialBalance(tp());
const lineMap = new Map(report.lines.map((l) => [l.accountCode, l]));
// Cash 1010: debit balance = 1000 - 300 - 100 - 200 = 400 (includes future entry)
const cash = lineMap.get("1010")!;
expect(cash.debitBalance.toNumber()).toBe(400);
expect(cash.creditBalance.toNumber()).toBe(0);
// AR 1100: debit balance = 1000 - 1000 = 0
const ar = lineMap.get("1100")!;
expect(ar.debitBalance.toNumber()).toBe(0);
expect(ar.creditBalance.toNumber()).toBe(0);
// Revenue 4010: credit balance = 1000
const revenue = lineMap.get("4010")!;
expect(revenue.creditBalance.toNumber()).toBe(1000);
expect(revenue.debitBalance.toNumber()).toBe(0);
// Bandwidth 5040: debit balance = 300
const bandwidth = lineMap.get("5040")!;
expect(bandwidth.debitBalance.toNumber()).toBe(300);
// Other Expense 5090: debit balance = 100 + 200 = 300 (includes future entry)
const other = lineMap.get("5090")!;
expect(other.debitBalance.toNumber()).toBe(300);
});
it("asOfDate filter excludes entries after the date", async () => {
// As of end of February: excludes future April entry
const report = await FinancialReportService.getTrialBalance(tp(), {
asOfDate: testEndDate,
});
expect(report.isBalanced).toBe(true);
const lineMap = new Map(report.lines.map((l) => [l.accountCode, l]));
// Cash 1010: 1000 - 300 - 100 = 600 (future 200 excluded)
const cash = lineMap.get("1010")!;
expect(cash.debitBalance.toNumber()).toBe(600);
// Other Expense 5090: 100 only (future 200 excluded)
const other = lineMap.get("5090")!;
expect(other.debitBalance.toNumber()).toBe(100);
});
});
// ---------------------------------------------------------------------------
// Income Statement Tests
// ---------------------------------------------------------------------------
describe("Income Statement", () => {
it("revenue section shows Subscription Revenue = 1000", async () => {
const report = await FinancialReportService.getIncomeStatement(tp(), {
startDate: testStartDate,
endDate: testEndDate,
});
expect(report.revenue.accounts.length).toBeGreaterThanOrEqual(1);
const subRevenue = report.revenue.accounts.find((a) => a.code === "4010");
expect(subRevenue).toBeDefined();
expect(subRevenue!.balance.toNumber()).toBe(1000);
});
it("expense section shows correct expense accounts", async () => {
const report = await FinancialReportService.getIncomeStatement(tp(), {
startDate: testStartDate,
endDate: testEndDate,
});
const bandwidth = report.expenses.accounts.find((a) => a.code === "5040");
expect(bandwidth).toBeDefined();
expect(bandwidth!.balance.toNumber()).toBe(300);
const other = report.expenses.accounts.find((a) => a.code === "5090");
expect(other).toBeDefined();
expect(other!.balance.toNumber()).toBe(100);
});
it("netIncome = revenue total - expense total = 600", async () => {
const report = await FinancialReportService.getIncomeStatement(tp(), {
startDate: testStartDate,
endDate: testEndDate,
});
expect(report.revenue.total.toNumber()).toBe(1000);
expect(report.expenses.total.toNumber()).toBe(400);
expect(report.netIncome.toNumber()).toBe(600);
});
it("excludes header accounts (4000, 5000 not in report lines)", async () => {
const report = await FinancialReportService.getIncomeStatement(tp(), {
startDate: testStartDate,
endDate: testEndDate,
});
const allCodes = [
...report.revenue.accounts.map((a) => a.code),
...report.expenses.accounts.map((a) => a.code),
];
expect(allCodes).not.toContain("4000");
expect(allCodes).not.toContain("5000");
});
it("date range filtering excludes entries outside range", async () => {
// Use a narrow range that excludes all entries
const report = await FinancialReportService.getIncomeStatement(tp(), {
startDate: new Date("2026-01-01T00:00:00Z"),
endDate: new Date("2026-01-31T23:59:59Z"),
});
expect(report.revenue.total.toNumber()).toBe(0);
expect(report.expenses.total.toNumber()).toBe(0);
expect(report.netIncome.toNumber()).toBe(0);
expect(report.revenue.accounts.length).toBe(0);
expect(report.expenses.accounts.length).toBe(0);
});
});
// ---------------------------------------------------------------------------
// Balance Sheet Tests
// ---------------------------------------------------------------------------
describe("Balance Sheet", () => {
it("totalAssets = totalLiabilities + totalEquity (isBalanced = true)", async () => {
const report = await FinancialReportService.getBalanceSheet(tp(), {
asOfDate: testEndDate,
});
expect(report.isBalanced).toBe(true);
expect(
report.totalAssets.equals(report.totalLiabilitiesAndEquity)
).toBe(true);
});
it("assets section shows Cash on Hand = 600", async () => {
const report = await FinancialReportService.getBalanceSheet(tp(), {
asOfDate: testEndDate,
});
const cash = report.assets.accounts.find((a) => a.code === "1010");
expect(cash).toBeDefined();
expect(cash!.balance.toNumber()).toBe(600);
// AR should be 0 and thus omitted (non-zero balances only)
const ar = report.assets.accounts.find((a) => a.code === "1100");
expect(ar).toBeUndefined();
});
it("equity section includes computed Net Income line", async () => {
const report = await FinancialReportService.getBalanceSheet(tp(), {
asOfDate: testEndDate,
});
// Net income = 1000 revenue - 400 expenses = 600
expect(report.equity.netIncome.toNumber()).toBe(600);
// Total equity = equity account balances + net income
expect(report.equity.total.toNumber()).toBe(600);
});
it("asOfDate filtering works", async () => {
// Including future entries should change balances
const reportFuture = await FinancialReportService.getBalanceSheet(tp(), {
asOfDate: new Date("2026-12-31T23:59:59Z"),
});
const cash = reportFuture.assets.accounts.find((a) => a.code === "1010");
expect(cash).toBeDefined();
// Cash: 1000 - 300 - 100 - 200 = 400
expect(cash!.balance.toNumber()).toBe(400);
// Net income with future: 1000 - 400 - 200 = 400
expect(reportFuture.equity.netIncome.toNumber()).toBe(400);
// Still balanced
expect(reportFuture.isBalanced).toBe(true);
});
});
// ---------------------------------------------------------------------------
// Drill-down (Account Entries) Tests
// ---------------------------------------------------------------------------
describe("Account Entries (Drill-down)", () => {
it("Cash on Hand (1010) shows all entries with running balance", async () => {
const report = await FinancialReportService.getAccountEntries(tp(), {
accountId: cashAccountId,
startDate: testStartDate,
endDate: testEndDate,
});
expect(report.accountCode).toBe("1010");
expect(report.accountName).toBe("Cash on Hand");
expect(report.entries.length).toBe(3); // payment credit, 2 expense debits
// Running balance should end at 600
const lastEntry = report.entries[report.entries.length - 1];
expect(lastEntry.runningBalance.toNumber()).toBe(600);
// First entry is the payment (debit 1000 to cash)
expect(report.entries[0].debit.toNumber()).toBe(1000);
expect(report.entries[0].runningBalance.toNumber()).toBe(1000);
});
it("entries include entryNumber, description, referenceType", async () => {
const report = await FinancialReportService.getAccountEntries(tp(), {
accountId: cashAccountId,
startDate: testStartDate,
endDate: testEndDate,
});
for (const entry of report.entries) {
expect(entry.entryNumber).toBeDefined();
expect(entry.entryNumber).toMatch(/^JE-\d{4}-\d{4}$/);
expect(entry.description).toBeDefined();
expect(typeof entry.description).toBe("string");
// referenceType may be null but should be present
expect("referenceType" in entry).toBe(true);
}
// Check specific referenceTypes
const refTypes = report.entries.map((e) => e.referenceType);
expect(refTypes).toContain("Payment");
expect(refTypes).toContain("Expense");
});
});
// ---------------------------------------------------------------------------
// Edge Cases
// ---------------------------------------------------------------------------
describe("Edge Cases", () => {
it("empty tenant — Trial Balance returns lines with zero balances, isBalanced = true", async () => {
// Create a fresh empty tenant
const emptyTenant = await prisma.tenant.create({
data: {
name: `Empty FinReport Tenant ${TS}`,
slug: `empty-finreport-${TS}`,
ownerEmail: `empty-finreport-${TS}@test.example`,
status: TenantStatus.ACTIVE,
},
});
const emptyTenantId = emptyTenant.id;
try {
// Seed COA but no journal entries
await prisma.$transaction(async (tx) => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
await seedChartOfAccounts(tx as any, emptyTenantId);
});
const emptyTp = withTenantContext(emptyTenantId);
const report = await FinancialReportService.getTrialBalance(emptyTp);
expect(report.isBalanced).toBe(true);
expect(report.totalDebits.toNumber()).toBe(0);
expect(report.totalCredits.toNumber()).toBe(0);
// Should have lines (all zero balance)
expect(report.lines.length).toBeGreaterThan(0);
} finally {
// Cleanup empty tenant
await prisma.account.deleteMany({ where: { tenantId: emptyTenantId } });
await prisma.tenant.deleteMany({ where: { id: emptyTenantId } });
}
});
it("all reports return only leaf accounts (no category headers)", async () => {
const tb = await FinancialReportService.getTrialBalance(tp());
const is = await FinancialReportService.getIncomeStatement(tp(), {
startDate: testStartDate,
endDate: testEndDate,
});
const bs = await FinancialReportService.getBalanceSheet(tp(), {
asOfDate: testEndDate,
});
// Income Statement: no header codes
const isCodes = [
...is.revenue.accounts.map((a) => a.code),
...is.expenses.accounts.map((a) => a.code),
];
for (const code of isCodes) {
expect(code.endsWith("000")).toBe(false);
}
// Balance Sheet: no header codes
const bsCodes = [
...bs.assets.accounts.map((a) => a.code),
...bs.liabilities.accounts.map((a) => a.code),
...bs.equity.accounts.map((a) => a.code),
];
for (const code of bsCodes) {
expect(code.endsWith("000")).toBe(false);
}
// Trial Balance includes all accounts (including headers) — but that's by design
// since it uses JournalEntryService.getTrialBalance which returns all accounts
expect(tb.lines.length).toBeGreaterThan(0);
});
});