diff --git a/src/lib/__tests__/financial-report-service.test.ts b/src/lib/__tests__/financial-report-service.test.ts new file mode 100644 index 0000000..c34823e --- /dev/null +++ b/src/lib/__tests__/financial-report-service.test.ts @@ -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); + }); +});