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 { Box, Card, Chip, Stack, Typography } from '@mui/material';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
import DescriptionOutlinedIcon from '@mui/icons-material/DescriptionOutlined';
|
import DescriptionOutlinedIcon from '@mui/icons-material/DescriptionOutlined';
|
||||||
|
|
||||||
interface ReportDef {
|
interface ReportDef {
|
||||||
name: string;
|
name: string;
|
||||||
description: string;
|
description: string;
|
||||||
|
path?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const CATEGORIES: { title: string; reports: ReportDef[] }[] = [
|
const CATEGORIES: { title: string; reports: ReportDef[] }[] = [
|
||||||
{
|
{
|
||||||
title: 'Financial',
|
title: 'Financial',
|
||||||
reports: [
|
reports: [
|
||||||
{ name: 'Trial Balance', description: 'Debit and credit balances for every account, Main or per-branch.' },
|
{ 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.' },
|
{
|
||||||
{ name: 'Balance Sheet', description: 'Assets, liabilities, and equity as at a selected date.' },
|
name: 'Profit & Loss Statement',
|
||||||
{ name: 'General Ledger', description: 'Full transaction detail for any account and date range.' },
|
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',
|
title: 'Receivables',
|
||||||
reports: [
|
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.' },
|
{ name: 'Customer Statement', description: 'Transaction history and running balance per customer.' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'Payables',
|
title: 'Payables',
|
||||||
reports: [
|
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.' },
|
{ name: 'Payment Run', description: 'Supplier invoices selected for the next payment batch.' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
@@ -35,12 +41,13 @@ const CATEGORIES: { title: string; reports: ReportDef[] }[] = [
|
|||||||
reports: [
|
reports: [
|
||||||
{ name: 'Budget vs Actual', description: 'Budgeted figures compared against actual GL movement.' },
|
{ name: 'Budget vs Actual', description: 'Budgeted figures compared against actual GL movement.' },
|
||||||
{ name: 'Branch Performance', description: 'Key metrics compared across branches.' },
|
{ name: 'Branch Performance', description: 'Key metrics compared across branches.' },
|
||||||
{ name: 'Stock Valuation', description: 'On-hand stock quantities and value by warehouse.' },
|
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
export function ReportsPage() {
|
export function ReportsPage() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box>
|
<Box>
|
||||||
<Typography variant="h5" sx={{ mb: 0.5 }}>
|
<Typography variant="h5" sx={{ mb: 0.5 }}>
|
||||||
@@ -60,11 +67,13 @@ export function ReportsPage() {
|
|||||||
{category.reports.map((report) => (
|
{category.reports.map((report) => (
|
||||||
<Card
|
<Card
|
||||||
key={report.name}
|
key={report.name}
|
||||||
|
onClick={() => report.path && navigate(report.path)}
|
||||||
sx={{
|
sx={{
|
||||||
p: 2,
|
p: 2,
|
||||||
width: 280,
|
width: 280,
|
||||||
opacity: 0.6,
|
opacity: report.path ? 1 : 0.6,
|
||||||
cursor: 'not-allowed',
|
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 }}>
|
<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 }}>
|
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mb: 1.5 }}>
|
||||||
{report.description}
|
{report.description}
|
||||||
</Typography>
|
</Typography>
|
||||||
<Chip label="Coming soon" size="small" />
|
{!report.path && <Chip label="Coming soon" size="small" />}
|
||||||
</Card>
|
</Card>
|
||||||
))}
|
))}
|
||||||
</Stack>
|
</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 { PaymentDetailPage } from '../features/ap/payments/PaymentDetailPage';
|
||||||
import { AdminWarehousesPage } from '../features/admin/warehouses/AdminWarehousesPage';
|
import { AdminWarehousesPage } from '../features/admin/warehouses/AdminWarehousesPage';
|
||||||
import { StockItemsListPage } from '../features/stock/items/StockItemsListPage';
|
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() {
|
export function AppRouter() {
|
||||||
return (
|
return (
|
||||||
@@ -45,6 +50,11 @@ export function AppRouter() {
|
|||||||
<Route element={<MainLayout />}>
|
<Route element={<MainLayout />}>
|
||||||
<Route path="/" element={<DashboardPage />} />
|
<Route path="/" element={<DashboardPage />} />
|
||||||
<Route path="/reports" element={<ReportsPage />} />
|
<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" element={<JournalEntryListPage />} />
|
||||||
<Route path="/gl/journal-entries/:id" element={<JournalEntryDetailPage />} />
|
<Route path="/gl/journal-entries/:id" element={<JournalEntryDetailPage />} />
|
||||||
|
|||||||
@@ -408,3 +408,76 @@ export interface StockItem {
|
|||||||
stockLevels?: StockLevel[];
|
stockLevels?: StockLevel[];
|
||||||
createdAt: string;
|
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 { apPaymentsRouter } from './modules/ap/apPayments.routes';
|
||||||
import { warehousesRouter } from './modules/stock/warehouses.routes';
|
import { warehousesRouter } from './modules/stock/warehouses.routes';
|
||||||
import { stockItemsRouter } from './modules/stock/stockItems.routes';
|
import { stockItemsRouter } from './modules/stock/stockItems.routes';
|
||||||
|
import { reportsRouter } from './modules/reports/reports.routes';
|
||||||
|
|
||||||
export function createApp(): Express {
|
export function createApp(): Express {
|
||||||
const app = express();
|
const app = express();
|
||||||
@@ -50,6 +51,7 @@ export function createApp(): Express {
|
|||||||
app.use('/api/ap/payments', apPaymentsRouter);
|
app.use('/api/ap/payments', apPaymentsRouter);
|
||||||
app.use('/api/stock/warehouses', warehousesRouter);
|
app.use('/api/stock/warehouses', warehousesRouter);
|
||||||
app.use('/api/stock/items', stockItemsRouter);
|
app.use('/api/stock/items', stockItemsRouter);
|
||||||
|
app.use('/api/reports', reportsRouter);
|
||||||
|
|
||||||
app.use((req, res) => res.status(404).json({ error: 'Not found' }));
|
app.use((req, res) => res.status(404).json({ error: 'Not found' }));
|
||||||
app.use(errorHandler);
|
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