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:
532
src/lib/__tests__/financial-report-service.test.ts
Normal file
532
src/lib/__tests__/financial-report-service.test.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user