SPB Git

spb/air Public MIT

AIR — The Language of Accounting.

Python 100%
7.0 KB · 188 lines python
Raw Blame History
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