From eb42eb8fc84f6bf1734e2887a00600443ec78b07 Mon Sep 17 00:00:00 2001 From: Joshua Oosthuizen Date: Fri, 4 Sep 2026 10:52:08 +0200 Subject: [PATCH] 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 Claude-Session: https://claude.ai/code/session_01JWqiUSojMLUoN7mrJqdEbN --- PHASE_6_REPORTING_TEST_PLAN.md | 53 +++ client/src/api/reports.api.ts | 46 +++ .../src/features/reports/AgedPayablesPage.tsx | 39 ++ .../features/reports/AgedReceivablesPage.tsx | 39 ++ .../src/features/reports/AgedReportTable.tsx | 115 ++++++ .../src/features/reports/BalanceSheetPage.tsx | 122 +++++++ .../features/reports/ProfitAndLossPage.tsx | 138 +++++++ client/src/features/reports/ReportsPage.tsx | 29 +- .../src/features/reports/TrialBalancePage.tsx | 112 ++++++ client/src/hooks/useReports.ts | 44 +++ client/src/routes/AppRouter.tsx | 10 + client/src/types/api.ts | 73 ++++ src/app.ts | 2 + src/modules/reports/reports.controller.ts | 44 +++ src/modules/reports/reports.routes.ts | 21 ++ src/modules/reports/reports.service.ts | 343 ++++++++++++++++++ tests/integration/reports.spec.ts | 158 ++++++++ 17 files changed, 1378 insertions(+), 10 deletions(-) create mode 100644 PHASE_6_REPORTING_TEST_PLAN.md create mode 100644 client/src/api/reports.api.ts create mode 100644 client/src/features/reports/AgedPayablesPage.tsx create mode 100644 client/src/features/reports/AgedReceivablesPage.tsx create mode 100644 client/src/features/reports/AgedReportTable.tsx create mode 100644 client/src/features/reports/BalanceSheetPage.tsx create mode 100644 client/src/features/reports/ProfitAndLossPage.tsx create mode 100644 client/src/features/reports/TrialBalancePage.tsx create mode 100644 client/src/hooks/useReports.ts create mode 100644 src/modules/reports/reports.controller.ts create mode 100644 src/modules/reports/reports.routes.ts create mode 100644 src/modules/reports/reports.service.ts create mode 100644 tests/integration/reports.spec.ts diff --git a/PHASE_6_REPORTING_TEST_PLAN.md b/PHASE_6_REPORTING_TEST_PLAN.md new file mode 100644 index 0000000..1ee2a03 --- /dev/null +++ b/PHASE_6_REPORTING_TEST_PLAN.md @@ -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") diff --git a/client/src/api/reports.api.ts b/client/src/api/reports.api.ts new file mode 100644 index 0000000..7328bc4 --- /dev/null +++ b/client/src/api/reports.api.ts @@ -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 { + const response = await apiClient.get('/reports/trial-balance', { + params: { branchScope: branchParam(branchScope), asOfDate }, + }); + return response.data; +} + +export async function fetchProfitAndLoss( + branchScope: BranchSelection, + fromDate?: string, + toDate?: string, +): Promise { + const response = await apiClient.get('/reports/profit-and-loss', { + params: { branchScope: branchParam(branchScope), fromDate, toDate }, + }); + return response.data; +} + +export async function fetchBalanceSheet(branchScope: BranchSelection, asOfDate?: string): Promise { + const response = await apiClient.get('/reports/balance-sheet', { + params: { branchScope: branchParam(branchScope), asOfDate }, + }); + return response.data; +} + +export async function fetchAgedReceivables(branchScope: BranchSelection, asOfDate?: string): Promise { + const response = await apiClient.get('/reports/aged-receivables', { + params: { branchScope: branchParam(branchScope), asOfDate }, + }); + return response.data; +} + +export async function fetchAgedPayables(branchScope: BranchSelection, asOfDate?: string): Promise { + const response = await apiClient.get('/reports/aged-payables', { + params: { branchScope: branchParam(branchScope), asOfDate }, + }); + return response.data; +} diff --git a/client/src/features/reports/AgedPayablesPage.tsx b/client/src/features/reports/AgedPayablesPage.tsx new file mode 100644 index 0000000..5c23d1b --- /dev/null +++ b/client/src/features/reports/AgedPayablesPage.tsx @@ -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 ( + + + Aged Payables + + + Outstanding supplier balances by age bucket, as at the selected date. + + + + setAsOfDate(e.target.value)} + slotProps={{ inputLabel: { shrink: true } }} + /> + + + + + ); +} diff --git a/client/src/features/reports/AgedReceivablesPage.tsx b/client/src/features/reports/AgedReceivablesPage.tsx new file mode 100644 index 0000000..12681bb --- /dev/null +++ b/client/src/features/reports/AgedReceivablesPage.tsx @@ -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 ( + + + Aged Receivables + + + Outstanding customer balances by age bucket, as at the selected date. + + + + setAsOfDate(e.target.value)} + slotProps={{ inputLabel: { shrink: true } }} + /> + + + + + ); +} diff --git a/client/src/features/reports/AgedReportTable.tsx b/client/src/features/reports/AgedReportTable.tsx new file mode 100644 index 0000000..b233713 --- /dev/null +++ b/client/src/features/reports/AgedReportTable.tsx @@ -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 ( + + {isLoading ? ( + + + + ) : ( + + + + Account + Current + 1–30 days + 31–60 days + 61–90 days + 90+ days + Total + + + + {data?.rows.length === 0 && ( + + + + No outstanding balances as at this date. + + + + )} + {data?.rows.map((row) => ( + + + + {row.code} — {row.name} + + + + + + + + + + + + + + + + + + + + + + + + ))} + {data && ( + + + Total + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + )} + +
+ )} +
+ ); +} diff --git a/client/src/features/reports/BalanceSheetPage.tsx b/client/src/features/reports/BalanceSheetPage.tsx new file mode 100644 index 0000000..e96c049 --- /dev/null +++ b/client/src/features/reports/BalanceSheetPage.tsx @@ -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 ( + <> + + + + {title} + + + + {rows.map((row) => ( + + {row.accountCode ?? ''} + {row.accountName} + + + + + ))} + + + Total {title} + + + + + + + + + ); +} + +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 ( + + + Balance Sheet + {data && } + + + 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. + + + + setAsOfDate(e.target.value)} + slotProps={{ inputLabel: { shrink: true } }} + /> + + + + {isLoading ? ( + + + + ) : ( + + + + Code + Account + Amount + + + + {data &&
} + {data && ( + + + + )} + {data &&
} + {data && ( + + + + )} + {data &&
} + +
+ )} +
+
+ ); +} diff --git a/client/src/features/reports/ProfitAndLossPage.tsx b/client/src/features/reports/ProfitAndLossPage.tsx new file mode 100644 index 0000000..6a4e297 --- /dev/null +++ b/client/src/features/reports/ProfitAndLossPage.tsx @@ -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 ( + + + Profit & Loss Statement + + + Income and expense performance for the selected period. + + + + + setFromDate(e.target.value)} + slotProps={{ inputLabel: { shrink: true } }} + /> + setToDate(e.target.value)} + slotProps={{ inputLabel: { shrink: true } }} + /> + + + + + {isLoading ? ( + + + + ) : ( + + + + Code + Account + Amount + + + + + + + Income + + + + {data?.income.map((row) => ( + + {row.accountCode} + {row.accountName} + + + + + ))} + + + Total Income + + + + + + + + + + + + Expense + + + + {data?.expense.map((row) => ( + + {row.accountCode} + {row.accountName} + + + + + ))} + + + Total Expense + + + + + + + + + {data && ( + + + Net Profit + + + = 0 ? 'success.main' : 'error.main' }}> + {data.netProfit.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })} + + + + )} + +
+ )} +
+
+ ); +} diff --git a/client/src/features/reports/ReportsPage.tsx b/client/src/features/reports/ReportsPage.tsx index 5822ac5..62e6fe6 100644 --- a/client/src/features/reports/ReportsPage.tsx +++ b/client/src/features/reports/ReportsPage.tsx @@ -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 ( @@ -60,11 +67,13 @@ export function ReportsPage() { {category.reports.map((report) => ( 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, }} > @@ -76,7 +85,7 @@ export function ReportsPage() { {report.description} - + {!report.path && } ))} diff --git a/client/src/features/reports/TrialBalancePage.tsx b/client/src/features/reports/TrialBalancePage.tsx new file mode 100644 index 0000000..a6e11a5 --- /dev/null +++ b/client/src/features/reports/TrialBalancePage.tsx @@ -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 ( + + + Trial Balance + {data && } + + + Debit and credit balances for every account with posted activity, as at the selected date. + + + + setAsOfDate(e.target.value)} + slotProps={{ inputLabel: { shrink: true } }} + /> + + + + {isLoading ? ( + + + + ) : ( + + + + Code + Account + Debit + Credit + + + + {data?.rows.length === 0 && ( + + + + No posted activity as at this date. + + + + )} + {data?.rows.map((row) => ( + + {row.accountCode} + {row.accountName} + + + + + + + + ))} + {data && ( + + + Total + + + + + + + + + + + + + )} + +
+ )} +
+
+ ); +} diff --git a/client/src/hooks/useReports.ts b/client/src/hooks/useReports.ts new file mode 100644 index 0000000..a273e8a --- /dev/null +++ b/client/src/hooks/useReports.ts @@ -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), + }); +} diff --git a/client/src/routes/AppRouter.tsx b/client/src/routes/AppRouter.tsx index 19c2c71..0d88bc1 100644 --- a/client/src/routes/AppRouter.tsx +++ b/client/src/routes/AppRouter.tsx @@ -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() { }> } /> } /> + } /> + } /> + } /> + } /> + } /> } /> } /> diff --git a/client/src/types/api.ts b/client/src/types/api.ts index 53304b9..2f5def0 100644 --- a/client/src/types/api.ts +++ b/client/src/types/api.ts @@ -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; +} diff --git a/src/app.ts b/src/app.ts index edd9db0..d5f3ad8 100644 --- a/src/app.ts +++ b/src/app.ts @@ -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); diff --git a/src/modules/reports/reports.controller.ts b/src/modules/reports/reports.controller.ts new file mode 100644 index 0000000..36850aa --- /dev/null +++ b/src/modules/reports/reports.controller.ts @@ -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 { + 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 { + 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 { + 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 { + 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 { + 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 { + const asOfDate = parseDate(req.query.asOfDate, new Date()); + res.json(await reportsService.agedPayables(req.branchScope!, asOfDate)); +} diff --git a/src/modules/reports/reports.routes.ts b/src/modules/reports/reports.routes.ts new file mode 100644 index 0000000..042e24d --- /dev/null +++ b/src/modules/reports/reports.routes.ts @@ -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)); diff --git a/src/modules/reports/reports.service.ts b/src/modules/reports/reports.service.ts new file mode 100644 index 0000000..ffb5fc2 --- /dev/null +++ b/src/modules/reports/reports.service.ts @@ -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 { + 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): Promise { + 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 { + 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 { + 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 { + 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; +} + +function bucketFor(daysOverdue: number): keyof Omit { + 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 { + 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(); + 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 { + 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(); + 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 }; +} diff --git a/tests/integration/reports.spec.ts b/tests/integration/reports.spec.ts new file mode 100644 index 0000000..7ac40c7 --- /dev/null +++ b/tests/integration/reports.spec.ts @@ -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); + }); +});