spb/air Public MIT
AIR — The Language of Accounting.
Python 100%
1# =============================================================================2# Projet : AIR — Accounting Intermediate Representation3# Auteur : Simon-Pierre Boucher4# Contact : contact@spboucher.ai5# Fichier : reporting.py6# Description : Financial statements from the native ledger — multi-format (text, markdown, csv, json).7# =============================================================================8"""Reporting: AIR standalone mode needs no third-party system.910All statements are pure projections over the ledger:11- general ledger (every line, by account)12- trial balance (debit/credit totals per account)13- income statement (revenue - expenses = net income)14- balance sheet (assets = liabilities + equity + net income)1516Each report renders to: "text", "markdown", "csv", "json".17Amounts stay Decimal end-to-end and serialize as strings.18"""19from __future__ import annotations2021import csv22import io23import json24from collections import defaultdict25from decimal import Decimal2627from core.journal import AccountType28from kernel.ledger import Ledger2930FORMATS = ("text", "markdown", "csv", "json")3132Row = list[str]333435def _render(title: str, headers: list[str], rows: list[Row], fmt: str) -> str:36 if fmt == "json":37 return json.dumps(38 {"_author": "Simon-Pierre Boucher <contact@spboucher.ai>",39 "report": title,40 "rows": [dict(zip(headers, r)) for r in rows]},41 indent=2,42 )43 if fmt == "csv":44 buf = io.StringIO()45 writer = csv.writer(buf)46 writer.writerow(headers)47 writer.writerows(rows)48 return buf.getvalue()49 if fmt == "markdown":50 out = [f"## {title}", "", "| " + " | ".join(headers) + " |",51 "|" + "|".join("---" for _ in headers) + "|"]52 out += ["| " + " | ".join(r) + " |" for r in rows]53 return "\n".join(out) + "\n"54 # text55 widths = [max(len(h), *(len(r[i]) for r in rows)) if rows else len(h)56 for i, h in enumerate(headers)]57 line = " ".join(h.ljust(widths[i]) for i, h in enumerate(headers))58 sep = "-" * len(line)59 body = [60 " ".join(r[i].ljust(widths[i]) for i in range(len(headers))) for r in rows61 ]62 return "\n".join([title, sep, line, sep, *body, sep]) + "\n"636465def general_ledger(ledger: Ledger, fmt: str = "text") -> str:66 headers = ["account", "name", "date", "entry", "side", "amount", "currency", "memo"]67 rows: list[Row] = []68 accounts = ledger.accounts()69 for code, account in accounts.items():70 for entry in ledger.entries:71 for line in entry.lines:72 if line.account.code != code:73 continue74 rows.append([75 code, account.name, entry.date.isoformat(), entry.id,76 line.side.value, str(line.amount.amount),77 line.amount.currency, line.memo,78 ])79 return _render("General Ledger", headers, rows, fmt)808182def trial_balance(ledger: Ledger, fmt: str = "text") -> str:83 headers = ["account", "name", "type", "currency", "debit", "credit"]84 debit: dict[tuple[str, str], Decimal] = defaultdict(Decimal)85 credit: dict[tuple[str, str], Decimal] = defaultdict(Decimal)86 for entry in ledger.entries:87 for line in entry.lines:88 key = (line.account.code, line.amount.currency)89 if line.side.value == "debit":90 debit[key] += line.amount.amount91 else:92 credit[key] += line.amount.amount93 accounts = ledger.accounts()94 rows: list[Row] = []95 total_d = defaultdict(Decimal)96 total_c = defaultdict(Decimal)97 for key in sorted(set(debit) | set(credit)):98 code, ccy = key99 account = accounts[code]100 d, c = debit[key], credit[key]101 # present net movement on the account's normal side102 net = d - c103 d_show = net if net > 0 else Decimal(0)104 c_show = -net if net < 0 else Decimal(0)105 total_d[ccy] += d_show106 total_c[ccy] += c_show107 rows.append([code, account.name, account.type.value, ccy,108 str(d_show), str(c_show)])109 for ccy in sorted(total_d):110 rows.append(["TOTAL", "", "", ccy, str(total_d[ccy]), str(total_c[ccy])])111 return _render("Trial Balance", headers, rows, fmt)112113114def _type_totals(ledger: Ledger) -> dict[AccountType, dict[str, Decimal]]:115 totals: dict[AccountType, dict[str, Decimal]] = {116 t: defaultdict(Decimal) for t in AccountType117 }118 balances = ledger.balances()119 accounts = ledger.accounts()120 for code, per_ccy in balances.items():121 for ccy, bal in per_ccy.items():122 totals[accounts[code].type][ccy] += bal123 return totals124125126def income_statement(ledger: Ledger, fmt: str = "text") -> str:127 headers = ["section", "account", "currency", "amount"]128 accounts = ledger.accounts()129 balances = ledger.balances()130 rows: list[Row] = []131 net = defaultdict(Decimal)132 for wanted, section, sign in (133 (AccountType.REVENUE, "Revenue", 1),134 (AccountType.EXPENSE, "Expenses", -1),135 ):136 for code, per_ccy in balances.items():137 if accounts[code].type is not wanted:138 continue139 for ccy, bal in per_ccy.items():140 if bal == 0:141 continue142 rows.append([section, f"{code} {accounts[code].name}", ccy, str(bal)])143 net[ccy] += sign * bal144 for ccy in sorted(net):145 rows.append(["NET INCOME", "", ccy, str(net[ccy])])146 return _render("Income Statement", headers, rows, fmt)147148149def balance_sheet(ledger: Ledger, fmt: str = "text") -> str:150 headers = ["section", "account", "currency", "amount"]151 accounts = ledger.accounts()152 balances = ledger.balances()153 totals = _type_totals(ledger)154 rows: list[Row] = []155 for wanted, section in (156 (AccountType.ASSET, "Assets"),157 (AccountType.LIABILITY, "Liabilities"),158 (AccountType.EQUITY, "Equity"),159 ):160 for code, per_ccy in balances.items():161 if accounts[code].type is not wanted:162 continue163 for ccy, bal in per_ccy.items():164 if bal == 0:165 continue166 rows.append([section, f"{code} {accounts[code].name}", ccy, str(bal)])167 currencies = sorted({168 ccy for per in totals.values() for ccy in per169 })170 for ccy in currencies:171 net_income = totals[AccountType.REVENUE][ccy] - totals[AccountType.EXPENSE][ccy]172 rows.append(["Equity", "Net income (current period)", ccy, str(net_income)])173 rows.append(["TOTAL ASSETS", "", ccy, str(totals[AccountType.ASSET][ccy])])174 rows.append([175 "TOTAL LIAB.+EQUITY", "", ccy,176 str(totals[AccountType.LIABILITY][ccy]177 + totals[AccountType.EQUITY][ccy] + net_income),178 ])179 return _render("Balance Sheet", headers, rows, fmt)180181182REPORTS = {183 "general-ledger": general_ledger,184 "trial-balance": trial_balance,185 "income-statement": income_statement,186 "balance-sheet": balance_sheet,187}188