Files
cross-financial-management/tests/integration/reports.spec.ts
T
Joshua OosthuizenandClaude Sonnet 5 eb42eb8fc8 Add Reporting Suite: Trial Balance, P&L, Balance Sheet, Aged AR/AP
Each report is served off the existing posted-ledger data (no schema
changes) and respects the existing Main/branch scope selector, same as
every other module. Balance Sheet computes a live "Current Year
Earnings" equity line from unclosed P&L, since there's no year-end
close step yet.

Backed by a new integration test suite that verifies the trial balance
and balance sheet stay in balance under real posted AR/AP activity,
that P&L correctly scopes income to its period, and that aging buckets
customers/suppliers by days overdue.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JWqiUSojMLUoN7mrJqdEbN
2026-09-04 10:52:08 +02:00

159 lines
6.5 KiB
TypeScript

/**
* Requires a real, migrated, seeded Postgres database (see SETUP.md) reachable via DATABASE_URL.
* Run with: npm run test:integration
*/
import { PrismaClient } from '@prisma/client';
import * as arInvoicesService from '../../src/modules/ar/arInvoices.service';
import * as apInvoicesService from '../../src/modules/ap/apInvoices.service';
import * as reportsService from '../../src/modules/reports/reports.service';
const prisma = new PrismaClient();
async function getAccount(accountCode: string) {
return prisma.gLAccount.findUniqueOrThrow({ where: { accountCode } });
}
const MAIN = { mode: 'MAIN' as const };
describe('Reports — Trial Balance / P&L / Balance Sheet / Aged AR & AP (real DB)', () => {
let adminId: number;
let branchHQId: number;
let salesAccount: { id: number };
let expenseAccount: { id: number };
let customerId: number;
let supplierId: number;
let stdTaxCodeId: number;
beforeAll(async () => {
const admin = await prisma.user.findUniqueOrThrow({ where: { email: 'joosth2002@icloud.com' } });
adminId = admin.id;
salesAccount = await getAccount('4000-000-000-HQ');
expenseAccount = await getAccount('5100-000-000-HQ');
const hqSegmentValue = await prisma.accountSegmentValue.findFirstOrThrow({
where: { code: 'HQ', segmentDef: { isBranchSegment: true } },
});
branchHQId = hqSegmentValue.id;
const stdTaxCode = await prisma.taxCode.findUniqueOrThrow({ where: { code: 'STD' } });
stdTaxCodeId = stdTaxCode.id;
const customer = await prisma.customer.create({
data: { code: `RPT-AR-${Date.now()}`, name: 'Reports Test Customer', branchSegmentValueId: branchHQId },
});
customerId = customer.id;
const supplier = await prisma.supplier.create({
data: { code: `RPT-AP-${Date.now()}`, name: 'Reports Test Supplier', branchSegmentValueId: branchHQId },
});
supplierId = supplier.id;
});
afterAll(async () => {
await prisma.$disconnect();
});
it('keeps the trial balance in balance after posting activity', async () => {
const invoiceDraft = await arInvoicesService.createInvoiceDraft(
{
customerId,
invoiceDate: '2026-05-01',
dueDate: '2026-05-31',
lines: [{ description: 'Reports TB test', quantity: 1, unitPrice: 400, revenueGLAccountId: salesAccount.id, taxCodeId: stdTaxCodeId }],
},
adminId,
);
await arInvoicesService.postInvoice(invoiceDraft.id, adminId);
const tb = await reportsService.trialBalance(MAIN, new Date('2026-12-31'));
expect(tb.totalDebit).toBeCloseTo(tb.totalCredit, 2);
});
it('reflects a posted AR invoice as income for the period it falls in, and not for a period before it', async () => {
const before = await reportsService.profitAndLoss(MAIN, new Date('2026-06-01'), new Date('2026-06-30'));
const invoiceDraft = await arInvoicesService.createInvoiceDraft(
{
customerId,
invoiceDate: '2026-06-15',
dueDate: '2026-07-15',
lines: [{ description: 'Reports P&L test', quantity: 1, unitPrice: 250, revenueGLAccountId: salesAccount.id }],
},
adminId,
);
await arInvoicesService.postInvoice(invoiceDraft.id, adminId);
const after = await reportsService.profitAndLoss(MAIN, new Date('2026-06-01'), new Date('2026-06-30'));
expect(after.totalIncome - before.totalIncome).toBeCloseTo(250, 2);
expect(after.netProfit - before.netProfit).toBeCloseTo(250, 2);
const earlier = await reportsService.profitAndLoss(MAIN, new Date('2026-01-01'), new Date('2026-01-31'));
const salesLine = earlier.income.find((row) => row.accountId === salesAccount.id);
expect(salesLine).toBeUndefined();
});
it('keeps the balance sheet in balance (Assets = Liabilities + Equity) after mixed AR/AP activity', async () => {
const arDraft = await arInvoicesService.createInvoiceDraft(
{
customerId,
invoiceDate: '2026-07-01',
dueDate: '2026-07-31',
lines: [{ description: 'Reports BS test', quantity: 1, unitPrice: 600, revenueGLAccountId: salesAccount.id, taxCodeId: stdTaxCodeId }],
},
adminId,
);
await arInvoicesService.postInvoice(arDraft.id, adminId);
const apDraft = await apInvoicesService.createInvoiceDraft(
{
supplierId,
invoiceDate: '2026-07-02',
dueDate: '2026-08-01',
lines: [{ description: 'Reports BS test', quantity: 1, unitPrice: 300, expenseGLAccountId: expenseAccount.id, taxCodeId: stdTaxCodeId }],
},
adminId,
);
await apInvoicesService.postInvoice(apDraft.id, adminId);
const bs = await reportsService.balanceSheet(MAIN, new Date('2026-12-31'));
expect(bs.balanceCheck).toBeCloseTo(0, 2);
expect(bs.totalAssets).toBeCloseTo(bs.totalLiabilities + bs.totalEquity, 2);
});
it('buckets an aged receivable by days overdue from its due date', async () => {
const invoiceDraft = await arInvoicesService.createInvoiceDraft(
{ customerId, invoiceDate: '2026-01-01', dueDate: '2026-01-31', lines: [{ description: 'Aged test', quantity: 1, unitPrice: 90, revenueGLAccountId: salesAccount.id }] },
adminId,
);
await arInvoicesService.postInvoice(invoiceDraft.id, adminId);
// asOfDate is 45 days after the due date -> the 31-60 day bucket.
const aged = await reportsService.agedReceivables(MAIN, new Date('2026-03-17'));
const row = aged.rows.find((r) => r.partyId === customerId);
expect(row).toBeDefined();
expect(row!.d31to60).toBeGreaterThanOrEqual(90);
expect(row!.total).toBeGreaterThanOrEqual(90);
});
it('buckets an aged payable by days overdue from its due date', async () => {
const invoiceDraft = await apInvoicesService.createInvoiceDraft(
{ supplierId, invoiceDate: '2026-01-01', dueDate: '2026-01-31', lines: [{ description: 'Aged test', quantity: 1, unitPrice: 70, expenseGLAccountId: expenseAccount.id }] },
adminId,
);
await apInvoicesService.postInvoice(invoiceDraft.id, adminId);
const aged = await reportsService.agedPayables(MAIN, new Date('2026-03-17'));
const row = aged.rows.find((r) => r.partyId === supplierId);
expect(row).toBeDefined();
expect(row!.d31to60).toBeGreaterThanOrEqual(70);
expect(row!.total).toBeGreaterThanOrEqual(70);
});
it('scopes every report to a single branch when a BRANCH scope is passed', async () => {
const branchScope = { mode: 'BRANCH' as const, branchSegmentValueId: branchHQId };
const tb = await reportsService.trialBalance(branchScope, new Date('2026-12-31'));
expect(tb.rows.length).toBeGreaterThan(0);
});
});