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
159 lines
6.5 KiB
TypeScript
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);
|
|
});
|
|
});
|