# ============================================================================= # Projet : AIR — Accounting Intermediate Representation # Auteur : Simon-Pierre Boucher # Contact : contact@spboucher.ai # Fichier : reporting.py # Description : Financial statements from the native ledger — multi-format (text, markdown, csv, json). # ============================================================================= """Reporting: AIR standalone mode needs no third-party system. All statements are pure projections over the ledger: - general ledger (every line, by account) - trial balance (debit/credit totals per account) - income statement (revenue - expenses = net income) - balance sheet (assets = liabilities + equity + net income) Each report renders to: "text", "markdown", "csv", "json". Amounts stay Decimal end-to-end and serialize as strings. """ from __future__ import annotations import csv import io import json from collections import defaultdict from decimal import Decimal from core.journal import AccountType from kernel.ledger import Ledger FORMATS = ("text", "markdown", "csv", "json") Row = list[str] def _render(title: str, headers: list[str], rows: list[Row], fmt: str) -> str: if fmt == "json": return json.dumps( {"_author": "Simon-Pierre Boucher ", "report": title, "rows": [dict(zip(headers, r)) for r in rows]}, indent=2, ) if fmt == "csv": buf = io.StringIO() writer = csv.writer(buf) writer.writerow(headers) writer.writerows(rows) return buf.getvalue() if fmt == "markdown": out = [f"## {title}", "", "| " + " | ".join(headers) + " |", "|" + "|".join("---" for _ in headers) + "|"] out += ["| " + " | ".join(r) + " |" for r in rows] return "\n".join(out) + "\n" # text widths = [max(len(h), *(len(r[i]) for r in rows)) if rows else len(h) for i, h in enumerate(headers)] line = " ".join(h.ljust(widths[i]) for i, h in enumerate(headers)) sep = "-" * len(line) body = [ " ".join(r[i].ljust(widths[i]) for i in range(len(headers))) for r in rows ] return "\n".join([title, sep, line, sep, *body, sep]) + "\n" def general_ledger(ledger: Ledger, fmt: str = "text") -> str: headers = ["account", "name", "date", "entry", "side", "amount", "currency", "memo"] rows: list[Row] = [] accounts = ledger.accounts() for code, account in accounts.items(): for entry in ledger.entries: for line in entry.lines: if line.account.code != code: continue rows.append([ code, account.name, entry.date.isoformat(), entry.id, line.side.value, str(line.amount.amount), line.amount.currency, line.memo, ]) return _render("General Ledger", headers, rows, fmt) def trial_balance(ledger: Ledger, fmt: str = "text") -> str: headers = ["account", "name", "type", "currency", "debit", "credit"] debit: dict[tuple[str, str], Decimal] = defaultdict(Decimal) credit: dict[tuple[str, str], Decimal] = defaultdict(Decimal) for entry in ledger.entries: for line in entry.lines: key = (line.account.code, line.amount.currency) if line.side.value == "debit": debit[key] += line.amount.amount else: credit[key] += line.amount.amount accounts = ledger.accounts() rows: list[Row] = [] total_d = defaultdict(Decimal) total_c = defaultdict(Decimal) for key in sorted(set(debit) | set(credit)): code, ccy = key account = accounts[code] d, c = debit[key], credit[key] # present net movement on the account's normal side net = d - c d_show = net if net > 0 else Decimal(0) c_show = -net if net < 0 else Decimal(0) total_d[ccy] += d_show total_c[ccy] += c_show rows.append([code, account.name, account.type.value, ccy, str(d_show), str(c_show)]) for ccy in sorted(total_d): rows.append(["TOTAL", "", "", ccy, str(total_d[ccy]), str(total_c[ccy])]) return _render("Trial Balance", headers, rows, fmt) def _type_totals(ledger: Ledger) -> dict[AccountType, dict[str, Decimal]]: totals: dict[AccountType, dict[str, Decimal]] = { t: defaultdict(Decimal) for t in AccountType } balances = ledger.balances() accounts = ledger.accounts() for code, per_ccy in balances.items(): for ccy, bal in per_ccy.items(): totals[accounts[code].type][ccy] += bal return totals def income_statement(ledger: Ledger, fmt: str = "text") -> str: headers = ["section", "account", "currency", "amount"] accounts = ledger.accounts() balances = ledger.balances() rows: list[Row] = [] net = defaultdict(Decimal) for wanted, section, sign in ( (AccountType.REVENUE, "Revenue", 1), (AccountType.EXPENSE, "Expenses", -1), ): for code, per_ccy in balances.items(): if accounts[code].type is not wanted: continue for ccy, bal in per_ccy.items(): if bal == 0: continue rows.append([section, f"{code} {accounts[code].name}", ccy, str(bal)]) net[ccy] += sign * bal for ccy in sorted(net): rows.append(["NET INCOME", "", ccy, str(net[ccy])]) return _render("Income Statement", headers, rows, fmt) def balance_sheet(ledger: Ledger, fmt: str = "text") -> str: headers = ["section", "account", "currency", "amount"] accounts = ledger.accounts() balances = ledger.balances() totals = _type_totals(ledger) rows: list[Row] = [] for wanted, section in ( (AccountType.ASSET, "Assets"), (AccountType.LIABILITY, "Liabilities"), (AccountType.EQUITY, "Equity"), ): for code, per_ccy in balances.items(): if accounts[code].type is not wanted: continue for ccy, bal in per_ccy.items(): if bal == 0: continue rows.append([section, f"{code} {accounts[code].name}", ccy, str(bal)]) currencies = sorted({ ccy for per in totals.values() for ccy in per }) for ccy in currencies: net_income = totals[AccountType.REVENUE][ccy] - totals[AccountType.EXPENSE][ccy] rows.append(["Equity", "Net income (current period)", ccy, str(net_income)]) rows.append(["TOTAL ASSETS", "", ccy, str(totals[AccountType.ASSET][ccy])]) rows.append([ "TOTAL LIAB.+EQUITY", "", ccy, str(totals[AccountType.LIABILITY][ccy] + totals[AccountType.EQUITY][ccy] + net_income), ]) return _render("Balance Sheet", headers, rows, fmt) REPORTS = { "general-ledger": general_ledger, "trial-balance": trial_balance, "income-statement": income_statement, "balance-sheet": balance_sheet, }