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:
co-authored by
Claude Sonnet 5
parent
b137176c40
commit
eb42eb8fc8
@@ -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")
|
||||
@@ -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&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 & 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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),
|
||||
});
|
||||
}
|
||||
@@ -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 />} />
|
||||
|
||||
@@ -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'>;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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));
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user