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
This commit is contained in:
Joshua Oosthuizen
2026-09-04 10:52:08 +02:00
co-authored by Claude Sonnet 5
parent b137176c40
commit eb42eb8fc8
17 changed files with 1378 additions and 10 deletions
+53
View File
@@ -0,0 +1,53 @@
# Phase 6 — Reporting Suite: Manual Test Checklist
Five reports are now live under **Reports Centre**, each reachable directly from its card
(the "Coming soon" reports remain placeholders). Every report respects the branch switcher in
the top bar — Main gives a consolidated view, a single branch scopes to just that branch's
posted activity.
## 1. Trial Balance (`/reports/trial-balance`)
- [ ] Pick an "As at" date after some posted activity — accounts with posted movement appear,
sorted by account code
- [ ] The "Balanced" / "Out of balance" chip in the header reads **Balanced** (it always should
— this is a structural invariant of double-entry posting, not something that can drift)
- [ ] Total Debit column sum equals Total Credit column sum in the footer row
- [ ] Switching the branch selector to a single branch narrows the accounts/amounts shown to
that branch's postings only
## 2. Profit & Loss Statement (`/reports/profit-and-loss`)
- [ ] Defaults to year-to-date on load
- [ ] Narrowing the date range changes which invoices/postings are included (an invoice dated
outside the range should disappear from its account's line)
- [ ] Net Profit = Total Income − Total Expense, shown in green when positive / red when
negative
- [ ] Income/Expense accounts with zero movement in the period are omitted, not shown as zero
## 3. Balance Sheet (`/reports/balance-sheet`)
- [ ] Assets, Liabilities, and Equity sections all populate
- [ ] "Current Year Earnings" appears as a computed equity line (no account code) equal to
this fiscal year's Profit & Loss net profit up to the As-at date — this app has no
year-end close step, so this is how the balance sheet stays real-time-balanced
- [ ] Header chip reads **Balanced** (Total Assets = Total Liabilities + Total Equity)
## 4. Aged Receivables (`/reports/aged-receivables`)
- [ ] Only customers with an outstanding (unpaid/partially paid) posted invoice appear
- [ ] An overdue invoice lands in the correct bucket (Current / 1–30 / 31–60 / 61–90 / 90+)
based on days between its due date and the As-at date
- [ ] Clicking a customer name navigates to that customer's detail page
- [ ] Footer row sums match the column totals
## 5. Aged Payables (`/reports/aged-payables`)
- [ ] Same checks as Aged Receivables, mirrored for suppliers — clicking a supplier name
navigates to the supplier's detail page
## Regression
- [ ] General Ledger card in Reports Centre still links to the existing Journal Entries page
(its "By Account" tab is the account drill-down report)
- [ ] Reports Centre's still-unbuilt cards (Customer Statement, Payment Run, Budget vs Actual,
Branch Performance) remain visibly disabled ("Coming soon")
+46
View File
@@ -0,0 +1,46 @@
import { apiClient } from './client';
import type { AgedReport, BalanceSheetReport, ProfitAndLossReport, TrialBalanceReport } from '../types/api';
import type { BranchSelection } from '../store/branchStore';
function branchParam(branchScope: BranchSelection): string {
return branchScope === 'MAIN' ? 'MAIN' : String(branchScope);
}
export async function fetchTrialBalance(branchScope: BranchSelection, asOfDate?: string): Promise<TrialBalanceReport> {
const response = await apiClient.get<TrialBalanceReport>('/reports/trial-balance', {
params: { branchScope: branchParam(branchScope), asOfDate },
});
return response.data;
}
export async function fetchProfitAndLoss(
branchScope: BranchSelection,
fromDate?: string,
toDate?: string,
): Promise<ProfitAndLossReport> {
const response = await apiClient.get<ProfitAndLossReport>('/reports/profit-and-loss', {
params: { branchScope: branchParam(branchScope), fromDate, toDate },
});
return response.data;
}
export async function fetchBalanceSheet(branchScope: BranchSelection, asOfDate?: string): Promise<BalanceSheetReport> {
const response = await apiClient.get<BalanceSheetReport>('/reports/balance-sheet', {
params: { branchScope: branchParam(branchScope), asOfDate },
});
return response.data;
}
export async function fetchAgedReceivables(branchScope: BranchSelection, asOfDate?: string): Promise<AgedReport> {
const response = await apiClient.get<AgedReport>('/reports/aged-receivables', {
params: { branchScope: branchParam(branchScope), asOfDate },
});
return response.data;
}
export async function fetchAgedPayables(branchScope: BranchSelection, asOfDate?: string): Promise<AgedReport> {
const response = await apiClient.get<AgedReport>('/reports/aged-payables', {
params: { branchScope: branchParam(branchScope), asOfDate },
});
return response.data;
}
@@ -0,0 +1,39 @@
import { useState } from 'react';
import { Box, Card, TextField, Typography } from '@mui/material';
import { useAgedPayables } from '../../hooks/useReports';
import { useBranchStore } from '../../store/branchStore';
import { AgedReportTable } from './AgedReportTable';
function today(): string {
return new Date().toISOString().slice(0, 10);
}
export function AgedPayablesPage() {
const selected = useBranchStore((state) => state.selected);
const [asOfDate, setAsOfDate] = useState(today());
const { data, isLoading } = useAgedPayables(selected, asOfDate);
return (
<Box>
<Typography variant="h5" sx={{ mb: 0.5 }}>
Aged Payables
</Typography>
<Typography variant="body2" color="text.secondary" sx={{ mb: 3 }}>
Outstanding supplier balances by age bucket, as at the selected date.
</Typography>
<Card sx={{ p: 2, mb: 2 }}>
<TextField
label="As at"
type="date"
size="small"
value={asOfDate}
onChange={(e) => setAsOfDate(e.target.value)}
slotProps={{ inputLabel: { shrink: true } }}
/>
</Card>
<AgedReportTable data={data} isLoading={isLoading} linkBase="/ap/suppliers" />
</Box>
);
}
@@ -0,0 +1,39 @@
import { useState } from 'react';
import { Box, Card, TextField, Typography } from '@mui/material';
import { useAgedReceivables } from '../../hooks/useReports';
import { useBranchStore } from '../../store/branchStore';
import { AgedReportTable } from './AgedReportTable';
function today(): string {
return new Date().toISOString().slice(0, 10);
}
export function AgedReceivablesPage() {
const selected = useBranchStore((state) => state.selected);
const [asOfDate, setAsOfDate] = useState(today());
const { data, isLoading } = useAgedReceivables(selected, asOfDate);
return (
<Box>
<Typography variant="h5" sx={{ mb: 0.5 }}>
Aged Receivables
</Typography>
<Typography variant="body2" color="text.secondary" sx={{ mb: 3 }}>
Outstanding customer balances by age bucket, as at the selected date.
</Typography>
<Card sx={{ p: 2, mb: 2 }}>
<TextField
label="As at"
type="date"
size="small"
value={asOfDate}
onChange={(e) => setAsOfDate(e.target.value)}
slotProps={{ inputLabel: { shrink: true } }}
/>
</Card>
<AgedReportTable data={data} isLoading={isLoading} linkBase="/ar/customers" />
</Box>
);
}
@@ -0,0 +1,115 @@
import { Box, Card, CircularProgress, Link as MuiLink, Table, TableBody, TableCell, TableHead, TableRow, Typography } from '@mui/material';
import { Link as RouterLink } from 'react-router-dom';
import { MoneyCell } from '../../components/common/MoneyCell';
import type { AgedReport } from '../../types/api';
export function AgedReportTable({
data,
isLoading,
linkBase,
}: {
data: AgedReport | undefined;
isLoading: boolean;
linkBase: string;
}) {
return (
<Card>
{isLoading ? (
<Box sx={{ p: 4, display: 'flex', justifyContent: 'center' }}>
<CircularProgress size={24} />
</Box>
) : (
<Table size="small">
<TableHead>
<TableRow>
<TableCell>Account</TableCell>
<TableCell align="right">Current</TableCell>
<TableCell align="right">1–30 days</TableCell>
<TableCell align="right">31–60 days</TableCell>
<TableCell align="right">61–90 days</TableCell>
<TableCell align="right">90+ days</TableCell>
<TableCell align="right">Total</TableCell>
</TableRow>
</TableHead>
<TableBody>
{data?.rows.length === 0 && (
<TableRow>
<TableCell colSpan={7}>
<Typography variant="body2" color="text.secondary">
No outstanding balances as at this date.
</Typography>
</TableCell>
</TableRow>
)}
{data?.rows.map((row) => (
<TableRow key={row.partyId}>
<TableCell>
<MuiLink component={RouterLink} to={`${linkBase}/${row.partyId}`} underline="hover">
{row.code} — {row.name}
</MuiLink>
</TableCell>
<TableCell align="right">
<MoneyCell value={row.current} />
</TableCell>
<TableCell align="right">
<MoneyCell value={row.d1to30} />
</TableCell>
<TableCell align="right">
<MoneyCell value={row.d31to60} />
</TableCell>
<TableCell align="right">
<MoneyCell value={row.d61to90} />
</TableCell>
<TableCell align="right">
<MoneyCell value={row.d90plus} />
</TableCell>
<TableCell align="right">
<strong>
<MoneyCell value={row.total} />
</strong>
</TableCell>
</TableRow>
))}
{data && (
<TableRow>
<TableCell>
<strong>Total</strong>
</TableCell>
<TableCell align="right">
<strong>
<MoneyCell value={data.totals.current} />
</strong>
</TableCell>
<TableCell align="right">
<strong>
<MoneyCell value={data.totals.d1to30} />
</strong>
</TableCell>
<TableCell align="right">
<strong>
<MoneyCell value={data.totals.d31to60} />
</strong>
</TableCell>
<TableCell align="right">
<strong>
<MoneyCell value={data.totals.d61to90} />
</strong>
</TableCell>
<TableCell align="right">
<strong>
<MoneyCell value={data.totals.d90plus} />
</strong>
</TableCell>
<TableCell align="right">
<strong>
<MoneyCell value={data.totals.total} />
</strong>
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
)}
</Card>
);
}
@@ -0,0 +1,122 @@
import { useState } from 'react';
import {
Box,
Card,
Chip,
CircularProgress,
Stack,
Table,
TableBody,
TableCell,
TableHead,
TableRow,
TextField,
Typography,
} from '@mui/material';
import { useBalanceSheet } from '../../hooks/useReports';
import { useBranchStore } from '../../store/branchStore';
import { MoneyCell } from '../../components/common/MoneyCell';
import type { BalanceSheetLineItem } from '../../types/api';
function today(): string {
return new Date().toISOString().slice(0, 10);
}
function Section({ title, rows, total }: { title: string; rows: BalanceSheetLineItem[]; total: number }) {
return (
<>
<TableRow>
<TableCell colSpan={3}>
<Typography variant="overline" color="text.secondary">
{title}
</Typography>
</TableCell>
</TableRow>
{rows.map((row) => (
<TableRow key={row.accountId ?? row.accountName}>
<TableCell sx={{ fontFamily: 'monospace' }}>{row.accountCode ?? ''}</TableCell>
<TableCell>{row.accountName}</TableCell>
<TableCell align="right">
<MoneyCell value={row.amount} />
</TableCell>
</TableRow>
))}
<TableRow>
<TableCell colSpan={2}>
<strong>Total {title}</strong>
</TableCell>
<TableCell align="right">
<strong>
<MoneyCell value={total} />
</strong>
</TableCell>
</TableRow>
</>
);
}
export function BalanceSheetPage() {
const selected = useBranchStore((state) => state.selected);
const [asOfDate, setAsOfDate] = useState(today());
const { data, isLoading } = useBalanceSheet(selected, asOfDate);
const balanced = data ? Math.abs(data.balanceCheck) < 0.01 : false;
return (
<Box>
<Stack direction="row" sx={{ alignItems: 'center', justifyContent: 'space-between', mb: 0.5 }}>
<Typography variant="h5">Balance Sheet</Typography>
{data && <Chip label={balanced ? 'Balanced' : 'Out of balance'} color={balanced ? 'success' : 'error'} size="small" />}
</Stack>
<Typography variant="body2" color="text.secondary" sx={{ mb: 3 }}>
Assets, liabilities, and equity as at the selected date. Equity includes current-year
earnings computed live from unclosed income and expense — this app has no year-end
close step, so the P&amp;L feeds straight into equity here.
</Typography>
<Card sx={{ p: 2, mb: 2 }}>
<TextField
label="As at"
type="date"
size="small"
value={asOfDate}
onChange={(e) => setAsOfDate(e.target.value)}
slotProps={{ inputLabel: { shrink: true } }}
/>
</Card>
<Card>
{isLoading ? (
<Box sx={{ p: 4, display: 'flex', justifyContent: 'center' }}>
<CircularProgress size={24} />
</Box>
) : (
<Table size="small">
<TableHead>
<TableRow>
<TableCell>Code</TableCell>
<TableCell>Account</TableCell>
<TableCell align="right">Amount</TableCell>
</TableRow>
</TableHead>
<TableBody>
{data && <Section title="Assets" rows={data.assets} total={data.totalAssets} />}
{data && (
<TableRow>
<TableCell colSpan={3} sx={{ pt: 3 }} />
</TableRow>
)}
{data && <Section title="Liabilities" rows={data.liabilities} total={data.totalLiabilities} />}
{data && (
<TableRow>
<TableCell colSpan={3} sx={{ pt: 3 }} />
</TableRow>
)}
{data && <Section title="Equity" rows={data.equity} total={data.totalEquity} />}
</TableBody>
</Table>
)}
</Card>
</Box>
);
}
@@ -0,0 +1,138 @@
import { useState } from 'react';
import { Box, Card, CircularProgress, Stack, Table, TableBody, TableCell, TableHead, TableRow, TextField, Typography } from '@mui/material';
import { useProfitAndLoss } from '../../hooks/useReports';
import { useBranchStore } from '../../store/branchStore';
import { MoneyCell } from '../../components/common/MoneyCell';
function today(): string {
return new Date().toISOString().slice(0, 10);
}
function startOfYear(): string {
return `${new Date().getFullYear()}-01-01`;
}
export function ProfitAndLossPage() {
const selected = useBranchStore((state) => state.selected);
const [fromDate, setFromDate] = useState(startOfYear());
const [toDate, setToDate] = useState(today());
const { data, isLoading } = useProfitAndLoss(selected, fromDate, toDate);
return (
<Box>
<Typography variant="h5" sx={{ mb: 0.5 }}>
Profit &amp; Loss Statement
</Typography>
<Typography variant="body2" color="text.secondary" sx={{ mb: 3 }}>
Income and expense performance for the selected period.
</Typography>
<Card sx={{ p: 2, mb: 2 }}>
<Stack direction="row" spacing={2}>
<TextField
label="From"
type="date"
size="small"
value={fromDate}
onChange={(e) => setFromDate(e.target.value)}
slotProps={{ inputLabel: { shrink: true } }}
/>
<TextField
label="To"
type="date"
size="small"
value={toDate}
onChange={(e) => setToDate(e.target.value)}
slotProps={{ inputLabel: { shrink: true } }}
/>
</Stack>
</Card>
<Card>
{isLoading ? (
<Box sx={{ p: 4, display: 'flex', justifyContent: 'center' }}>
<CircularProgress size={24} />
</Box>
) : (
<Table size="small">
<TableHead>
<TableRow>
<TableCell>Code</TableCell>
<TableCell>Account</TableCell>
<TableCell align="right">Amount</TableCell>
</TableRow>
</TableHead>
<TableBody>
<TableRow>
<TableCell colSpan={3}>
<Typography variant="overline" color="text.secondary">
Income
</Typography>
</TableCell>
</TableRow>
{data?.income.map((row) => (
<TableRow key={row.accountId}>
<TableCell sx={{ fontFamily: 'monospace' }}>{row.accountCode}</TableCell>
<TableCell>{row.accountName}</TableCell>
<TableCell align="right">
<MoneyCell value={row.amount} />
</TableCell>
</TableRow>
))}
<TableRow>
<TableCell colSpan={2}>
<strong>Total Income</strong>
</TableCell>
<TableCell align="right">
<strong>
<MoneyCell value={data?.totalIncome} />
</strong>
</TableCell>
</TableRow>
<TableRow>
<TableCell colSpan={3} sx={{ pt: 3 }}>
<Typography variant="overline" color="text.secondary">
Expense
</Typography>
</TableCell>
</TableRow>
{data?.expense.map((row) => (
<TableRow key={row.accountId}>
<TableCell sx={{ fontFamily: 'monospace' }}>{row.accountCode}</TableCell>
<TableCell>{row.accountName}</TableCell>
<TableCell align="right">
<MoneyCell value={row.amount} />
</TableCell>
</TableRow>
))}
<TableRow>
<TableCell colSpan={2}>
<strong>Total Expense</strong>
</TableCell>
<TableCell align="right">
<strong>
<MoneyCell value={data?.totalExpense} />
</strong>
</TableCell>
</TableRow>
{data && (
<TableRow>
<TableCell colSpan={2}>
<Typography sx={{ fontWeight: 700 }}>Net Profit</Typography>
</TableCell>
<TableCell align="right">
<Typography sx={{ fontWeight: 700, color: data.netProfit >= 0 ? 'success.main' : 'error.main' }}>
{data.netProfit.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
</Typography>
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
)}
</Card>
</Box>
);
}
+19 -10
View File
@@ -1,32 +1,38 @@
import { Box, Card, Chip, Stack, Typography } from '@mui/material';
import { useNavigate } from 'react-router-dom';
import DescriptionOutlinedIcon from '@mui/icons-material/DescriptionOutlined';
interface ReportDef {
name: string;
description: string;
path?: string;
}
const CATEGORIES: { title: string; reports: ReportDef[] }[] = [
{
title: 'Financial',
reports: [
{ name: 'Trial Balance', description: 'Debit and credit balances for every account, Main or per-branch.' },
{ name: 'Profit & Loss Statement', description: 'Income and expense performance for a selected period.' },
{ name: 'Balance Sheet', description: 'Assets, liabilities, and equity as at a selected date.' },
{ name: 'General Ledger', description: 'Full transaction detail for any account and date range.' },
{ name: 'Trial Balance', description: 'Debit and credit balances for every account, Main or per-branch.', path: '/reports/trial-balance' },
{
name: 'Profit & Loss Statement',
description: 'Income and expense performance for a selected period.',
path: '/reports/profit-and-loss',
},
{ name: 'Balance Sheet', description: 'Assets, liabilities, and equity as at a selected date.', path: '/reports/balance-sheet' },
{ name: 'General Ledger', description: 'Full transaction detail for any account and date range.', path: '/gl/journal-entries' },
],
},
{
title: 'Receivables',
reports: [
{ name: 'Aged Receivables', description: 'Outstanding customer balances by age bucket.' },
{ name: 'Aged Receivables', description: 'Outstanding customer balances by age bucket.', path: '/reports/aged-receivables' },
{ name: 'Customer Statement', description: 'Transaction history and running balance per customer.' },
],
},
{
title: 'Payables',
reports: [
{ name: 'Aged Payables', description: 'Outstanding supplier balances by age bucket.' },
{ name: 'Aged Payables', description: 'Outstanding supplier balances by age bucket.', path: '/reports/aged-payables' },
{ name: 'Payment Run', description: 'Supplier invoices selected for the next payment batch.' },
],
},
@@ -35,12 +41,13 @@ const CATEGORIES: { title: string; reports: ReportDef[] }[] = [
reports: [
{ name: 'Budget vs Actual', description: 'Budgeted figures compared against actual GL movement.' },
{ name: 'Branch Performance', description: 'Key metrics compared across branches.' },
{ name: 'Stock Valuation', description: 'On-hand stock quantities and value by warehouse.' },
],
},
];
export function ReportsPage() {
const navigate = useNavigate();
return (
<Box>
<Typography variant="h5" sx={{ mb: 0.5 }}>
@@ -60,11 +67,13 @@ export function ReportsPage() {
{category.reports.map((report) => (
<Card
key={report.name}
onClick={() => report.path && navigate(report.path)}
sx={{
p: 2,
width: 280,
opacity: 0.6,
cursor: 'not-allowed',
opacity: report.path ? 1 : 0.6,
cursor: report.path ? 'pointer' : 'not-allowed',
'&:hover': report.path ? { borderColor: 'primary.main' } : undefined,
}}
>
<Stack direction="row" spacing={1.5} sx={{ alignItems: 'flex-start', mb: 1 }}>
@@ -76,7 +85,7 @@ export function ReportsPage() {
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mb: 1.5 }}>
{report.description}
</Typography>
<Chip label="Coming soon" size="small" />
{!report.path && <Chip label="Coming soon" size="small" />}
</Card>
))}
</Stack>
@@ -0,0 +1,112 @@
import { useState } from 'react';
import {
Box,
Card,
Chip,
CircularProgress,
Stack,
Table,
TableBody,
TableCell,
TableHead,
TableRow,
TextField,
Typography,
} from '@mui/material';
import { useTrialBalance } from '../../hooks/useReports';
import { useBranchStore } from '../../store/branchStore';
import { MoneyCell } from '../../components/common/MoneyCell';
function today(): string {
return new Date().toISOString().slice(0, 10);
}
export function TrialBalancePage() {
const selected = useBranchStore((state) => state.selected);
const [asOfDate, setAsOfDate] = useState(today());
const { data, isLoading } = useTrialBalance(selected, asOfDate);
const balanced = data ? data.totalDebit === data.totalCredit : false;
return (
<Box>
<Stack direction="row" sx={{ alignItems: 'center', justifyContent: 'space-between', mb: 0.5 }}>
<Typography variant="h5">Trial Balance</Typography>
{data && <Chip label={balanced ? 'Balanced' : 'Out of balance'} color={balanced ? 'success' : 'error'} size="small" />}
</Stack>
<Typography variant="body2" color="text.secondary" sx={{ mb: 3 }}>
Debit and credit balances for every account with posted activity, as at the selected date.
</Typography>
<Card sx={{ p: 2, mb: 2 }}>
<TextField
label="As at"
type="date"
size="small"
value={asOfDate}
onChange={(e) => setAsOfDate(e.target.value)}
slotProps={{ inputLabel: { shrink: true } }}
/>
</Card>
<Card>
{isLoading ? (
<Box sx={{ p: 4, display: 'flex', justifyContent: 'center' }}>
<CircularProgress size={24} />
</Box>
) : (
<Table size="small">
<TableHead>
<TableRow>
<TableCell>Code</TableCell>
<TableCell>Account</TableCell>
<TableCell align="right">Debit</TableCell>
<TableCell align="right">Credit</TableCell>
</TableRow>
</TableHead>
<TableBody>
{data?.rows.length === 0 && (
<TableRow>
<TableCell colSpan={4}>
<Typography variant="body2" color="text.secondary">
No posted activity as at this date.
</Typography>
</TableCell>
</TableRow>
)}
{data?.rows.map((row) => (
<TableRow key={row.accountId}>
<TableCell sx={{ fontFamily: 'monospace' }}>{row.accountCode}</TableCell>
<TableCell>{row.accountName}</TableCell>
<TableCell align="right">
<MoneyCell value={row.debit} />
</TableCell>
<TableCell align="right">
<MoneyCell value={row.credit} />
</TableCell>
</TableRow>
))}
{data && (
<TableRow>
<TableCell colSpan={2}>
<strong>Total</strong>
</TableCell>
<TableCell align="right">
<strong>
<MoneyCell value={data.totalDebit} />
</strong>
</TableCell>
<TableCell align="right">
<strong>
<MoneyCell value={data.totalCredit} />
</strong>
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
)}
</Card>
</Box>
);
}
+44
View File
@@ -0,0 +1,44 @@
import { useQuery } from '@tanstack/react-query';
import {
fetchAgedPayables,
fetchAgedReceivables,
fetchBalanceSheet,
fetchProfitAndLoss,
fetchTrialBalance,
} from '../api/reports.api';
import type { BranchSelection } from '../store/branchStore';
export function useTrialBalance(branchScope: BranchSelection, asOfDate: string) {
return useQuery({
queryKey: ['reports', 'trial-balance', branchScope, asOfDate],
queryFn: () => fetchTrialBalance(branchScope, asOfDate),
});
}
export function useProfitAndLoss(branchScope: BranchSelection, fromDate: string, toDate: string) {
return useQuery({
queryKey: ['reports', 'profit-and-loss', branchScope, fromDate, toDate],
queryFn: () => fetchProfitAndLoss(branchScope, fromDate, toDate),
});
}
export function useBalanceSheet(branchScope: BranchSelection, asOfDate: string) {
return useQuery({
queryKey: ['reports', 'balance-sheet', branchScope, asOfDate],
queryFn: () => fetchBalanceSheet(branchScope, asOfDate),
});
}
export function useAgedReceivables(branchScope: BranchSelection, asOfDate: string) {
return useQuery({
queryKey: ['reports', 'aged-receivables', branchScope, asOfDate],
queryFn: () => fetchAgedReceivables(branchScope, asOfDate),
});
}
export function useAgedPayables(branchScope: BranchSelection, asOfDate: string) {
return useQuery({
queryKey: ['reports', 'aged-payables', branchScope, asOfDate],
queryFn: () => fetchAgedPayables(branchScope, asOfDate),
});
}
+10
View File
@@ -35,6 +35,11 @@ import { PaymentFormPage } from '../features/ap/payments/PaymentFormPage';
import { PaymentDetailPage } from '../features/ap/payments/PaymentDetailPage';
import { AdminWarehousesPage } from '../features/admin/warehouses/AdminWarehousesPage';
import { StockItemsListPage } from '../features/stock/items/StockItemsListPage';
import { TrialBalancePage } from '../features/reports/TrialBalancePage';
import { ProfitAndLossPage } from '../features/reports/ProfitAndLossPage';
import { BalanceSheetPage } from '../features/reports/BalanceSheetPage';
import { AgedReceivablesPage } from '../features/reports/AgedReceivablesPage';
import { AgedPayablesPage } from '../features/reports/AgedPayablesPage';
export function AppRouter() {
return (
@@ -45,6 +50,11 @@ export function AppRouter() {
<Route element={<MainLayout />}>
<Route path="/" element={<DashboardPage />} />
<Route path="/reports" element={<ReportsPage />} />
<Route path="/reports/trial-balance" element={<TrialBalancePage />} />
<Route path="/reports/profit-and-loss" element={<ProfitAndLossPage />} />
<Route path="/reports/balance-sheet" element={<BalanceSheetPage />} />
<Route path="/reports/aged-receivables" element={<AgedReceivablesPage />} />
<Route path="/reports/aged-payables" element={<AgedPayablesPage />} />
<Route path="/gl/journal-entries" element={<JournalEntryListPage />} />
<Route path="/gl/journal-entries/:id" element={<JournalEntryDetailPage />} />
+73
View File
@@ -408,3 +408,76 @@ export interface StockItem {
stockLevels?: StockLevel[];
createdAt: string;
}
// ---------------------------------------------------------------------------
// Reports
// ---------------------------------------------------------------------------
export interface TrialBalanceRow {
accountId: number;
accountCode: string;
accountName: string;
accountType: GLAccountType;
debit: number;
credit: number;
}
export interface TrialBalanceReport {
asOfDate: string;
rows: TrialBalanceRow[];
totalDebit: number;
totalCredit: number;
}
export interface PLLineItem {
accountId: number;
accountCode: string;
accountName: string;
amount: number;
}
export interface ProfitAndLossReport {
fromDate: string;
toDate: string;
income: PLLineItem[];
expense: PLLineItem[];
totalIncome: number;
totalExpense: number;
netProfit: number;
}
export interface BalanceSheetLineItem {
accountId: number | null;
accountCode: string | null;
accountName: string;
amount: number;
}
export interface BalanceSheetReport {
asOfDate: string;
assets: BalanceSheetLineItem[];
liabilities: BalanceSheetLineItem[];
equity: BalanceSheetLineItem[];
totalAssets: number;
totalLiabilities: number;
totalEquity: number;
balanceCheck: number;
}
export interface AgedBucketRow {
partyId: number;
code: string;
name: string;
current: number;
d1to30: number;
d31to60: number;
d61to90: number;
d90plus: number;
total: number;
}
export interface AgedReport {
asOfDate: string;
rows: AgedBucketRow[];
totals: Omit<AgedBucketRow, 'partyId' | 'code' | 'name'>;
}
+2
View File
@@ -21,6 +21,7 @@ import { apDebitNotesRouter } from './modules/ap/apDebitNotes.routes';
import { apPaymentsRouter } from './modules/ap/apPayments.routes';
import { warehousesRouter } from './modules/stock/warehouses.routes';
import { stockItemsRouter } from './modules/stock/stockItems.routes';
import { reportsRouter } from './modules/reports/reports.routes';
export function createApp(): Express {
const app = express();
@@ -50,6 +51,7 @@ export function createApp(): Express {
app.use('/api/ap/payments', apPaymentsRouter);
app.use('/api/stock/warehouses', warehousesRouter);
app.use('/api/stock/items', stockItemsRouter);
app.use('/api/reports', reportsRouter);
app.use((req, res) => res.status(404).json({ error: 'Not found' }));
app.use(errorHandler);
+44
View File
@@ -0,0 +1,44 @@
import { Request, Response } from 'express';
import { prisma } from '../../lib/prisma';
import * as reportsService from './reports.service';
function parseDate(value: unknown, fallback: Date): Date {
if (typeof value !== 'string' || value.length === 0) return fallback;
const parsed = new Date(value);
if (Number.isNaN(parsed.getTime())) return fallback;
return parsed;
}
async function currentFiscalYearStart(asOfDate: Date): Promise<Date> {
const fiscalYear = await prisma.fiscalYear.findFirst({
where: { startDate: { lte: asOfDate } },
orderBy: { startDate: 'desc' },
});
return fiscalYear?.startDate ?? new Date(asOfDate.getFullYear(), 0, 1);
}
export async function trialBalanceHandler(req: Request, res: Response): Promise<void> {
const asOfDate = parseDate(req.query.asOfDate, new Date());
res.json(await reportsService.trialBalance(req.branchScope!, asOfDate));
}
export async function profitAndLossHandler(req: Request, res: Response): Promise<void> {
const toDate = parseDate(req.query.toDate, new Date());
const fromDate = parseDate(req.query.fromDate, await currentFiscalYearStart(toDate));
res.json(await reportsService.profitAndLoss(req.branchScope!, fromDate, toDate));
}
export async function balanceSheetHandler(req: Request, res: Response): Promise<void> {
const asOfDate = parseDate(req.query.asOfDate, new Date());
res.json(await reportsService.balanceSheet(req.branchScope!, asOfDate));
}
export async function agedReceivablesHandler(req: Request, res: Response): Promise<void> {
const asOfDate = parseDate(req.query.asOfDate, new Date());
res.json(await reportsService.agedReceivables(req.branchScope!, asOfDate));
}
export async function agedPayablesHandler(req: Request, res: Response): Promise<void> {
const asOfDate = parseDate(req.query.asOfDate, new Date());
res.json(await reportsService.agedPayables(req.branchScope!, asOfDate));
}
+21
View File
@@ -0,0 +1,21 @@
import { Router } from 'express';
import { requireAuth } from '../../middleware/auth';
import { attachBranchScope } from '../../middleware/branchScope';
import { asyncHandler } from '../../lib/asyncHandler';
import {
agedPayablesHandler,
agedReceivablesHandler,
balanceSheetHandler,
profitAndLossHandler,
trialBalanceHandler,
} from './reports.controller';
export const reportsRouter = Router();
reportsRouter.use(requireAuth, attachBranchScope);
reportsRouter.get('/trial-balance', asyncHandler(trialBalanceHandler));
reportsRouter.get('/profit-and-loss', asyncHandler(profitAndLossHandler));
reportsRouter.get('/balance-sheet', asyncHandler(balanceSheetHandler));
reportsRouter.get('/aged-receivables', asyncHandler(agedReceivablesHandler));
reportsRouter.get('/aged-payables', asyncHandler(agedPayablesHandler));
+343
View File
@@ -0,0 +1,343 @@
import { GLAccountType, JournalEntryStatus, DebitCredit, ARDocumentStatus, APDocumentStatus } from '@prisma/client';
import { prisma } from '../../lib/prisma';
import { BranchScope } from '../../lib/branchFilter';
// Journal entries are always created POSTED and, if later reversed, flip to REVERSED — their
// lines stay in place and the reversal is a separate POSTED entry, so both must count toward
// any ledger total. DRAFT exists on the enum but no code path currently writes it for a
// JournalEntry; excluding it here is a safety net, not a real-world filter.
const LEDGER_STATUSES: JournalEntryStatus[] = [JournalEntryStatus.POSTED, JournalEntryStatus.REVERSED];
function branchLineFilter(branchScope: BranchScope): Record<string, unknown> {
return branchScope.mode === 'BRANCH' ? { branchSegmentValueId: branchScope.branchSegmentValueId } : {};
}
interface AccountBalance {
glAccountId: number;
debitTotal: number;
creditTotal: number;
}
/** Sums debit/credit per GL account across posted ledger lines up to (and matching) the given filters. */
async function sumLinesByAccount(where: Record<string, unknown>): Promise<AccountBalance[]> {
const grouped = await prisma.journalEntryLine.groupBy({
by: ['glAccountId'],
where,
_sum: { debit: true, credit: true },
});
return grouped.map((row) => ({
glAccountId: row.glAccountId,
debitTotal: Number(row._sum.debit ?? 0),
creditTotal: Number(row._sum.credit ?? 0),
}));
}
export interface TrialBalanceRow {
accountId: number;
accountCode: string;
accountName: string;
accountType: GLAccountType;
debit: number;
credit: number;
}
export interface TrialBalanceReport {
asOfDate: string;
rows: TrialBalanceRow[];
totalDebit: number;
totalCredit: number;
}
export async function trialBalance(branchScope: BranchScope, asOfDate: Date): Promise<TrialBalanceReport> {
const balances = await sumLinesByAccount({
...branchLineFilter(branchScope),
journalEntry: { status: { in: LEDGER_STATUSES }, entryDate: { lte: asOfDate } },
});
const accounts = await prisma.gLAccount.findMany({
where: { id: { in: balances.map((b) => b.glAccountId) } },
orderBy: { accountCode: 'asc' },
});
const accountById = new Map(accounts.map((a) => [a.id, a]));
const rows: TrialBalanceRow[] = balances
.map((balance) => {
const account = accountById.get(balance.glAccountId);
if (!account) return null;
const net = balance.debitTotal - balance.creditTotal;
return {
accountId: account.id,
accountCode: account.accountCode,
accountName: account.accountName,
accountType: account.accountType,
debit: net > 0 ? net : 0,
credit: net < 0 ? -net : 0,
};
})
.filter((row): row is TrialBalanceRow => row !== null && (row.debit !== 0 || row.credit !== 0))
.sort((a, b) => a.accountCode.localeCompare(b.accountCode));
const totalDebit = rows.reduce((sum, row) => sum + row.debit, 0);
const totalCredit = rows.reduce((sum, row) => sum + row.credit, 0);
return { asOfDate: asOfDate.toISOString().slice(0, 10), rows, totalDebit, totalCredit };
}
export interface PLLineItem {
accountId: number;
accountCode: string;
accountName: string;
amount: number;
}
export interface ProfitAndLossReport {
fromDate: string;
toDate: string;
income: PLLineItem[];
expense: PLLineItem[];
totalIncome: number;
totalExpense: number;
netProfit: number;
}
async function profitAndLossLines(
branchScope: BranchScope,
fromDate: Date,
toDate: Date,
): Promise<{ income: PLLineItem[]; expense: PLLineItem[]; totalIncome: number; totalExpense: number }> {
const balances = await sumLinesByAccount({
...branchLineFilter(branchScope),
journalEntry: { status: { in: LEDGER_STATUSES }, entryDate: { gte: fromDate, lte: toDate } },
glAccount: { accountType: { in: [GLAccountType.INCOME, GLAccountType.EXPENSE] } },
});
const accounts = await prisma.gLAccount.findMany({ where: { id: { in: balances.map((b) => b.glAccountId) } } });
const accountById = new Map(accounts.map((a) => [a.id, a]));
const income: PLLineItem[] = [];
const expense: PLLineItem[] = [];
for (const balance of balances) {
const account = accountById.get(balance.glAccountId);
if (!account) continue;
const item = { accountId: account.id, accountCode: account.accountCode, accountName: account.accountName, amount: 0 };
if (account.accountType === GLAccountType.INCOME) {
item.amount = balance.creditTotal - balance.debitTotal;
if (item.amount !== 0) income.push(item);
} else {
item.amount = balance.debitTotal - balance.creditTotal;
if (item.amount !== 0) expense.push(item);
}
}
income.sort((a, b) => a.accountCode.localeCompare(b.accountCode));
expense.sort((a, b) => a.accountCode.localeCompare(b.accountCode));
const totalIncome = income.reduce((sum, row) => sum + row.amount, 0);
const totalExpense = expense.reduce((sum, row) => sum + row.amount, 0);
return { income, expense, totalIncome, totalExpense };
}
export async function profitAndLoss(branchScope: BranchScope, fromDate: Date, toDate: Date): Promise<ProfitAndLossReport> {
const { income, expense, totalIncome, totalExpense } = await profitAndLossLines(branchScope, fromDate, toDate);
return {
fromDate: fromDate.toISOString().slice(0, 10),
toDate: toDate.toISOString().slice(0, 10),
income,
expense,
totalIncome,
totalExpense,
netProfit: totalIncome - totalExpense,
};
}
export interface BalanceSheetLineItem {
accountId: number | null;
accountCode: string | null;
accountName: string;
amount: number;
}
export interface BalanceSheetReport {
asOfDate: string;
assets: BalanceSheetLineItem[];
liabilities: BalanceSheetLineItem[];
equity: BalanceSheetLineItem[];
totalAssets: number;
totalLiabilities: number;
totalEquity: number;
balanceCheck: number;
}
export async function balanceSheet(branchScope: BranchScope, asOfDate: Date): Promise<BalanceSheetReport> {
const balances = await sumLinesByAccount({
...branchLineFilter(branchScope),
journalEntry: { status: { in: LEDGER_STATUSES }, entryDate: { lte: asOfDate } },
glAccount: { accountType: { in: [GLAccountType.ASSET, GLAccountType.LIABILITY, GLAccountType.EQUITY] } },
});
const accounts = await prisma.gLAccount.findMany({ where: { id: { in: balances.map((b) => b.glAccountId) } } });
const accountById = new Map(accounts.map((a) => [a.id, a]));
const assets: BalanceSheetLineItem[] = [];
const liabilities: BalanceSheetLineItem[] = [];
const equity: BalanceSheetLineItem[] = [];
for (const balance of balances) {
const account = accountById.get(balance.glAccountId);
if (!account) continue;
const isDebitNormal = account.normalBalance === DebitCredit.DEBIT;
const amount = isDebitNormal ? balance.debitTotal - balance.creditTotal : balance.creditTotal - balance.debitTotal;
if (amount === 0) continue;
const item = { accountId: account.id, accountCode: account.accountCode, accountName: account.accountName, amount };
if (account.accountType === GLAccountType.ASSET) assets.push(item);
else if (account.accountType === GLAccountType.LIABILITY) liabilities.push(item);
else equity.push(item);
}
// Real double-entry systems close INCOME/EXPENSE into Retained Earnings only at year-end.
// This app has no closing-entries step, so an as-at balance sheet would otherwise be missing
// the current year's unclosed profit — plug it in live as a computed "Current Year Earnings"
// equity line, same technique used by Xero/Sage for an unclosed period.
const fiscalYear = await prisma.fiscalYear.findFirst({
where: { startDate: { lte: asOfDate } },
orderBy: { startDate: 'desc' },
});
if (fiscalYear) {
const periodStart = fiscalYear.startDate;
const periodEnd = asOfDate < fiscalYear.endDate ? asOfDate : fiscalYear.endDate;
const { totalIncome, totalExpense } = await profitAndLossLines(branchScope, periodStart, periodEnd);
const currentYearEarnings = totalIncome - totalExpense;
if (currentYearEarnings !== 0) {
equity.push({ accountId: null, accountCode: null, accountName: 'Current Year Earnings', amount: currentYearEarnings });
}
}
assets.sort((a, b) => (a.accountCode ?? '').localeCompare(b.accountCode ?? ''));
liabilities.sort((a, b) => (a.accountCode ?? '').localeCompare(b.accountCode ?? ''));
equity.sort((a, b) => (a.accountCode ?? '').localeCompare(b.accountCode ?? ''));
const totalAssets = assets.reduce((sum, row) => sum + row.amount, 0);
const totalLiabilities = liabilities.reduce((sum, row) => sum + row.amount, 0);
const totalEquity = equity.reduce((sum, row) => sum + row.amount, 0);
return {
asOfDate: asOfDate.toISOString().slice(0, 10),
assets,
liabilities,
equity,
totalAssets,
totalLiabilities,
totalEquity,
balanceCheck: totalAssets - (totalLiabilities + totalEquity),
};
}
export interface AgedBucketRow {
partyId: number;
code: string;
name: string;
current: number;
d1to30: number;
d31to60: number;
d61to90: number;
d90plus: number;
total: number;
}
export interface AgedReport {
asOfDate: string;
rows: AgedBucketRow[];
totals: Omit<AgedBucketRow, 'partyId' | 'code' | 'name'>;
}
function bucketFor(daysOverdue: number): keyof Omit<AgedBucketRow, 'partyId' | 'code' | 'name' | 'total'> {
if (daysOverdue <= 0) return 'current';
if (daysOverdue <= 30) return 'd1to30';
if (daysOverdue <= 60) return 'd31to60';
if (daysOverdue <= 90) return 'd61to90';
return 'd90plus';
}
function emptyBucketRow(partyId: number, code: string, name: string): AgedBucketRow {
return { partyId, code, name, current: 0, d1to30: 0, d31to60: 0, d61to90: 0, d90plus: 0, total: 0 };
}
export async function agedReceivables(branchScope: BranchScope, asOfDate: Date): Promise<AgedReport> {
const invoices = await prisma.aRInvoice.findMany({
where: {
status: ARDocumentStatus.POSTED,
balance: { gt: 0 },
invoiceDate: { lte: asOfDate },
...(branchScope.mode === 'BRANCH' ? { branchSegmentValueId: branchScope.branchSegmentValueId } : {}),
},
include: { customer: true },
});
const byCustomer = new Map<number, AgedBucketRow>();
for (const invoice of invoices) {
const row =
byCustomer.get(invoice.customerId) ?? emptyBucketRow(invoice.customerId, invoice.customer.code, invoice.customer.name);
const daysOverdue = Math.floor((asOfDate.getTime() - invoice.dueDate.getTime()) / (1000 * 60 * 60 * 24));
const bucket = bucketFor(daysOverdue);
const balance = Number(invoice.balance);
row[bucket] += balance;
row.total += balance;
byCustomer.set(invoice.customerId, row);
}
const rows = [...byCustomer.values()].sort((a, b) => a.name.localeCompare(b.name));
const totals = rows.reduce(
(acc, row) => ({
current: acc.current + row.current,
d1to30: acc.d1to30 + row.d1to30,
d31to60: acc.d31to60 + row.d31to60,
d61to90: acc.d61to90 + row.d61to90,
d90plus: acc.d90plus + row.d90plus,
total: acc.total + row.total,
}),
{ current: 0, d1to30: 0, d31to60: 0, d61to90: 0, d90plus: 0, total: 0 },
);
return { asOfDate: asOfDate.toISOString().slice(0, 10), rows, totals };
}
export async function agedPayables(branchScope: BranchScope, asOfDate: Date): Promise<AgedReport> {
const invoices = await prisma.aPInvoice.findMany({
where: {
status: APDocumentStatus.POSTED,
balance: { gt: 0 },
invoiceDate: { lte: asOfDate },
...(branchScope.mode === 'BRANCH' ? { branchSegmentValueId: branchScope.branchSegmentValueId } : {}),
},
include: { supplier: true },
});
const bySupplier = new Map<number, AgedBucketRow>();
for (const invoice of invoices) {
const row =
bySupplier.get(invoice.supplierId) ?? emptyBucketRow(invoice.supplierId, invoice.supplier.code, invoice.supplier.name);
const daysOverdue = Math.floor((asOfDate.getTime() - invoice.dueDate.getTime()) / (1000 * 60 * 60 * 24));
const bucket = bucketFor(daysOverdue);
const balance = Number(invoice.balance);
row[bucket] += balance;
row.total += balance;
bySupplier.set(invoice.supplierId, row);
}
const rows = [...bySupplier.values()].sort((a, b) => a.name.localeCompare(b.name));
const totals = rows.reduce(
(acc, row) => ({
current: acc.current + row.current,
d1to30: acc.d1to30 + row.d1to30,
d31to60: acc.d31to60 + row.d31to60,
d61to90: acc.d61to90 + row.d61to90,
d90plus: acc.d90plus + row.d90plus,
total: acc.total + row.total,
}),
{ current: 0, d1to30: 0, d31to60: 0, d61to90: 0, d90plus: 0, total: 0 },
);
return { asOfDate: asOfDate.toISOString().slice(0, 10), rows, totals };
}
+158
View File
@@ -0,0 +1,158 @@
/**
* 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);
});
});