├── .gitignore ├── README.md ├── abacus ├── __init__.py ├── base.py ├── book.py ├── chart.py ├── entry.py └── ledger.py ├── examples ├── chart.py ├── readme.py └── roadmap.md ├── haskell └── LICENSE ├── justfile ├── package-lock.json ├── package.json ├── poetry.lock ├── posts └── 1.sql ├── prose └── assumptions.md ├── pyproject.toml └── tests ├── conftest.py ├── test_book.py ├── test_chart.py ├── test_entry.py ├── test_generate.py ├── test_ledger.py └── test_mixed.py /.gitignore: -------------------------------------------------------------------------------- 1 | # do not save JSON files with data 2 | balances.json 3 | chart.json 4 | store.json 5 | posts/db.db 6 | history*.json 7 | 8 | # npm 9 | node_modules/ 10 | 11 | # haskell 12 | */dist-newstyle/* 13 | 14 | # the rest is Python 15 | # Byte-compiled / optimized / DLL files 16 | __pycache__/ 17 | *.py[cod] 18 | *$py.class 19 | 20 | # C extensions 21 | *.so 22 | 23 | # Distribution / packaging 24 | .Python 25 | build/ 26 | develop-eggs/ 27 | dist/ 28 | downloads/ 29 | eggs/ 30 | .eggs/ 31 | lib/ 32 | lib64/ 33 | parts/ 34 | sdist/ 35 | var/ 36 | wheels/ 37 | share/python-wheels/ 38 | *.egg-info/ 39 | .installed.cfg 40 | *.egg 41 | MANIFEST 42 | 43 | # PyInstaller 44 | # Usually these files are written by a python script from a template 45 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 46 | *.manifest 47 | *.spec 48 | 49 | # Installer logs 50 | pip-log.txt 51 | pip-delete-this-directory.txt 52 | 53 | # Unit test / coverage reports 54 | htmlcov/ 55 | .tox/ 56 | .nox/ 57 | .coverage 58 | .coverage.* 59 | .cache 60 | nosetests.xml 61 | coverage.xml 62 | *.cover 63 | *.py,cover 64 | .hypothesis/ 65 | .pytest_cache/ 66 | cover/ 67 | 68 | # Translations 69 | *.mo 70 | *.pot 71 | 72 | # Django stuff: 73 | *.log 74 | local_settings.py 75 | db.sqlite3 76 | db.sqlite3-journal 77 | 78 | # Flask stuff: 79 | instance/ 80 | .webassets-cache 81 | 82 | # Scrapy stuff: 83 | .scrapy 84 | 85 | # Sphinx documentation 86 | docs/_build/ 87 | 88 | # PyBuilder 89 | .pybuilder/ 90 | target/ 91 | 92 | # Jupyter Notebook 93 | .ipynb_checkpoints 94 | 95 | # IPython 96 | profile_default/ 97 | ipython_config.py 98 | 99 | # pyenv 100 | # For a library or package, you might want to ignore these files since the code is 101 | # intended to run in multiple environments; otherwise, check them in: 102 | # .python-version 103 | 104 | # pipenv 105 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 106 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 107 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 108 | # install all needed dependencies. 109 | #Pipfile.lock 110 | 111 | # poetry 112 | # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. 113 | # This is especially recommended for binary packages to ensure reproducibility, and is more 114 | # commonly ignored for libraries. 115 | # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control 116 | #poetry.lock 117 | 118 | # pdm 119 | # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. 120 | #pdm.lock 121 | # pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it 122 | # in version control. 123 | # https://pdm.fming.dev/latest/usage/project/#working-with-version-control 124 | .pdm.toml 125 | .pdm-python 126 | .pdm-build/ 127 | 128 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm 129 | __pypackages__/ 130 | 131 | # Celery stuff 132 | celerybeat-schedule 133 | celerybeat.pid 134 | 135 | # SageMath parsed files 136 | *.sage.py 137 | 138 | # Environments 139 | .env 140 | .venv 141 | env/ 142 | venv/ 143 | ENV/ 144 | env.bak/ 145 | venv.bak/ 146 | 147 | # Spyder project settings 148 | .spyderproject 149 | .spyproject 150 | 151 | # Rope project settings 152 | .ropeproject 153 | 154 | # mkdocs documentation 155 | /site 156 | 157 | # mypy 158 | .mypy_cache/ 159 | .dmypy.json 160 | dmypy.json 161 | 162 | # Pyre type checker 163 | .pyre/ 164 | 165 | # pytype static type analyzer 166 | .pytype/ 167 | 168 | # Cython debug symbols 169 | cython_debug/ 170 | 171 | # PyCharm 172 | # JetBrains specific template is maintained in a separate JetBrains.gitignore that can 173 | # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore 174 | # and can be added to the global gitignore or merged into this file. For a more nuclear 175 | # option (not recommended) you can uncomment the following to ignore the entire idea folder. 176 | #.idea/ 177 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # abacus-minimal 2 | 3 | ![PyPI - Version](https://img.shields.io/pypi/v/abacus-minimal?color=blue) 4 | 5 | Accounting logic should be possible to express in readable code, right? 6 | 7 | `abacus-minimal` aims to be concise, correct and expressive in implementation of double entry book-keeping rules. 8 | 9 | Progress and features so far: 10 | 11 | - event-based ledger, 12 | - regular and contra accounts, 13 | - multiple entries, 14 | - period end closing, 15 | - income statement and balance sheet reports, 16 | - proxy reports before closing, 17 | - saving and loading data from JSON. 18 | 19 | ## Install 20 | 21 | ```bash 22 | pip install abacus-minimal 23 | ``` 24 | 25 |
26 | 27 | Latest: 28 | 29 | ```bash 30 | pip install git+https://github.com/epogrebnyak/abacus-minimal.git 31 | ``` 32 | 33 |
34 | 35 | ## Ledger as a sequence of events 36 | 37 | `abacus-minimal` provides an accounting ledger that is controlled by 38 | events: 39 | 40 | - chart of account changes, 41 | - business transactions, 42 | - adjustment and reconciliation entries, 43 | - closing entries. 44 | 45 | Given a sequence of events you can always recreate the ledger state. 46 | 47 | ### Example 48 | 49 | In code below account creation, three double entries and a command to close 50 | accounts are in the `events` list. This list is accepted by ledger, and from the 51 | ledger you can see the account balances and reports. 52 | 53 | ```python 54 | from abacus import Asset, Double, Equity, Expense, Income, Ledger, Close 55 | 56 | create_accounts = [ 57 | Asset("cash"), 58 | Equity("equity"), 59 | Equity("re", title="Retained earnings"), 60 | Income("sales"), 61 | Expense("salaries"), 62 | ] 63 | business_events = [ 64 | # Post entries 65 | Double("cash", "equity", 1000), 66 | Double("cash", "sales", 250), 67 | Double("salaries", "cash", 150), 68 | # Close period 69 | Close(earnings_account="re") 70 | ] 71 | events = create_accounts + business_events 72 | ledger = Ledger.from_list(events) 73 | ``` 74 | 75 | Reports reflect the state of ledger: 76 | 77 | ```python 78 | print(ledger.balances) 79 | print(ledger.income_statement()) 80 | print(ledger.balance_sheet()) 81 | ``` 82 | 83 | ### Primitive and compound events 84 | 85 | There are six types of basic, or 'primitive', events in `abacus-minimal`: 86 | 87 | 88 | 89 | Primitive event | What is does 90 | :----------------:|---------------- 91 | `Add` | Add an empty account of asset, equity, liability, income or expense type 92 | `Offset` | Аdd a empty contra account that corresponds to an existing account 93 | `Drop` | Deactivate an account 94 | `Debit` | Debit an account with a specified amount 95 | `Credit` | Credit an account with a specified amount 96 | `PeriodEnd` | Mark reporting period end 97 | 98 | 99 | 100 | From example above you can extract the primitives as following: 101 | 102 | ```python 103 | for p in ledger.history.primitives: 104 | print(p) 105 | ``` 106 | 107 | There are also compound events. Every compound event 108 | can be represented as a list of primitives. 109 | 110 | 111 | 112 | Compound event | What it does | Translates to a list of 113 | :-------------:|----------------------------------------------|------------- 114 | `Account` | Specifies an account and its contra accounts | `Add` and `Offset` 115 | `Double` and `Multiple` | Represent accounting entries | `Debit` and `Credit` 116 | `Transfer` | Moves account balance from one account to another | `Double` and `Drop` 117 | `Close` | Closes temporary accounts to aggregation account | `PeriodEnd` and `Transfer` 118 | 119 | 120 | 121 | Note that `Transfer` and `Close` are initially translated into other compound events, 122 | but ultimately, they are further simplified into the primitives. 123 | 124 | ## Ledger as class 125 | 126 | You can also work with higher-level `Chart`, `Book` and `Entry` classes 127 | without looking at events level: 128 | 129 | - `Chart` holds a chart of accounts and can be saved and loaded from JSON (`chart.json`). 130 | - `Book` is created from chart, accepts entries to post to ledger and 131 | also can be saved and loaded from JSON (`history.json`). 132 | - `Entry` is a posting to a ledger that can be a double or a multiple entry. 133 | 134 | ### Example 135 | 136 | Consider an example where you need to process the following transactions: 137 | 138 | - a company gets €20000 equity investment from shareholders, 139 | - bills a client €1000 plus 20% [value added tax (VAT)][vat], 140 | - receives €600 installment payment, 141 | - makes a €150 refund, 142 | - pays €450 in salaries to the staff. 143 | 144 | [vat]: https://taxation-customs.ec.europa.eu/taxation/vat_en 145 | 146 | ```python 147 | from abacus import Book, Chart, Entry 148 | 149 | # Create chart and ledger 150 | chart = Chart( 151 | assets=["cash", "ar"], 152 | equity=["equity"], 153 | liabilities=["tax_due"], 154 | income=["services"], 155 | expenses=["salaries"], 156 | contra_accounts={"services": ["refunds"]}, 157 | retained_earnings="retained_earnings", 158 | current_earnings="current_earnings") 159 | book = Book.from_chart(chart) 160 | 161 | # Post entries 162 | entries = [ 163 | Entry("Shareholder investment").double("cash", "equity", 20_000), 164 | Entry("Invoiced services") 165 | .debit("ar", 1200) 166 | .credit("services", 1000) 167 | .credit("tax_due", 200), 168 | Entry("Accepted payment").double("cash", "ar", 600), 169 | Entry("Made refund").double("refunds", "cash", 150), 170 | Entry("Paid salaries").double("salaries", "cash", 450), 171 | ] 172 | book.post_many(entries) 173 | print(book.balances) 174 | 175 | # Close the period and show reports 176 | book.close() 177 | print(book.income_statement) 178 | print(book.balance_sheet) 179 | ``` 180 | 181 | ## Everything as JSON 182 | 183 | All data structures used are serialisable. You can write code to create a chart of accounts and a ledger, save them to JSONs or pick up data from the JSON files and restore the ledger. 184 | 185 | ```python 186 | # Save 187 | book.save("chart.json", "history.json", allow_overwrite=True) 188 | 189 | # Load and re-enter 190 | book2 = Book.load_unsafe("chart.json", "history.json") 191 | 192 | from pprint import pprint 193 | pprint(book2) # may not fully identical to `book` yet 194 | ``` 195 | 196 | ## Accounting concepts and workflow 197 | 198 | ### Key concepts 199 | 200 | In `abacus-minimal`: 201 | 202 | - there are regular accounts of five types (asset, liability, equity, income, expense); 203 | - contra accounts to regular accounts are possible (eg depreciation, discounts); 204 | - period end closes temporary accounts (income, expense and their associated contra accounts); 205 | - balance sheet and income statement are available before and after close; 206 | - post close entries are allowed on permanent accounts; 207 | 208 | See a detailed list of assumptions and limitations [here](prose/assumptions.md). 209 | 210 | ### Workflow 211 | 212 | The steps for using `abacus-minimal` follow the steps of a typical accounting cycle: 213 | 214 | - create a chart of accounts, 215 | - open ledger for the current reporting period, 216 | - post account balances from the previous period, 217 | - post entries that reflect business transactions within the period, 218 | - post reconciliation and adjustment entries, 219 | - close accounts at reporting period end, 220 | - post entries after close, 221 | - show financial reports, 222 | - save account balances data for the next reporting period. 223 | 224 | ### Accounting identity 225 | 226 | `abacus-minimal` adheres to the following interpretation of accounting identity. 227 | 228 | 1. The value of company property, or assets, equals to shareholder and creditor claims on the company: 229 | 230 | ``` 231 | Assets = Equity + Liabilities 232 | ``` 233 | 234 | Equity is the residual claim on assets after creditors: 235 | 236 | ``` 237 | Equity = Assets - Liabilities 238 | ``` 239 | 240 | 2. Company current earnings, or profit, is equal to income less expenses associated with generating this income: 241 | 242 | ``` 243 | Current Earnings = Income - Expenses 244 | ``` 245 | 246 | Current earnings accumulate to retained earnings: 247 | 248 | ``` 249 | Retained Earnings = Retained Earnings From Previous Period + Current Earnings 250 | ``` 251 | 252 | 3. Equity consists of shareholder equity, other equity accounts and 253 | retained earnings: 254 | 255 | ``` 256 | Equity = Shareholder Equity + Other Equity + Retained Earnings 257 | ``` 258 | 259 | 4. Substituting we get a form of extended accounting equation: 260 | 261 | ``` 262 | Assets + Expenses = 263 | Shareholder Equity + Other Equity + Retained Earnings + Income + Liabilities 264 | ``` 265 | 266 | 5. We also add contra accounts: 267 | 268 | ``` 269 | Assets + Expenses + Contra Accounts To Equity, Income and Liabilitites = 270 | Shareholder Equity + Other Equity + Retained Earnings + Income + Liabilities 271 | + Contra Accounts to Assets and Expenses 272 | ``` 273 | 274 | Our book-keeping goal is to reflect business events as changes to the variables 275 | in this eqaution while maintaining the balance of it. 276 | 277 | ## Alternatives 278 | 279 | `abacus-minimal` takes a lot of inspiration from the following projects: 280 | 281 | - [ledger](https://ledger-cli.org/), 282 | [hledger](https://github.com/simonmichael/hledger), 283 | [beancount](https://github.com/beancount/beancount) 284 | and other [plain text accounting tools](https://plaintextaccounting.org/), 285 | - [medici](https://github.com/flash-oss/medici), a high performance ledger in JavaScript using Mongo database, 286 | - [microbooks](https://microbooks.io/) API and [python-accounting](https://github.com/ekmungai/python-accounting), a production-grade project, tightly coupled to a database. 287 | 288 | ## Accounting knowledge 289 | 290 | If you are totally new to accounting the suggested friendly course is . 291 | 292 | ACCA and CPA are the international and the US professional qualifications and IFRS and US GAAP are the standards for accounting recognition, measurement and disclosure. 293 | 294 | A great overview of accounting concepts is at [IFRS Conceptual Framework for Financial Reporting][ifrs]. 295 | 296 | Part B-G in the [ACCA syllabus for the FFA exam][ffa] are useful to review to work with `abacus-minimal`. 297 | 298 | [ifrs]: https://www.ifrs.org/content/dam/ifrs/publications/pdf-standards/english/2021/issued/part-a/conceptual-framework-for-financial-reporting.pdf 299 | [ffa]: https://www.accaglobal.com/content/dam/acca/global/PDF-students/acca/f3/studyguides/fa-ffa-syllabusandstudyguide-sept23-aug24.pdf 300 | 301 | Textbooks and articles: 302 | 303 | 1. [A list of free and open source textbooks](https://library.sacredheart.edu/opentextbooks/accounting). 304 | 2. [Frank Wood "Business Accounting"](https://www.google.com/search?q=Frank+Wood+%22Business+Accounting). 305 | 3. [200 Years of Accounting History Dates and Events](https://maaw.info/AccountingHistoryDatesAndEvents.htm). 306 | 307 | ## Project conventions 308 | 309 | I use [`just` command runner](https://github.com/casey/just) to automate code maintenance tasks in this project. 310 | 311 | `just test` and `just fix` scripts will run the following tools: 312 | 313 | - `pytest` 314 | - `mypy` 315 | - `black` and `isort --float-to-top` (probably should replace with `ruff format`) 316 | - `ruff check` 317 | - `prettier` for markdown formatting 318 | - `codedown` to extract Python code from README.md. 319 | 320 | `examples/readme.py` is overwritten by the `just readme` command. 321 | 322 | I use `poetry` as a package manager, but heard good things about `uv` that I want to try. 323 | 324 | ## Changelog 325 | 326 | - `0.14.2` (2024-11-23) Added `just sql` command to run database examples. 327 | - `0.14.0` (2024-11-15) Event-based ledger now on `main`. Mixed test suite and `pyright`. 328 | - `0.13.0` (2024-11-15) Event-based ledger will become next minor version. 329 | - `0.12.0` (2024-11-13) `events.py` offers events-based ledger modification. 330 | - `0.11.1` (2024-11-06) `abacus.core` now feature complete. 331 | - `0.10.7` (2024-11-02) `Posting` type is a list of single entries. 332 | - `0.10.5` (2024-10-27) Handles income statement and balances sheet before and after close. 333 | - `0.10.0` (2024-10-24) Separates core, chart, entry and book code and tests. 334 | -------------------------------------------------------------------------------- /abacus/__init__.py: -------------------------------------------------------------------------------- 1 | from .base import T5, AbacusError 2 | from .book import Book 3 | from .chart import Add, Asset, Chart, Equity, Expense, Income, Liability, Offset 4 | from .entry import Credit, Debit, Double, Entry, Multiple 5 | from .ledger import Close, Contra, Event, History, Initial, Ledger 6 | -------------------------------------------------------------------------------- /abacus/base.py: -------------------------------------------------------------------------------- 1 | from decimal import Decimal 2 | from enum import Enum 3 | from pathlib import Path 4 | from typing import Iterable 5 | 6 | Numeric = int | float | Decimal 7 | 8 | 9 | class Operation(object): 10 | """A unit of change of the ledger state. 11 | 12 | Types of operations: 13 | - Account and Charting: change chart of accounts, 14 | - Posting: change account balances, 15 | - Closing: compound period end operation on a ledger. 16 | """ 17 | 18 | 19 | class Charting(Operation): 20 | """Change chart of accounts.""" 21 | 22 | 23 | class Posting(Operation): 24 | """Change account balances.""" 25 | 26 | 27 | class Closing(Operation): 28 | """Close accounts at period end.""" 29 | 30 | 31 | class AbacusError(Exception): 32 | pass 33 | 34 | @staticmethod 35 | def must_not_exist(collection: Iterable[str], name: str): 36 | if name in collection: 37 | raise AbacusError(f"Account {name} already exists.") 38 | 39 | @staticmethod 40 | def must_exist(collection: Iterable[str], name: str): 41 | if name not in collection: 42 | raise AbacusError(f"Account {name} not found.") 43 | 44 | 45 | class T5(Enum): 46 | Asset = "asset" 47 | Liability = "liability" 48 | Equity = "equity" 49 | Income = "income" 50 | Expense = "expense" 51 | 52 | def __repr__(self): 53 | return self.value.capitalize() 54 | 55 | 56 | class SaveLoadMixin: 57 | """A mix-in class for loading and saving pydantic models to files.""" 58 | 59 | @classmethod 60 | def load(cls, filename: str | Path): 61 | return cls.model_validate_json(Path(filename).read_text()) # type: ignore 62 | 63 | def save(self, filename: str | Path, allow_overwrite: bool = False): 64 | if not allow_overwrite and Path(filename).exists(): 65 | raise FileExistsError(f"File already exists: {filename}") 66 | content = self.model_dump_json(indent=2, warnings=False) # type: ignore 67 | Path(filename).write_text(content) # type: ignore 68 | -------------------------------------------------------------------------------- /abacus/book.py: -------------------------------------------------------------------------------- 1 | """User-facing Book class for an accounting ledger.""" 2 | 3 | from dataclasses import dataclass 4 | from typing import Iterable 5 | 6 | from .chart import Chart, Earnings 7 | from .entry import Entry 8 | from .ledger import History, Initial, Ledger 9 | 10 | 11 | @dataclass 12 | class Book: 13 | earnings: Earnings 14 | ledger: Ledger 15 | 16 | @classmethod 17 | def from_chart(cls, chart: Chart): 18 | ledger = Ledger.from_accounts(chart.accounts) 19 | return cls(chart.earnings, ledger) 20 | 21 | @property 22 | def chart(self) -> Chart: 23 | return self.earnings.to_chart(self.ledger.history.accounts) 24 | 25 | def open(self, balances: dict): 26 | self.ledger.apply(Initial(balances)) 27 | 28 | def post(self, entry: Entry): 29 | self.ledger.apply(entry.to_multiple(), entry.title) 30 | 31 | def post_many(self, entries: Iterable[Entry]): 32 | for entry in entries: 33 | self.post(entry) 34 | 35 | def close(self): 36 | self.ledger.close(self.earnings.retained) 37 | 38 | @property 39 | def balances(self): 40 | return self.ledger.balances 41 | 42 | @property 43 | def income_statement(self): 44 | return self.ledger.income_statement() 45 | 46 | @property 47 | def balance_sheet(self): 48 | return self.ledger.balance_sheet(self.earnings.current) 49 | 50 | def save_chart(self, path, allow_overwrite=False): 51 | self.chart.save(path, allow_overwrite) 52 | 53 | @classmethod 54 | def from_chart_dump(cls, path): 55 | chart = Chart.load(path) 56 | return cls(chart.earnings, Ledger()) 57 | 58 | def save_history(self, path, allow_overwrite=False): 59 | self.ledger.history.save(path, allow_overwrite) 60 | 61 | def load_history(self, path): 62 | return History.load(path) 63 | 64 | def save(self, chart_path, history_path, allow_overwrite=False): 65 | """Save book to chart and history JSON files.""" 66 | self.chart.save(chart_path, allow_overwrite) 67 | self.ledger.history.save(history_path, allow_overwrite) 68 | 69 | @classmethod 70 | def load_unsafe(cls, chart_path, history_path): 71 | """Unsafe method to load a Book from chart and history JSON files.""" 72 | self = cls.from_chart_dump(chart_path) 73 | history = self.load_history(history_path) 74 | self.ledger = Ledger.from_accounts(history.accounts) 75 | # pray that the history is consistent with the chart (fixme to cheeck) 76 | return self 77 | -------------------------------------------------------------------------------- /abacus/chart.py: -------------------------------------------------------------------------------- 1 | """Chart of accounts and events to modify it. 2 | 3 | The primitive events are: 4 | 5 | - `Add` and `Offset` to add regular and contra accounts, 6 | - `Drop` to deactivate an empty account. 7 | 8 | `Account` is a parent class for `Asset`, `Equity`, `Liability`, `Income`, and `Expense`. 9 | `Account` is a compound event that translates to a sequence of `Add` and `Offset` events. 10 | 11 | `BaseChart` has accoutns of five types and their contra accounts. 12 | 13 | `Earnings` class indicates current and retained earnings account names 14 | that we need for closing the ledger at period end. 15 | 16 | `Chart` is a serializable chart of accounts that: 17 | - can be saved and loaded from a flat and readable JSON, 18 | - has 4 validation methods for consistency checks, 19 | - contains `BaseChart` and `Earnings` objects. 20 | """ 21 | 22 | from abc import ABC, abstractmethod 23 | from dataclasses import dataclass, field 24 | from typing import Iterable, Iterator, Literal 25 | 26 | from pydantic import BaseModel, ConfigDict 27 | 28 | from .base import T5, AbacusError, Charting, Operation, SaveLoadMixin 29 | 30 | 31 | @dataclass 32 | class Add(Charting): 33 | """Add account.""" 34 | 35 | name: str 36 | t: T5 37 | tag: Literal["add"] = "add" 38 | 39 | 40 | @dataclass 41 | class Offset(Charting): 42 | """Add contra account.""" 43 | 44 | parent: str 45 | name: str 46 | tag: Literal["offset"] = "offset" 47 | 48 | 49 | # FIXME: drop affects ledger but not chart 50 | @dataclass 51 | class Drop(Charting): 52 | """Drop account if the account and its contra accounts have zero balances.""" 53 | 54 | name: str 55 | tag: Literal["drop"] = "drop" 56 | 57 | 58 | @dataclass 59 | class Account(ABC, Operation, Iterable[Add | Offset]): 60 | name: str 61 | contra_accounts: list[str] = field(default_factory=list) 62 | title: str | None = None 63 | 64 | @property 65 | def headline(self) -> str: 66 | return f"{self.tag}:{self.name}" 67 | 68 | @property 69 | @abstractmethod 70 | def tag(self): 71 | pass 72 | 73 | @property 74 | def t(self) -> T5: 75 | return T5(self.tag) 76 | 77 | def __iter__(self) -> Iterator[Add | Offset]: 78 | yield Add(self.name, self.t) 79 | for contra_name in self.contra_accounts: 80 | yield Offset(self.name, contra_name) 81 | 82 | 83 | @dataclass 84 | class Asset(Account): 85 | tag: Literal["asset"] = "asset" # type: ignore 86 | 87 | 88 | @dataclass 89 | class Equity(Account): 90 | tag: Literal["equity"] = "equity" # type: ignore 91 | 92 | 93 | @dataclass 94 | class Liability(Account): 95 | tag: Literal["liability"] = "liability" # type: ignore 96 | 97 | 98 | @dataclass 99 | class Income(Account): 100 | tag: Literal["income"] = "income" # type: ignore 101 | 102 | 103 | @dataclass 104 | class Expense(Account): 105 | tag: Literal["expense"] = "expense" # type: ignore 106 | 107 | 108 | class BaseChart(BaseModel): 109 | """Chart of accounts without earnings accounts.""" 110 | 111 | model_config = ConfigDict(extra="forbid") 112 | 113 | assets: list[str] = [] 114 | equity: list[str] = [] 115 | liabilities: list[str] = [] 116 | income: list[str] = [] 117 | expenses: list[str] = [] 118 | contra_accounts: dict[str, list[str]] = {} 119 | names: dict[str, str] = {} 120 | 121 | def extend(self, accounts: Iterable["Account"]): 122 | """Add a list of accounts to chart.""" 123 | for account in accounts: 124 | self.add_account(account) 125 | return self 126 | 127 | def add_account(self, account: "Account"): 128 | """Add account to chart.""" 129 | self.add_string(account.headline) 130 | for contra in account.contra_accounts: 131 | self.offset(account.name, contra) 132 | if account.title: 133 | self.name(account.name, account.title) 134 | return self 135 | 136 | def add_string(self, string: str): 137 | """Add account by slug like `asset:cash` or `a:cash`.""" 138 | prefix, account_name = string.split(":") 139 | match prefix[0].lower(): 140 | case "a": 141 | self.assets.append(account_name) 142 | case "l": 143 | self.liabilities.append(account_name) 144 | case "c": 145 | self.equity.append(account_name) 146 | case "e": 147 | match prefix[1].lower(): 148 | case "q": 149 | self.equity.append(account_name) 150 | case "x": 151 | self.expenses.append(account_name) 152 | case _: 153 | raise AbacusError(f"Invalid prefix: {prefix}") 154 | case "i": 155 | self.income.append(account_name) 156 | case _: 157 | raise AbacusError(f"Invalid prefix: {prefix}") 158 | return self 159 | 160 | def offset(self, account_name: str, contra_name: str): 161 | """Add contra account to chart.""" 162 | AbacusError.must_exist(self.account_names, account_name) 163 | if contra_name in self.contra_accounts: 164 | self.contra_accounts[account_name].append(contra_name) 165 | else: 166 | self.contra_accounts[account_name] = [contra_name] 167 | return self 168 | 169 | def name(self, account_name: str, title: str): 170 | """Add descriptive account title.""" 171 | self.names[account_name] = title 172 | return self 173 | 174 | @property 175 | def matching(self): 176 | """Match attributes with account classes.""" 177 | return ( 178 | (Asset, "assets"), 179 | (Equity, "equity"), 180 | (Liability, "liabilities"), 181 | (Income, "income"), 182 | (Expense, "expenses"), 183 | ) 184 | 185 | @property 186 | def accounts(self) -> list["Account"]: 187 | """Accounts from this chart.""" 188 | accounts = [] 189 | for cls, attribute in self.matching: 190 | for account_name in getattr(self, attribute): 191 | contra_names = self.contra_accounts.get(account_name, []) 192 | title = self.names.get(account_name, None) 193 | accounts.append(cls(account_name, contra_names, title)) 194 | return accounts 195 | 196 | @property 197 | def regular_names(self): 198 | """All regular account names.""" 199 | return [add.name for add in self.accounts] 200 | 201 | @property 202 | def contra_names(self) -> list[str]: 203 | """All contra account names.""" 204 | return [ 205 | name 206 | for contra_names in self.contra_accounts.values() 207 | for name in contra_names 208 | ] 209 | 210 | @property 211 | def account_names(self) -> list[str]: 212 | """All accounts in this chart including the duplicates.""" 213 | return self.regular_names + self.contra_names 214 | 215 | @property 216 | def duplicates(self) -> list[str]: 217 | """Duplicate account names. Must be empty for valid chart.""" 218 | names = self.account_names 219 | for name in set(names): 220 | names.remove(name) 221 | return names 222 | 223 | def assert_account_names_are_unique(self): 224 | """Raise error if duplicate account names are found.""" 225 | if ds := self.duplicates: 226 | raise AbacusError(f"Account names are not unique: {ds}") 227 | 228 | def assert_contra_account_references_are_valid(self): 229 | """Raise error if any contra account reference is invalid.""" 230 | regular_names = self.regular_names 231 | for parent_account in self.contra_accounts.keys(): 232 | AbacusError.must_exist(regular_names, parent_account) 233 | 234 | def to_chart(self, current_earnings: str, retained_earnings: str) -> "Chart": 235 | """Convert to chart using current and retained earnings account names.""" 236 | return Chart( 237 | **self.model_dump(), 238 | current_earnings=current_earnings, 239 | retained_earnings=retained_earnings, 240 | ) 241 | 242 | 243 | class Chart(BaseChart, SaveLoadMixin): 244 | """Serializable chart of accounts that is saved to plain JSON.""" 245 | 246 | current_earnings: str 247 | retained_earnings: str 248 | 249 | @classmethod 250 | def new( 251 | cls, 252 | current_earnings: str, 253 | retained_earnings: str, 254 | accounts: Iterable["Account"], 255 | ): 256 | """Create chart from account classes.""" 257 | self = cls( 258 | current_earnings=current_earnings, retained_earnings=retained_earnings 259 | ) 260 | self.extend(accounts) 261 | return cls(**self.model_dump()) 262 | 263 | def model_post_init(self, _): 264 | self.move_retained_earnings_to_chart() 265 | self.assert_current_earnings_not_in_chart() 266 | self.assert_account_names_are_unique() 267 | self.assert_contra_account_references_are_valid() 268 | 269 | def move_retained_earnings_to_chart(self): 270 | """Add retained earnings to equity account if not already there.""" 271 | re = Equity(self.retained_earnings) 272 | if re.name not in self.equity: 273 | self.add_account(re) 274 | 275 | def assert_current_earnings_not_in_chart(self): 276 | """Raise error if current earnings is in chart.""" 277 | AbacusError.must_not_exist(self.account_names, self.current_earnings) 278 | 279 | @property 280 | def base(self) -> BaseChart: 281 | """Base chart without earnings.""" 282 | dump = self.model_dump() 283 | del dump["current_earnings"] 284 | del dump["retained_earnings"] 285 | return BaseChart(**dump) 286 | 287 | @property 288 | def earnings(self) -> "Earnings": 289 | """Earnings account names from this chart.""" 290 | return Earnings(current=self.current_earnings, retained=self.retained_earnings) 291 | 292 | 293 | class Earnings(BaseModel): 294 | current: str 295 | retained: str 296 | 297 | def to_chart(self, accounts: Iterable["Account"]) -> Chart: 298 | return BaseChart().extend(accounts).to_chart(self.current, self.retained) 299 | -------------------------------------------------------------------------------- /abacus/entry.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass, field 2 | from decimal import Decimal 3 | from typing import Iterable, Literal 4 | 5 | from .base import AbacusError, Numeric, Posting 6 | 7 | 8 | @dataclass 9 | class Debit(Posting): 10 | """Increase debit-normal accounts, decrease credit-normal accounts.""" 11 | 12 | account: str 13 | amount: int | float | Decimal 14 | tag: Literal["debit"] = "debit" 15 | 16 | 17 | @dataclass 18 | class Credit(Posting): 19 | """Increase credit-normal accounts, decrease debit-normal accounts.""" 20 | 21 | account: str 22 | amount: int | float | Decimal 23 | tag: Literal["credit"] = "credit" 24 | 25 | 26 | @dataclass 27 | class Double(Posting, Iterable[Debit | Credit]): 28 | """Double-entry transaction.""" 29 | 30 | debit: str 31 | credit: str 32 | amount: int | float | Decimal 33 | tag: Literal["double"] = "double" 34 | 35 | def __iter__(self): 36 | yield Debit(self.debit, self.amount) 37 | yield Credit(self.credit, self.amount) 38 | 39 | 40 | @dataclass 41 | class Unbalanced(Posting, Iterable[Debit | Credit]): 42 | """Multiple entry that is not guaranteed to be balanced by debits and credits.""" 43 | 44 | debits: list[tuple[str, Numeric]] = field(default_factory=list) 45 | credits: list[tuple[str, Numeric]] = field(default_factory=list) 46 | 47 | def __iter__(self): 48 | for account, amount in self.debits: 49 | yield Debit(account, Decimal(amount)) 50 | for account, amount in self.credits: 51 | yield Credit(account, Decimal(amount)) 52 | 53 | @staticmethod 54 | def sums(xs): 55 | return sum(x for (_, x) in xs) 56 | 57 | def is_balanced(self): 58 | return self.sums(self.debits) == self.sums(self.credits) 59 | 60 | def to_multiple(self): 61 | return Multiple(debits=self.debits, credits=self.credits) 62 | 63 | 64 | @dataclass 65 | class Multiple(Unbalanced): 66 | """Multiple entry that is balanced by debits and credits.""" 67 | 68 | tag: Literal["multiple"] = "multiple" 69 | 70 | @classmethod 71 | def from_list(cls, singles): 72 | entry = Unbalanced() 73 | for s in singles: 74 | match s: 75 | case Debit(account, amount): 76 | entry.debits.append((account, amount)) 77 | case Credit(account, amount): 78 | entry.credits.append((account, amount)) 79 | return entry.to_multiple() 80 | 81 | def __post_init__(self): 82 | self.validate() 83 | 84 | def validate(self): 85 | ds = self.sums(self.debits) 86 | cs = self.sums(self.credits) 87 | if ds != cs: 88 | raise AbacusError( 89 | f"Debits {ds} and credits {cs} are not balanced for {self}." 90 | ) 91 | return self 92 | 93 | 94 | @dataclass 95 | class Entry: 96 | title: str 97 | debits: list[tuple[str, Decimal]] = field(default_factory=list) 98 | credits: list[tuple[str, Decimal]] = field(default_factory=list) 99 | _amount: Decimal | None = None 100 | 101 | def amount(self, amount): 102 | """Set amount for the entry.""" 103 | self._amount = Decimal(amount) 104 | return self 105 | 106 | def get_amount(self, amount: Numeric | None = None) -> Decimal: 107 | """Use provided amount, default amount or raise error if no data about amount.""" 108 | if amount is None: 109 | if self._amount: 110 | return self._amount 111 | else: 112 | raise AbacusError("Amount is not set.") 113 | else: 114 | return Decimal(amount) 115 | 116 | def debit(self, account, amount=None): 117 | amount = self.get_amount(amount) 118 | self.debits.append((account, amount)) 119 | return self 120 | 121 | def credit(self, account, amount=None): 122 | amount = self.get_amount(amount) 123 | self.credits.append((account, amount)) 124 | return self 125 | 126 | def double(self, debit, credit, amount): 127 | self.debit(debit, amount) 128 | self.credit(credit, amount) 129 | return self 130 | 131 | def to_multiple(self): 132 | return Multiple(debits=self.debits, credits=self.credits) # type: ignore 133 | 134 | def __iter__(self): 135 | return iter(self.to_multiple()) 136 | -------------------------------------------------------------------------------- /abacus/ledger.py: -------------------------------------------------------------------------------- 1 | """An accounting ledger that equates to a sequence of events. 2 | 3 | The main class is `Ledger`. You can modify the state of ledger by applying operations to it. 4 | 5 | The basic ('primitive') operations are: 6 | - `Add` and `Offset` to add regular and contra accounts, 7 | - `Debit` and `Credit` to change account balances, 8 | - `PeriodEnd` to mark accounting period end, 9 | - `Drop` to deactivate an empty account. 10 | 11 | The compound operations are: 12 | - `Account` to specify an account together with contra accounts, 13 | - `Initial` to open ledger with initial balances, 14 | - `Double` to make a double entry, 15 | - `Multiple` to make a multiple entry, 16 | - `Transfer` to move account balance to another account, 17 | - `Close` for closing accounts at period end. 18 | 19 | Each compound operation consists of a sequence of basic operations. 20 | 21 | `Ledger.history` holds a sequence of events that where applied to the ledger. 22 | You can re-run the events on empty ledger and will arrive to the same state of ledger. 23 | """ 24 | 25 | from abc import ABC, abstractmethod 26 | from collections import UserDict 27 | from copy import deepcopy 28 | from dataclasses import dataclass, field 29 | from decimal import Decimal 30 | from typing import Iterable, Literal 31 | 32 | import simplejson as json # type: ignore 33 | from pydantic import BaseModel 34 | 35 | from .base import T5, AbacusError, Closing, Numeric, Operation, SaveLoadMixin 36 | from .chart import Account, Add, Asset, Drop, Equity, Expense, Income, Liability, Offset 37 | from .entry import Credit, Debit, Double, Multiple, Posting, Unbalanced 38 | 39 | 40 | @dataclass 41 | class Initial(Posting): 42 | """Open ledger with initial balances.""" 43 | 44 | balances: dict[str, Numeric] 45 | tag: Literal["initial"] = "initial" 46 | 47 | def to_entry(self, chart: "ChartDict") -> Multiple: 48 | """Convert event to valid multiple entry using chart.""" 49 | entry = Unbalanced() 50 | for account, amount in self.balances.items(): 51 | AbacusError.must_exist(chart, account) 52 | pair = account, Decimal(amount) 53 | if chart.is_debit_account(account): 54 | entry.debits.append(pair) 55 | else: 56 | entry.credits.append(pair) 57 | return entry.to_multiple() 58 | 59 | 60 | @dataclass 61 | class Transfer(Closing): 62 | """Transfer account balance to another account.""" 63 | 64 | from_account: str 65 | to_account: str 66 | tag: Literal["transfer"] = "transfer" 67 | 68 | 69 | @dataclass 70 | class PeriodEnd(Closing): 71 | """Mark end of accounting period and save the copy of ledger 72 | to be used for the income statement. 73 | """ 74 | 75 | tag: Literal["period_end"] = "period_end" 76 | 77 | 78 | @dataclass 79 | class Close(Closing): 80 | """Close ledger to earnings account.""" 81 | 82 | earnings_account: str 83 | tag: Literal["close"] = "close" 84 | 85 | 86 | @dataclass 87 | class TAccount(ABC): 88 | balance: Decimal = Decimal(0) 89 | 90 | @abstractmethod 91 | def debit(self, amount: int | float | Decimal): 92 | pass 93 | 94 | def credit(self, amount: int | float | Decimal): 95 | self.debit(-amount) 96 | 97 | def is_empty(self) -> bool: 98 | return self.balance == Decimal(0) 99 | 100 | @abstractmethod 101 | def transfer(self, from_account: str, to_account: str) -> Double: 102 | pass 103 | 104 | 105 | class DebitAccount(TAccount): 106 | def debit(self, amount: int | float | Decimal): 107 | self.balance += Decimal(amount) 108 | 109 | def transfer(self, from_account: str, to_account: str) -> Double: 110 | return Double(to_account, from_account, self.balance) 111 | 112 | 113 | class CreditAccount(TAccount): 114 | def debit(self, amount: int | float | Decimal): 115 | self.balance -= Decimal(amount) 116 | 117 | def transfer(self, from_account: str, to_account: str) -> Double: 118 | return Double(from_account, to_account, self.balance) 119 | 120 | @dataclass 121 | class Contra: 122 | """Contra account, refers to an existing regular account.""" 123 | 124 | name: str 125 | 126 | class ChartDict(UserDict[str, T5 | Contra]): 127 | """A representation of chart of accounts that ensures account names are unique.""" 128 | 129 | def is_debit_account(self, name) -> bool: 130 | """Return True if *name* is debit-normal account.""" 131 | AbacusError.must_exist(self, name) 132 | match self[name]: 133 | case Contra(name): 134 | return not self.is_debit_account(name) 135 | case t: 136 | return t in {T5.Asset, T5.Expense} 137 | 138 | def by_type(self, t: T5) -> list[str]: 139 | """List regular accounts of a given type.""" 140 | return [name for name, account_type in self.data.items() if account_type == t] 141 | 142 | def find_contra_accounts(self, regular_account_name: str) -> list[str]: 143 | """List contra accounts for an given regular account.""" 144 | return [ 145 | contra_name 146 | for contra_name, parent in self.data.items() 147 | if parent == Contra(regular_account_name) 148 | ] 149 | 150 | def close_contra_accounts(chart: ChartDict, t: T5) -> Iterable[tuple[str, str]]: 151 | """Yield pairs of account names for closing contra accounts.""" 152 | for account_name in chart.by_type(t): 153 | for contra_name in chart.find_contra_accounts(account_name): 154 | yield (contra_name, account_name) 155 | 156 | 157 | def closing_pairs(chart: ChartDict, earnings_account: str) -> Iterable[tuple[str, str]]: 158 | """Yield pairs of account names that will need to transfer when closing.""" 159 | for t in (T5.Income, T5.Expense): 160 | yield from close_contra_accounts(chart, t) 161 | for account_name in chart.by_type(t): 162 | yield (account_name, earnings_account) 163 | 164 | 165 | Primitive = Add | Offset | Debit | Credit | PeriodEnd | Drop 166 | AccountType = Asset | Equity | Liability | Income | Expense 167 | EntryType = Double | Multiple | Initial 168 | ClosingType = Transfer | Close 169 | # TODO: may disqualify primitives from actions or allow a list of primitives in actions 170 | Action = Primitive | AccountType | EntryType | ClosingType 171 | # Action2 = list[Primitive] | AccountType | EntryType | ClosingType 172 | 173 | @dataclass 174 | class Event: 175 | action: Action 176 | primitives: list[Primitive] 177 | note: str | None 178 | 179 | 180 | class History(BaseModel, SaveLoadMixin): 181 | events: list[Event] = field(default_factory=list) 182 | 183 | def append( 184 | self, action: Action, primitives: list[Primitive], note: str | None = None 185 | ): 186 | event = Event(action, primitives, note) 187 | self.events.append(event) 188 | 189 | @property 190 | def primitives(self) -> Iterable[Primitive]: 191 | for event in self.events: 192 | yield from event.primitives 193 | 194 | @property 195 | def actions(self) -> Iterable: 196 | for event in self.events: 197 | yield event.action 198 | 199 | @property 200 | def accounts(self) -> Iterable[Account]: 201 | for action in self.actions: 202 | if isinstance(action, Account): 203 | yield action 204 | 205 | def to_ledger(self) -> "Ledger": 206 | """Re-create ledger from history of actions.""" 207 | return Ledger.from_list(self.actions) 208 | 209 | 210 | @dataclass 211 | class Ledger: 212 | accounts: dict[str, TAccount] = field(default_factory=dict) 213 | chart: ChartDict = field(default_factory=ChartDict) 214 | history: History = field(default_factory=History) 215 | accounts_before_close: dict[str, TAccount] | None = None 216 | 217 | def is_closed(self) -> bool: 218 | return self.accounts_before_close is not None 219 | 220 | @classmethod 221 | def from_accounts(cls, accounts: Iterable[Account]): 222 | return cls.from_list(accounts) # type: ignore 223 | 224 | @classmethod 225 | def from_list(cls, actions: Iterable[Action]): 226 | return cls().apply_many(actions) 227 | 228 | @property 229 | def balances(self): 230 | return ReportDict( 231 | {name: account.balance for name, account in self.accounts.items()} 232 | ) 233 | 234 | def _create_account(self, name): 235 | if self.chart.is_debit_account(name): 236 | self.accounts[name] = DebitAccount() 237 | else: 238 | self.accounts[name] = CreditAccount() 239 | 240 | def run_iterable(self, iterable) -> list[Primitive]: 241 | """Get primitives from an iterable action and use them to change ledger.""" 242 | primitives: list[Primitive] = [] 243 | for operation in iter(iterable): 244 | primitives += self.run(operation) 245 | return primitives 246 | 247 | def run(self, action: Operation) -> list[Primitive]: 248 | """Apply action and return list of primitives.""" 249 | if isinstance(action, (Account, Double, Multiple)): 250 | return self.run_iterable(action) 251 | match action: 252 | case Add(name, t): 253 | AbacusError.must_not_exist(self.chart, name) 254 | self.chart[name] = t 255 | self._create_account(name) 256 | return [action] 257 | case Offset(parent, name): 258 | if parent not in self.chart: 259 | raise AbacusError(f"Account {parent} must exist.") 260 | AbacusError.must_not_exist(self.chart, name) 261 | self.chart[name] = Contra(parent) 262 | self._create_account(name) 263 | return [action] 264 | case Drop(name): 265 | if not self.accounts[name].is_empty(): 266 | raise AbacusError(f"Account {name} is not empty.") 267 | # FIMXE: must also check contra accounts are empty 268 | del self.accounts[name] 269 | return [action] 270 | case Debit(account, amount): 271 | AbacusError.must_exist(self.accounts, account) 272 | self.accounts[account].debit(amount) 273 | return [action] 274 | case Credit(account, amount): 275 | AbacusError.must_exist(self.accounts, account) 276 | self.accounts[account].credit(amount) 277 | return [action] 278 | case Initial(_): 279 | initial_entry = action.to_entry(self.chart) 280 | return self.run(initial_entry) 281 | case Transfer(from_account, to_account): 282 | transfer_entry = self.transfer_entry(from_account, to_account) 283 | return self.run(transfer_entry) 284 | case PeriodEnd(): 285 | self.accounts_before_close = deepcopy(self.accounts) 286 | return [action] 287 | case Close(earnings_account): 288 | actions = self.close_ledger_items(earnings_account) 289 | return self.run_iterable(actions) 290 | case _: 291 | raise AbacusError(f"Unknown {action}") 292 | 293 | def transfer_entry(self, from_account, to_account): 294 | return self.accounts[from_account].transfer(from_account, to_account) 295 | 296 | def close_ledger_items(self, earnings_account: str) -> Iterable[Operation]: 297 | yield PeriodEnd() 298 | for from_account, to_account in closing_pairs(self.chart, earnings_account): 299 | yield Transfer(from_account, to_account) 300 | yield Drop(from_account) 301 | 302 | def close(self, earnings_account: str): 303 | self.apply(Close(earnings_account)) 304 | return self 305 | 306 | def apply(self, action: Action, note: str | None = None): 307 | operations = self.run(action) 308 | self.history.append(action, operations, note) 309 | return self 310 | 311 | def apply_many(self, actions: Iterable[Action], note: str | None = None): 312 | for action in actions: 313 | self.apply(action, note) 314 | return self 315 | 316 | def income_statement(self) -> "IncomeStatement": 317 | if self.is_closed(): 318 | return IncomeStatement.new(self.accounts_before_close, self.chart) 319 | else: 320 | return IncomeStatement.new(self.accounts, self.chart) 321 | 322 | def proxy(self, proxy_earnings_account: str) -> "Ledger": 323 | """Create a shallow ledger copy and close to proxy accumulation account.""" 324 | return ( 325 | Ledger(accounts=deepcopy(self.accounts), chart=deepcopy(self.chart)) 326 | .apply(Equity(proxy_earnings_account)) 327 | .close(proxy_earnings_account) 328 | ) 329 | 330 | def balance_sheet(self, proxy_earnings: str = "current_earnings") -> "BalanceSheet": 331 | ledger = self if self.is_closed() else self.proxy(proxy_earnings) 332 | return BalanceSheet.new(ledger.accounts, ledger.chart) 333 | 334 | 335 | @dataclass 336 | class Reporter: 337 | accounts: dict[str, TAccount] 338 | chart: ChartDict 339 | 340 | def net_balance(self, name: str) -> Decimal: 341 | """Return account balance minus associated contra account balances.""" 342 | contra_account_balances = [ 343 | self.accounts[contra_name].balance 344 | for contra_name in self.chart.find_contra_accounts(name) 345 | ] 346 | return Decimal(self.accounts[name].balance - sum(contra_account_balances)) 347 | 348 | def fill(self, t: T5) -> "ReportDict": 349 | """Return net balances for a given account type.""" 350 | result = ReportDict() 351 | for name in self.chart.by_type(t): 352 | result[name] = self.net_balance(name) 353 | return result 354 | 355 | 356 | class ReportDict(UserDict[str, Decimal], SaveLoadMixin): 357 | @property 358 | def total(self): 359 | return Decimal(sum(self.data.values())) 360 | 361 | def model_dump_json(self, indent: int = 2, warnings: bool = False): 362 | return json.dumps(self.data, indent=indent) 363 | 364 | @classmethod 365 | def model_validate_json(cls, text: str): 366 | return cls(json.loads(text)) 367 | 368 | 369 | class Report: 370 | """Base class for financial reports.""" 371 | 372 | 373 | @dataclass 374 | class IncomeStatement(Report): 375 | income: ReportDict 376 | expenses: ReportDict 377 | 378 | @classmethod 379 | def new(cls, balances, chart): 380 | reporter = Reporter(balances, chart) 381 | return cls(income=reporter.fill(T5.Income), expenses=reporter.fill(T5.Expense)) 382 | 383 | @property 384 | def net_earnings(self): 385 | """Calculate net earnings as income less expenses.""" 386 | return self.income.total - self.expenses.total 387 | 388 | 389 | @dataclass 390 | class BalanceSheet(Report): 391 | assets: ReportDict 392 | equity: ReportDict 393 | liabilities: ReportDict 394 | 395 | @classmethod 396 | def new(cls, balances, chart): 397 | reporter = Reporter(balances, chart) 398 | return cls( 399 | assets=reporter.fill(T5.Asset), 400 | equity=reporter.fill(T5.Equity), 401 | liabilities=reporter.fill(T5.Liability), 402 | ) 403 | 404 | def is_balanced(self) -> bool: 405 | """Return True if assets equal liabilities plus equity.""" 406 | return self.assets.total == (self.equity.total + self.liabilities.total) 407 | -------------------------------------------------------------------------------- /examples/chart.py: -------------------------------------------------------------------------------- 1 | from abacus.chart import Chart 2 | 3 | print( 4 | Chart( 5 | retained_earnings="re", 6 | current_earnings="profit", 7 | ).earnings 8 | ) 9 | -------------------------------------------------------------------------------- /examples/readme.py: -------------------------------------------------------------------------------- 1 | """This file is automatically generated from README.md""" 2 | from pprint import pprint 3 | 4 | from abacus import ( 5 | Asset, 6 | Book, 7 | Chart, 8 | Close, 9 | Double, 10 | Entry, 11 | Equity, 12 | Expense, 13 | Income, 14 | Ledger, 15 | ) 16 | 17 | create_accounts = [ 18 | Asset("cash"), 19 | Equity("equity"), 20 | Equity("re", title="Retained earnings"), 21 | Income("sales"), 22 | Expense("salaries"), 23 | ] 24 | business_events = [ 25 | # Post entries 26 | Double("cash", "equity", 1000), 27 | Double("cash", "sales", 250), 28 | Double("salaries", "cash", 150), 29 | # Close period 30 | Close(earnings_account="re"), 31 | ] 32 | events = create_accounts + business_events 33 | ledger = Ledger.from_list(events) 34 | 35 | print(ledger.balances) 36 | print(ledger.income_statement()) 37 | print(ledger.balance_sheet()) 38 | 39 | for p in ledger.history.primitives: 40 | print(p) 41 | 42 | 43 | # Create chart and ledger 44 | chart = Chart( 45 | assets=["cash", "ar"], 46 | equity=["equity"], 47 | liabilities=["tax_due"], 48 | income=["services"], 49 | expenses=["salaries"], 50 | contra_accounts={"services": ["refunds"]}, 51 | retained_earnings="retained_earnings", 52 | current_earnings="current_earnings", 53 | ) 54 | book = Book.from_chart(chart) 55 | 56 | # Post entries 57 | entries = [ 58 | Entry("Shareholder investment").double("cash", "equity", 20_000), 59 | Entry("Invoiced services") 60 | .debit("ar", 1200) 61 | .credit("services", 1000) 62 | .credit("tax_due", 200), 63 | Entry("Accepted payment").double("cash", "ar", 600), 64 | Entry("Made refund").double("refunds", "cash", 150), 65 | Entry("Paid salaries").double("salaries", "cash", 450), 66 | ] 67 | book.post_many(entries) 68 | print(book.balances) 69 | 70 | # Close the period and show reports 71 | book.close() 72 | print(book.income_statement) 73 | print(book.balance_sheet) 74 | 75 | # Save 76 | book.save("chart.json", "history.json", allow_overwrite=True) 77 | 78 | # Load and re-enter 79 | book2 = Book.load_unsafe("chart.json", "history.json") 80 | 81 | pprint(book2) # may not fully identical to `book` yet 82 | -------------------------------------------------------------------------------- /examples/roadmap.md: -------------------------------------------------------------------------------- 1 | # Roadmap 2 | 3 | ## Using upstream 4 | 5 | Implanting `abacus-minimal` as a dependency to: 6 | 7 | - [ ] [abacus-py][cli], 8 | - [ ] [abacus-streamlit][app]. 9 | 10 | ## New features 11 | 12 | - [ ] Business event layer `Event("Invoice", net_amount=200, vat=40)` 13 | - [ ] `Book.increase()` and `Book.decrease()` methods 14 | - [ ] `Entry.explain()` method 15 | 16 | ## Application ideas 17 | 18 | - [ ] real company - eg Walmart accounts 19 | - [ ] business simulation layer - stream of entries 20 | - [ ] more examples from textbooks 21 | - [ ] chart repository and conversions between charts of accounts as requested in [#4][ras]. 22 | - [ ] a quiz based on Book class - play entries and expect the use picks correct debit and credit 23 | 24 | [cli]: https://github.com/epogrebnyak/abacus 25 | [app]: https://abacus.streamlit.app/ 26 | [ras]: https://github.com/epogrebnyak/abacus/issues/4 27 | 28 | 29 | ## Extension ideas 30 | 31 | Replacing `History` class with a database connector is a way to use `abacus-minimal` 32 | for high-load applications that would not fit in memory. 33 | -------------------------------------------------------------------------------- /haskell/LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2024 Evgeny Pogrebnyak 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining 4 | a copy of this software and associated documentation files (the 5 | "Software"), to deal in the Software without restriction, including 6 | without limitation the rights to use, copy, modify, merge, publish, 7 | distribute, sublicense, and/or sell copies of the Software, and to 8 | permit persons to whom the Software is furnished to do so, subject to 9 | the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be included 12 | in all copies or substantial portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 15 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 16 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. 17 | IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY 18 | CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, 19 | TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE 20 | SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | -------------------------------------------------------------------------------- /justfile: -------------------------------------------------------------------------------- 1 | # Run pytest and mypy 2 | test: 3 | poetry run pytest . 4 | poetry run mypy abacus 5 | poetry run pyright abacus 6 | poetry run python examples/readme.py 7 | rm chart.json history.json 8 | 9 | # Run linters 10 | fix: 11 | isort . --float-to-top 12 | black . 13 | ruff format . 14 | ruff check . --fix 15 | 16 | # Extract and run the code from README.md 17 | readme: 18 | npx prettier README.md --write 19 | echo \"\"\"This file is automatically generated from README.md\"\"\" > examples/readme.py 20 | cat README.md | npx codedown python >> examples/readme.py 21 | poetry run python examples/readme.py 22 | rm chart.json history.json 23 | isort examples/readme.py --float-to-top 24 | black examples/readme.py 25 | 26 | # Run SQL examples 27 | sql: 28 | sqlite3 < posts/1.sql 29 | -------------------------------------------------------------------------------- /package-lock.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "abacus-minimal", 3 | "lockfileVersion": 3, 4 | "requires": true, 5 | "packages": { 6 | "": { 7 | "dependencies": { 8 | "codedown": "^3.2.1", 9 | "prettier": "^3.3.3" 10 | } 11 | }, 12 | "node_modules/arg": { 13 | "version": "5.0.2", 14 | "resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz", 15 | "integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==", 16 | "license": "MIT" 17 | }, 18 | "node_modules/balanced-match": { 19 | "version": "1.0.2", 20 | "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", 21 | "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", 22 | "license": "MIT" 23 | }, 24 | "node_modules/brace-expansion": { 25 | "version": "1.1.11", 26 | "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", 27 | "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", 28 | "license": "MIT", 29 | "dependencies": { 30 | "balanced-match": "^1.0.0", 31 | "concat-map": "0.0.1" 32 | } 33 | }, 34 | "node_modules/codedown": { 35 | "version": "3.2.1", 36 | "resolved": "https://registry.npmjs.org/codedown/-/codedown-3.2.1.tgz", 37 | "integrity": "sha512-xe0zeZ1/MEp3txD38YIdmC1mQW6x+AYkpBTFrm6WEEZSQ01owcyQXNNo3556/IBDjji1towZz38UwqufNJIUUg==", 38 | "dependencies": { 39 | "arg": "5.0.2", 40 | "marked": "4.3.0", 41 | "minimatch": "3.1.2", 42 | "readline": "1.3.0" 43 | }, 44 | "bin": { 45 | "codedown": "codedown.js" 46 | }, 47 | "engines": { 48 | "node": ">= 0.10.0" 49 | } 50 | }, 51 | "node_modules/concat-map": { 52 | "version": "0.0.1", 53 | "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", 54 | "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", 55 | "license": "MIT" 56 | }, 57 | "node_modules/marked": { 58 | "version": "4.3.0", 59 | "resolved": "https://registry.npmjs.org/marked/-/marked-4.3.0.tgz", 60 | "integrity": "sha512-PRsaiG84bK+AMvxziE/lCFss8juXjNaWzVbN5tXAm4XjeaS9NAHhop+PjQxz2A9h8Q4M/xGmzP8vqNwy6JeK0A==", 61 | "license": "MIT", 62 | "bin": { 63 | "marked": "bin/marked.js" 64 | }, 65 | "engines": { 66 | "node": ">= 12" 67 | } 68 | }, 69 | "node_modules/minimatch": { 70 | "version": "3.1.2", 71 | "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", 72 | "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", 73 | "license": "ISC", 74 | "dependencies": { 75 | "brace-expansion": "^1.1.7" 76 | }, 77 | "engines": { 78 | "node": "*" 79 | } 80 | }, 81 | "node_modules/prettier": { 82 | "version": "3.3.3", 83 | "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.3.3.tgz", 84 | "integrity": "sha512-i2tDNA0O5IrMO757lfrdQZCc2jPNDVntV0m/+4whiDfWaTKfMNgR7Qz0NAeGz/nRqF4m5/6CLzbP4/liHt12Ew==", 85 | "license": "MIT", 86 | "bin": { 87 | "prettier": "bin/prettier.cjs" 88 | }, 89 | "engines": { 90 | "node": ">=14" 91 | }, 92 | "funding": { 93 | "url": "https://github.com/prettier/prettier?sponsor=1" 94 | } 95 | }, 96 | "node_modules/readline": { 97 | "version": "1.3.0", 98 | "resolved": "https://registry.npmjs.org/readline/-/readline-1.3.0.tgz", 99 | "integrity": "sha512-k2d6ACCkiNYz222Fs/iNze30rRJ1iIicW7JuX/7/cozvih6YCkFZH+J6mAFDVgv0dRBaAyr4jDqC95R2y4IADg==", 100 | "license": "BSD" 101 | } 102 | } 103 | } 104 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "dependencies": { 3 | "codedown": "^3.2.1", 4 | "prettier": "^3.3.3" 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /poetry.lock: -------------------------------------------------------------------------------- 1 | # This file is automatically @generated by Poetry 1.8.3 and should not be changed by hand. 2 | 3 | [[package]] 4 | name = "annotated-types" 5 | version = "0.7.0" 6 | description = "Reusable constraint types to use with typing.Annotated" 7 | optional = false 8 | python-versions = ">=3.8" 9 | files = [ 10 | {file = "annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53"}, 11 | {file = "annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89"}, 12 | ] 13 | 14 | [[package]] 15 | name = "colorama" 16 | version = "0.4.6" 17 | description = "Cross-platform colored terminal text." 18 | optional = false 19 | python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" 20 | files = [ 21 | {file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"}, 22 | {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, 23 | ] 24 | 25 | [[package]] 26 | name = "exceptiongroup" 27 | version = "1.2.2" 28 | description = "Backport of PEP 654 (exception groups)" 29 | optional = false 30 | python-versions = ">=3.7" 31 | files = [ 32 | {file = "exceptiongroup-1.2.2-py3-none-any.whl", hash = "sha256:3111b9d131c238bec2f8f516e123e14ba243563fb135d3fe885990585aa7795b"}, 33 | {file = "exceptiongroup-1.2.2.tar.gz", hash = "sha256:47c2edf7c6738fafb49fd34290706d1a1a2f4d1c6df275526b62cbb4aa5393cc"}, 34 | ] 35 | 36 | [package.extras] 37 | test = ["pytest (>=6)"] 38 | 39 | [[package]] 40 | name = "iniconfig" 41 | version = "2.0.0" 42 | description = "brain-dead simple config-ini parsing" 43 | optional = false 44 | python-versions = ">=3.7" 45 | files = [ 46 | {file = "iniconfig-2.0.0-py3-none-any.whl", hash = "sha256:b6a85871a79d2e3b22d2d1b94ac2824226a63c6b741c88f7ae975f18b6778374"}, 47 | {file = "iniconfig-2.0.0.tar.gz", hash = "sha256:2d91e135bf72d31a410b17c16da610a82cb55f6b0477d1a902134b24a455b8b3"}, 48 | ] 49 | 50 | [[package]] 51 | name = "isort" 52 | version = "5.13.2" 53 | description = "A Python utility / library to sort Python imports." 54 | optional = false 55 | python-versions = ">=3.8.0" 56 | files = [ 57 | {file = "isort-5.13.2-py3-none-any.whl", hash = "sha256:8ca5e72a8d85860d5a3fa69b8745237f2939afe12dbf656afbcb47fe72d947a6"}, 58 | {file = "isort-5.13.2.tar.gz", hash = "sha256:48fdfcb9face5d58a4f6dde2e72a1fb8dcaf8ab26f95ab49fab84c2ddefb0109"}, 59 | ] 60 | 61 | [package.extras] 62 | colors = ["colorama (>=0.4.6)"] 63 | 64 | [[package]] 65 | name = "mypy" 66 | version = "1.13.0" 67 | description = "Optional static typing for Python" 68 | optional = false 69 | python-versions = ">=3.8" 70 | files = [ 71 | {file = "mypy-1.13.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:6607e0f1dd1fb7f0aca14d936d13fd19eba5e17e1cd2a14f808fa5f8f6d8f60a"}, 72 | {file = "mypy-1.13.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:8a21be69bd26fa81b1f80a61ee7ab05b076c674d9b18fb56239d72e21d9f4c80"}, 73 | {file = "mypy-1.13.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7b2353a44d2179846a096e25691d54d59904559f4232519d420d64da6828a3a7"}, 74 | {file = "mypy-1.13.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:0730d1c6a2739d4511dc4253f8274cdd140c55c32dfb0a4cf8b7a43f40abfa6f"}, 75 | {file = "mypy-1.13.0-cp310-cp310-win_amd64.whl", hash = "sha256:c5fc54dbb712ff5e5a0fca797e6e0aa25726c7e72c6a5850cfd2adbc1eb0a372"}, 76 | {file = "mypy-1.13.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:581665e6f3a8a9078f28d5502f4c334c0c8d802ef55ea0e7276a6e409bc0d82d"}, 77 | {file = "mypy-1.13.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:3ddb5b9bf82e05cc9a627e84707b528e5c7caaa1c55c69e175abb15a761cec2d"}, 78 | {file = "mypy-1.13.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:20c7ee0bc0d5a9595c46f38beb04201f2620065a93755704e141fcac9f59db2b"}, 79 | {file = "mypy-1.13.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:3790ded76f0b34bc9c8ba4def8f919dd6a46db0f5a6610fb994fe8efdd447f73"}, 80 | {file = "mypy-1.13.0-cp311-cp311-win_amd64.whl", hash = "sha256:51f869f4b6b538229c1d1bcc1dd7d119817206e2bc54e8e374b3dfa202defcca"}, 81 | {file = "mypy-1.13.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:5c7051a3461ae84dfb5dd15eff5094640c61c5f22257c8b766794e6dd85e72d5"}, 82 | {file = "mypy-1.13.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:39bb21c69a5d6342f4ce526e4584bc5c197fd20a60d14a8624d8743fffb9472e"}, 83 | {file = "mypy-1.13.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:164f28cb9d6367439031f4c81e84d3ccaa1e19232d9d05d37cb0bd880d3f93c2"}, 84 | {file = "mypy-1.13.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:a4c1bfcdbce96ff5d96fc9b08e3831acb30dc44ab02671eca5953eadad07d6d0"}, 85 | {file = "mypy-1.13.0-cp312-cp312-win_amd64.whl", hash = "sha256:a0affb3a79a256b4183ba09811e3577c5163ed06685e4d4b46429a271ba174d2"}, 86 | {file = "mypy-1.13.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:a7b44178c9760ce1a43f544e595d35ed61ac2c3de306599fa59b38a6048e1aa7"}, 87 | {file = "mypy-1.13.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:5d5092efb8516d08440e36626f0153b5006d4088c1d663d88bf79625af3d1d62"}, 88 | {file = "mypy-1.13.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:de2904956dac40ced10931ac967ae63c5089bd498542194b436eb097a9f77bc8"}, 89 | {file = "mypy-1.13.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:7bfd8836970d33c2105562650656b6846149374dc8ed77d98424b40b09340ba7"}, 90 | {file = "mypy-1.13.0-cp313-cp313-win_amd64.whl", hash = "sha256:9f73dba9ec77acb86457a8fc04b5239822df0c14a082564737833d2963677dbc"}, 91 | {file = "mypy-1.13.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:100fac22ce82925f676a734af0db922ecfea991e1d7ec0ceb1e115ebe501301a"}, 92 | {file = "mypy-1.13.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:7bcb0bb7f42a978bb323a7c88f1081d1b5dee77ca86f4100735a6f541299d8fb"}, 93 | {file = "mypy-1.13.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bde31fc887c213e223bbfc34328070996061b0833b0a4cfec53745ed61f3519b"}, 94 | {file = "mypy-1.13.0-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:07de989f89786f62b937851295ed62e51774722e5444a27cecca993fc3f9cd74"}, 95 | {file = "mypy-1.13.0-cp38-cp38-win_amd64.whl", hash = "sha256:4bde84334fbe19bad704b3f5b78c4abd35ff1026f8ba72b29de70dda0916beb6"}, 96 | {file = "mypy-1.13.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:0246bcb1b5de7f08f2826451abd947bf656945209b140d16ed317f65a17dc7dc"}, 97 | {file = "mypy-1.13.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:7f5b7deae912cf8b77e990b9280f170381fdfbddf61b4ef80927edd813163732"}, 98 | {file = "mypy-1.13.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7029881ec6ffb8bc233a4fa364736789582c738217b133f1b55967115288a2bc"}, 99 | {file = "mypy-1.13.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:3e38b980e5681f28f033f3be86b099a247b13c491f14bb8b1e1e134d23bb599d"}, 100 | {file = "mypy-1.13.0-cp39-cp39-win_amd64.whl", hash = "sha256:a6789be98a2017c912ae6ccb77ea553bbaf13d27605d2ca20a76dfbced631b24"}, 101 | {file = "mypy-1.13.0-py3-none-any.whl", hash = "sha256:9c250883f9fd81d212e0952c92dbfcc96fc237f4b7c92f56ac81fd48460b3e5a"}, 102 | {file = "mypy-1.13.0.tar.gz", hash = "sha256:0291a61b6fbf3e6673e3405cfcc0e7650bebc7939659fdca2702958038bd835e"}, 103 | ] 104 | 105 | [package.dependencies] 106 | mypy-extensions = ">=1.0.0" 107 | tomli = {version = ">=1.1.0", markers = "python_version < \"3.11\""} 108 | typing-extensions = ">=4.6.0" 109 | 110 | [package.extras] 111 | dmypy = ["psutil (>=4.0)"] 112 | faster-cache = ["orjson"] 113 | install-types = ["pip"] 114 | mypyc = ["setuptools (>=50)"] 115 | reports = ["lxml"] 116 | 117 | [[package]] 118 | name = "mypy-extensions" 119 | version = "1.0.0" 120 | description = "Type system extensions for programs checked with the mypy type checker." 121 | optional = false 122 | python-versions = ">=3.5" 123 | files = [ 124 | {file = "mypy_extensions-1.0.0-py3-none-any.whl", hash = "sha256:4392f6c0eb8a5668a69e23d168ffa70f0be9ccfd32b5cc2d26a34ae5b844552d"}, 125 | {file = "mypy_extensions-1.0.0.tar.gz", hash = "sha256:75dbf8955dc00442a438fc4d0666508a9a97b6bd41aa2f0ffe9d2f2725af0782"}, 126 | ] 127 | 128 | [[package]] 129 | name = "nodeenv" 130 | version = "1.9.1" 131 | description = "Node.js virtual environment builder" 132 | optional = false 133 | python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" 134 | files = [ 135 | {file = "nodeenv-1.9.1-py2.py3-none-any.whl", hash = "sha256:ba11c9782d29c27c70ffbdda2d7415098754709be8a7056d79a737cd901155c9"}, 136 | {file = "nodeenv-1.9.1.tar.gz", hash = "sha256:6ec12890a2dab7946721edbfbcd91f3319c6ccc9aec47be7c7e6b7011ee6645f"}, 137 | ] 138 | 139 | [[package]] 140 | name = "packaging" 141 | version = "24.2" 142 | description = "Core utilities for Python packages" 143 | optional = false 144 | python-versions = ">=3.8" 145 | files = [ 146 | {file = "packaging-24.2-py3-none-any.whl", hash = "sha256:09abb1bccd265c01f4a3aa3f7a7db064b36514d2cba19a2f694fe6150451a759"}, 147 | {file = "packaging-24.2.tar.gz", hash = "sha256:c228a6dc5e932d346bc5739379109d49e8853dd8223571c7c5b55260edc0b97f"}, 148 | ] 149 | 150 | [[package]] 151 | name = "pluggy" 152 | version = "1.5.0" 153 | description = "plugin and hook calling mechanisms for python" 154 | optional = false 155 | python-versions = ">=3.8" 156 | files = [ 157 | {file = "pluggy-1.5.0-py3-none-any.whl", hash = "sha256:44e1ad92c8ca002de6377e165f3e0f1be63266ab4d554740532335b9d75ea669"}, 158 | {file = "pluggy-1.5.0.tar.gz", hash = "sha256:2cffa88e94fdc978c4c574f15f9e59b7f4201d439195c3715ca9e2486f1d0cf1"}, 159 | ] 160 | 161 | [package.extras] 162 | dev = ["pre-commit", "tox"] 163 | testing = ["pytest", "pytest-benchmark"] 164 | 165 | [[package]] 166 | name = "pydantic" 167 | version = "2.10.1" 168 | description = "Data validation using Python type hints" 169 | optional = false 170 | python-versions = ">=3.8" 171 | files = [ 172 | {file = "pydantic-2.10.1-py3-none-any.whl", hash = "sha256:a8d20db84de64cf4a7d59e899c2caf0fe9d660c7cfc482528e7020d7dd189a7e"}, 173 | {file = "pydantic-2.10.1.tar.gz", hash = "sha256:a4daca2dc0aa429555e0656d6bf94873a7dc5f54ee42b1f5873d666fb3f35560"}, 174 | ] 175 | 176 | [package.dependencies] 177 | annotated-types = ">=0.6.0" 178 | pydantic-core = "2.27.1" 179 | typing-extensions = ">=4.12.2" 180 | 181 | [package.extras] 182 | email = ["email-validator (>=2.0.0)"] 183 | timezone = ["tzdata"] 184 | 185 | [[package]] 186 | name = "pydantic-core" 187 | version = "2.27.1" 188 | description = "Core functionality for Pydantic validation and serialization" 189 | optional = false 190 | python-versions = ">=3.8" 191 | files = [ 192 | {file = "pydantic_core-2.27.1-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:71a5e35c75c021aaf400ac048dacc855f000bdfed91614b4a726f7432f1f3d6a"}, 193 | {file = "pydantic_core-2.27.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:f82d068a2d6ecfc6e054726080af69a6764a10015467d7d7b9f66d6ed5afa23b"}, 194 | {file = "pydantic_core-2.27.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:121ceb0e822f79163dd4699e4c54f5ad38b157084d97b34de8b232bcaad70278"}, 195 | {file = "pydantic_core-2.27.1-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:4603137322c18eaf2e06a4495f426aa8d8388940f3c457e7548145011bb68e05"}, 196 | {file = "pydantic_core-2.27.1-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a33cd6ad9017bbeaa9ed78a2e0752c5e250eafb9534f308e7a5f7849b0b1bfb4"}, 197 | {file = "pydantic_core-2.27.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:15cc53a3179ba0fcefe1e3ae50beb2784dede4003ad2dfd24f81bba4b23a454f"}, 198 | {file = "pydantic_core-2.27.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:45d9c5eb9273aa50999ad6adc6be5e0ecea7e09dbd0d31bd0c65a55a2592ca08"}, 199 | {file = "pydantic_core-2.27.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:8bf7b66ce12a2ac52d16f776b31d16d91033150266eb796967a7e4621707e4f6"}, 200 | {file = "pydantic_core-2.27.1-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:655d7dd86f26cb15ce8a431036f66ce0318648f8853d709b4167786ec2fa4807"}, 201 | {file = "pydantic_core-2.27.1-cp310-cp310-musllinux_1_1_armv7l.whl", hash = "sha256:5556470f1a2157031e676f776c2bc20acd34c1990ca5f7e56f1ebf938b9ab57c"}, 202 | {file = "pydantic_core-2.27.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:f69ed81ab24d5a3bd93861c8c4436f54afdf8e8cc421562b0c7504cf3be58206"}, 203 | {file = "pydantic_core-2.27.1-cp310-none-win32.whl", hash = "sha256:f5a823165e6d04ccea61a9f0576f345f8ce40ed533013580e087bd4d7442b52c"}, 204 | {file = "pydantic_core-2.27.1-cp310-none-win_amd64.whl", hash = "sha256:57866a76e0b3823e0b56692d1a0bf722bffb324839bb5b7226a7dbd6c9a40b17"}, 205 | {file = "pydantic_core-2.27.1-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:ac3b20653bdbe160febbea8aa6c079d3df19310d50ac314911ed8cc4eb7f8cb8"}, 206 | {file = "pydantic_core-2.27.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a5a8e19d7c707c4cadb8c18f5f60c843052ae83c20fa7d44f41594c644a1d330"}, 207 | {file = "pydantic_core-2.27.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7f7059ca8d64fea7f238994c97d91f75965216bcbe5f695bb44f354893f11d52"}, 208 | {file = "pydantic_core-2.27.1-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:bed0f8a0eeea9fb72937ba118f9db0cb7e90773462af7962d382445f3005e5a4"}, 209 | {file = "pydantic_core-2.27.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a3cb37038123447cf0f3ea4c74751f6a9d7afef0eb71aa07bf5f652b5e6a132c"}, 210 | {file = "pydantic_core-2.27.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:84286494f6c5d05243456e04223d5a9417d7f443c3b76065e75001beb26f88de"}, 211 | {file = "pydantic_core-2.27.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:acc07b2cfc5b835444b44a9956846b578d27beeacd4b52e45489e93276241025"}, 212 | {file = "pydantic_core-2.27.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4fefee876e07a6e9aad7a8c8c9f85b0cdbe7df52b8a9552307b09050f7512c7e"}, 213 | {file = "pydantic_core-2.27.1-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:258c57abf1188926c774a4c94dd29237e77eda19462e5bb901d88adcab6af919"}, 214 | {file = "pydantic_core-2.27.1-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:35c14ac45fcfdf7167ca76cc80b2001205a8d5d16d80524e13508371fb8cdd9c"}, 215 | {file = "pydantic_core-2.27.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:d1b26e1dff225c31897696cab7d4f0a315d4c0d9e8666dbffdb28216f3b17fdc"}, 216 | {file = "pydantic_core-2.27.1-cp311-none-win32.whl", hash = "sha256:2cdf7d86886bc6982354862204ae3b2f7f96f21a3eb0ba5ca0ac42c7b38598b9"}, 217 | {file = "pydantic_core-2.27.1-cp311-none-win_amd64.whl", hash = "sha256:3af385b0cee8df3746c3f406f38bcbfdc9041b5c2d5ce3e5fc6637256e60bbc5"}, 218 | {file = "pydantic_core-2.27.1-cp311-none-win_arm64.whl", hash = "sha256:81f2ec23ddc1b476ff96563f2e8d723830b06dceae348ce02914a37cb4e74b89"}, 219 | {file = "pydantic_core-2.27.1-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:9cbd94fc661d2bab2bc702cddd2d3370bbdcc4cd0f8f57488a81bcce90c7a54f"}, 220 | {file = "pydantic_core-2.27.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:5f8c4718cd44ec1580e180cb739713ecda2bdee1341084c1467802a417fe0f02"}, 221 | {file = "pydantic_core-2.27.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:15aae984e46de8d376df515f00450d1522077254ef6b7ce189b38ecee7c9677c"}, 222 | {file = "pydantic_core-2.27.1-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:1ba5e3963344ff25fc8c40da90f44b0afca8cfd89d12964feb79ac1411a260ac"}, 223 | {file = "pydantic_core-2.27.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:992cea5f4f3b29d6b4f7f1726ed8ee46c8331c6b4eed6db5b40134c6fe1768bb"}, 224 | {file = "pydantic_core-2.27.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0325336f348dbee6550d129b1627cb8f5351a9dc91aad141ffb96d4937bd9529"}, 225 | {file = "pydantic_core-2.27.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7597c07fbd11515f654d6ece3d0e4e5093edc30a436c63142d9a4b8e22f19c35"}, 226 | {file = "pydantic_core-2.27.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:3bbd5d8cc692616d5ef6fbbbd50dbec142c7e6ad9beb66b78a96e9c16729b089"}, 227 | {file = "pydantic_core-2.27.1-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:dc61505e73298a84a2f317255fcc72b710b72980f3a1f670447a21efc88f8381"}, 228 | {file = "pydantic_core-2.27.1-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:e1f735dc43da318cad19b4173dd1ffce1d84aafd6c9b782b3abc04a0d5a6f5bb"}, 229 | {file = "pydantic_core-2.27.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:f4e5658dbffe8843a0f12366a4c2d1c316dbe09bb4dfbdc9d2d9cd6031de8aae"}, 230 | {file = "pydantic_core-2.27.1-cp312-none-win32.whl", hash = "sha256:672ebbe820bb37988c4d136eca2652ee114992d5d41c7e4858cdd90ea94ffe5c"}, 231 | {file = "pydantic_core-2.27.1-cp312-none-win_amd64.whl", hash = "sha256:66ff044fd0bb1768688aecbe28b6190f6e799349221fb0de0e6f4048eca14c16"}, 232 | {file = "pydantic_core-2.27.1-cp312-none-win_arm64.whl", hash = "sha256:9a3b0793b1bbfd4146304e23d90045f2a9b5fd5823aa682665fbdaf2a6c28f3e"}, 233 | {file = "pydantic_core-2.27.1-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:f216dbce0e60e4d03e0c4353c7023b202d95cbaeff12e5fd2e82ea0a66905073"}, 234 | {file = "pydantic_core-2.27.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a2e02889071850bbfd36b56fd6bc98945e23670773bc7a76657e90e6b6603c08"}, 235 | {file = "pydantic_core-2.27.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:42b0e23f119b2b456d07ca91b307ae167cc3f6c846a7b169fca5326e32fdc6cf"}, 236 | {file = "pydantic_core-2.27.1-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:764be71193f87d460a03f1f7385a82e226639732214b402f9aa61f0d025f0737"}, 237 | {file = "pydantic_core-2.27.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1c00666a3bd2f84920a4e94434f5974d7bbc57e461318d6bb34ce9cdbbc1f6b2"}, 238 | {file = "pydantic_core-2.27.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3ccaa88b24eebc0f849ce0a4d09e8a408ec5a94afff395eb69baf868f5183107"}, 239 | {file = "pydantic_core-2.27.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c65af9088ac534313e1963443d0ec360bb2b9cba6c2909478d22c2e363d98a51"}, 240 | {file = "pydantic_core-2.27.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:206b5cf6f0c513baffaeae7bd817717140770c74528f3e4c3e1cec7871ddd61a"}, 241 | {file = "pydantic_core-2.27.1-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:062f60e512fc7fff8b8a9d680ff0ddaaef0193dba9fa83e679c0c5f5fbd018bc"}, 242 | {file = "pydantic_core-2.27.1-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:a0697803ed7d4af5e4c1adf1670af078f8fcab7a86350e969f454daf598c4960"}, 243 | {file = "pydantic_core-2.27.1-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:58ca98a950171f3151c603aeea9303ef6c235f692fe555e883591103da709b23"}, 244 | {file = "pydantic_core-2.27.1-cp313-none-win32.whl", hash = "sha256:8065914ff79f7eab1599bd80406681f0ad08f8e47c880f17b416c9f8f7a26d05"}, 245 | {file = "pydantic_core-2.27.1-cp313-none-win_amd64.whl", hash = "sha256:ba630d5e3db74c79300d9a5bdaaf6200172b107f263c98a0539eeecb857b2337"}, 246 | {file = "pydantic_core-2.27.1-cp313-none-win_arm64.whl", hash = "sha256:45cf8588c066860b623cd11c4ba687f8d7175d5f7ef65f7129df8a394c502de5"}, 247 | {file = "pydantic_core-2.27.1-cp38-cp38-macosx_10_12_x86_64.whl", hash = "sha256:5897bec80a09b4084aee23f9b73a9477a46c3304ad1d2d07acca19723fb1de62"}, 248 | {file = "pydantic_core-2.27.1-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:d0165ab2914379bd56908c02294ed8405c252250668ebcb438a55494c69f44ab"}, 249 | {file = "pydantic_core-2.27.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6b9af86e1d8e4cfc82c2022bfaa6f459381a50b94a29e95dcdda8442d6d83864"}, 250 | {file = "pydantic_core-2.27.1-cp38-cp38-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5f6c8a66741c5f5447e047ab0ba7a1c61d1e95580d64bce852e3df1f895c4067"}, 251 | {file = "pydantic_core-2.27.1-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9a42d6a8156ff78981f8aa56eb6394114e0dedb217cf8b729f438f643608cbcd"}, 252 | {file = "pydantic_core-2.27.1-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:64c65f40b4cd8b0e049a8edde07e38b476da7e3aaebe63287c899d2cff253fa5"}, 253 | {file = "pydantic_core-2.27.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9fdcf339322a3fae5cbd504edcefddd5a50d9ee00d968696846f089b4432cf78"}, 254 | {file = "pydantic_core-2.27.1-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:bf99c8404f008750c846cb4ac4667b798a9f7de673ff719d705d9b2d6de49c5f"}, 255 | {file = "pydantic_core-2.27.1-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:8f1edcea27918d748c7e5e4d917297b2a0ab80cad10f86631e488b7cddf76a36"}, 256 | {file = "pydantic_core-2.27.1-cp38-cp38-musllinux_1_1_armv7l.whl", hash = "sha256:159cac0a3d096f79ab6a44d77a961917219707e2a130739c64d4dd46281f5c2a"}, 257 | {file = "pydantic_core-2.27.1-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:029d9757eb621cc6e1848fa0b0310310de7301057f623985698ed7ebb014391b"}, 258 | {file = "pydantic_core-2.27.1-cp38-none-win32.whl", hash = "sha256:a28af0695a45f7060e6f9b7092558a928a28553366519f64083c63a44f70e618"}, 259 | {file = "pydantic_core-2.27.1-cp38-none-win_amd64.whl", hash = "sha256:2d4567c850905d5eaaed2f7a404e61012a51caf288292e016360aa2b96ff38d4"}, 260 | {file = "pydantic_core-2.27.1-cp39-cp39-macosx_10_12_x86_64.whl", hash = "sha256:e9386266798d64eeb19dd3677051f5705bf873e98e15897ddb7d76f477131967"}, 261 | {file = "pydantic_core-2.27.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:4228b5b646caa73f119b1ae756216b59cc6e2267201c27d3912b592c5e323b60"}, 262 | {file = "pydantic_core-2.27.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0b3dfe500de26c52abe0477dde16192ac39c98f05bf2d80e76102d394bd13854"}, 263 | {file = "pydantic_core-2.27.1-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:aee66be87825cdf72ac64cb03ad4c15ffef4143dbf5c113f64a5ff4f81477bf9"}, 264 | {file = "pydantic_core-2.27.1-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3b748c44bb9f53031c8cbc99a8a061bc181c1000c60a30f55393b6e9c45cc5bd"}, 265 | {file = "pydantic_core-2.27.1-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5ca038c7f6a0afd0b2448941b6ef9d5e1949e999f9e5517692eb6da58e9d44be"}, 266 | {file = "pydantic_core-2.27.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6e0bd57539da59a3e4671b90a502da9a28c72322a4f17866ba3ac63a82c4498e"}, 267 | {file = "pydantic_core-2.27.1-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:ac6c2c45c847bbf8f91930d88716a0fb924b51e0c6dad329b793d670ec5db792"}, 268 | {file = "pydantic_core-2.27.1-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:b94d4ba43739bbe8b0ce4262bcc3b7b9f31459ad120fb595627eaeb7f9b9ca01"}, 269 | {file = "pydantic_core-2.27.1-cp39-cp39-musllinux_1_1_armv7l.whl", hash = "sha256:00e6424f4b26fe82d44577b4c842d7df97c20be6439e8e685d0d715feceb9fb9"}, 270 | {file = "pydantic_core-2.27.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:38de0a70160dd97540335b7ad3a74571b24f1dc3ed33f815f0880682e6880131"}, 271 | {file = "pydantic_core-2.27.1-cp39-none-win32.whl", hash = "sha256:7ccebf51efc61634f6c2344da73e366c75e735960b5654b63d7e6f69a5885fa3"}, 272 | {file = "pydantic_core-2.27.1-cp39-none-win_amd64.whl", hash = "sha256:a57847b090d7892f123726202b7daa20df6694cbd583b67a592e856bff603d6c"}, 273 | {file = "pydantic_core-2.27.1-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:3fa80ac2bd5856580e242dbc202db873c60a01b20309c8319b5c5986fbe53ce6"}, 274 | {file = "pydantic_core-2.27.1-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:d950caa237bb1954f1b8c9227b5065ba6875ac9771bb8ec790d956a699b78676"}, 275 | {file = "pydantic_core-2.27.1-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0e4216e64d203e39c62df627aa882f02a2438d18a5f21d7f721621f7a5d3611d"}, 276 | {file = "pydantic_core-2.27.1-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:02a3d637bd387c41d46b002f0e49c52642281edacd2740e5a42f7017feea3f2c"}, 277 | {file = "pydantic_core-2.27.1-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:161c27ccce13b6b0c8689418da3885d3220ed2eae2ea5e9b2f7f3d48f1d52c27"}, 278 | {file = "pydantic_core-2.27.1-pp310-pypy310_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:19910754e4cc9c63bc1c7f6d73aa1cfee82f42007e407c0f413695c2f7ed777f"}, 279 | {file = "pydantic_core-2.27.1-pp310-pypy310_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:e173486019cc283dc9778315fa29a363579372fe67045e971e89b6365cc035ed"}, 280 | {file = "pydantic_core-2.27.1-pp310-pypy310_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:af52d26579b308921b73b956153066481f064875140ccd1dfd4e77db89dbb12f"}, 281 | {file = "pydantic_core-2.27.1-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:981fb88516bd1ae8b0cbbd2034678a39dedc98752f264ac9bc5839d3923fa04c"}, 282 | {file = "pydantic_core-2.27.1-pp39-pypy39_pp73-macosx_10_12_x86_64.whl", hash = "sha256:5fde892e6c697ce3e30c61b239330fc5d569a71fefd4eb6512fc6caec9dd9e2f"}, 283 | {file = "pydantic_core-2.27.1-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:816f5aa087094099fff7edabb5e01cc370eb21aa1a1d44fe2d2aefdfb5599b31"}, 284 | {file = "pydantic_core-2.27.1-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9c10c309e18e443ddb108f0ef64e8729363adbfd92d6d57beec680f6261556f3"}, 285 | {file = "pydantic_core-2.27.1-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:98476c98b02c8e9b2eec76ac4156fd006628b1b2d0ef27e548ffa978393fd154"}, 286 | {file = "pydantic_core-2.27.1-pp39-pypy39_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c3027001c28434e7ca5a6e1e527487051136aa81803ac812be51802150d880dd"}, 287 | {file = "pydantic_core-2.27.1-pp39-pypy39_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:7699b1df36a48169cdebda7ab5a2bac265204003f153b4bd17276153d997670a"}, 288 | {file = "pydantic_core-2.27.1-pp39-pypy39_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:1c39b07d90be6b48968ddc8c19e7585052088fd7ec8d568bb31ff64c70ae3c97"}, 289 | {file = "pydantic_core-2.27.1-pp39-pypy39_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:46ccfe3032b3915586e469d4972973f893c0a2bb65669194a5bdea9bacc088c2"}, 290 | {file = "pydantic_core-2.27.1-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:62ba45e21cf6571d7f716d903b5b7b6d2617e2d5d67c0923dc47b9d41369f840"}, 291 | {file = "pydantic_core-2.27.1.tar.gz", hash = "sha256:62a763352879b84aa31058fc931884055fd75089cccbd9d58bb6afd01141b235"}, 292 | ] 293 | 294 | [package.dependencies] 295 | typing-extensions = ">=4.6.0,<4.7.0 || >4.7.0" 296 | 297 | [[package]] 298 | name = "pyright" 299 | version = "1.1.389" 300 | description = "Command line wrapper for pyright" 301 | optional = false 302 | python-versions = ">=3.7" 303 | files = [ 304 | {file = "pyright-1.1.389-py3-none-any.whl", hash = "sha256:41e9620bba9254406dc1f621a88ceab5a88af4c826feb4f614d95691ed243a60"}, 305 | {file = "pyright-1.1.389.tar.gz", hash = "sha256:716bf8cc174ab8b4dcf6828c3298cac05c5ed775dda9910106a5dcfe4c7fe220"}, 306 | ] 307 | 308 | [package.dependencies] 309 | nodeenv = ">=1.6.0" 310 | typing-extensions = ">=4.1" 311 | 312 | [package.extras] 313 | all = ["nodejs-wheel-binaries", "twine (>=3.4.1)"] 314 | dev = ["twine (>=3.4.1)"] 315 | nodejs = ["nodejs-wheel-binaries"] 316 | 317 | [[package]] 318 | name = "pytest" 319 | version = "8.3.3" 320 | description = "pytest: simple powerful testing with Python" 321 | optional = false 322 | python-versions = ">=3.8" 323 | files = [ 324 | {file = "pytest-8.3.3-py3-none-any.whl", hash = "sha256:a6853c7375b2663155079443d2e45de913a911a11d669df02a50814944db57b2"}, 325 | {file = "pytest-8.3.3.tar.gz", hash = "sha256:70b98107bd648308a7952b06e6ca9a50bc660be218d53c257cc1fc94fda10181"}, 326 | ] 327 | 328 | [package.dependencies] 329 | colorama = {version = "*", markers = "sys_platform == \"win32\""} 330 | exceptiongroup = {version = ">=1.0.0rc8", markers = "python_version < \"3.11\""} 331 | iniconfig = "*" 332 | packaging = "*" 333 | pluggy = ">=1.5,<2" 334 | tomli = {version = ">=1", markers = "python_version < \"3.11\""} 335 | 336 | [package.extras] 337 | dev = ["argcomplete", "attrs (>=19.2)", "hypothesis (>=3.56)", "mock", "pygments (>=2.7.2)", "requests", "setuptools", "xmlschema"] 338 | 339 | [[package]] 340 | name = "ruff" 341 | version = "0.7.4" 342 | description = "An extremely fast Python linter and code formatter, written in Rust." 343 | optional = false 344 | python-versions = ">=3.7" 345 | files = [ 346 | {file = "ruff-0.7.4-py3-none-linux_armv6l.whl", hash = "sha256:a4919925e7684a3f18e18243cd6bea7cfb8e968a6eaa8437971f681b7ec51478"}, 347 | {file = "ruff-0.7.4-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:cfb365c135b830778dda8c04fb7d4280ed0b984e1aec27f574445231e20d6c63"}, 348 | {file = "ruff-0.7.4-py3-none-macosx_11_0_arm64.whl", hash = "sha256:63a569b36bc66fbadec5beaa539dd81e0527cb258b94e29e0531ce41bacc1f20"}, 349 | {file = "ruff-0.7.4-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0d06218747d361d06fd2fdac734e7fa92df36df93035db3dc2ad7aa9852cb109"}, 350 | {file = "ruff-0.7.4-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e0cea28d0944f74ebc33e9f934238f15c758841f9f5edd180b5315c203293452"}, 351 | {file = "ruff-0.7.4-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:80094ecd4793c68b2571b128f91754d60f692d64bc0d7272ec9197fdd09bf9ea"}, 352 | {file = "ruff-0.7.4-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:997512325c6620d1c4c2b15db49ef59543ef9cd0f4aa8065ec2ae5103cedc7e7"}, 353 | {file = "ruff-0.7.4-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:00b4cf3a6b5fad6d1a66e7574d78956bbd09abfd6c8a997798f01f5da3d46a05"}, 354 | {file = "ruff-0.7.4-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7dbdc7d8274e1422722933d1edddfdc65b4336abf0b16dfcb9dedd6e6a517d06"}, 355 | {file = "ruff-0.7.4-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0e92dfb5f00eaedb1501b2f906ccabfd67b2355bdf117fea9719fc99ac2145bc"}, 356 | {file = "ruff-0.7.4-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:3bd726099f277d735dc38900b6a8d6cf070f80828877941983a57bca1cd92172"}, 357 | {file = "ruff-0.7.4-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:2e32829c429dd081ee5ba39aef436603e5b22335c3d3fff013cd585806a6486a"}, 358 | {file = "ruff-0.7.4-py3-none-musllinux_1_2_i686.whl", hash = "sha256:662a63b4971807623f6f90c1fb664613f67cc182dc4d991471c23c541fee62dd"}, 359 | {file = "ruff-0.7.4-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:876f5e09eaae3eb76814c1d3b68879891d6fde4824c015d48e7a7da4cf066a3a"}, 360 | {file = "ruff-0.7.4-py3-none-win32.whl", hash = "sha256:75c53f54904be42dd52a548728a5b572344b50d9b2873d13a3f8c5e3b91f5cac"}, 361 | {file = "ruff-0.7.4-py3-none-win_amd64.whl", hash = "sha256:745775c7b39f914238ed1f1b0bebed0b9155a17cd8bc0b08d3c87e4703b990d6"}, 362 | {file = "ruff-0.7.4-py3-none-win_arm64.whl", hash = "sha256:11bff065102c3ae9d3ea4dc9ecdfe5a5171349cdd0787c1fc64761212fc9cf1f"}, 363 | {file = "ruff-0.7.4.tar.gz", hash = "sha256:cd12e35031f5af6b9b93715d8c4f40360070b2041f81273d0527683d5708fce2"}, 364 | ] 365 | 366 | [[package]] 367 | name = "simplejson" 368 | version = "3.19.3" 369 | description = "Simple, fast, extensible JSON encoder/decoder for Python" 370 | optional = false 371 | python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,>=2.5" 372 | files = [ 373 | {file = "simplejson-3.19.3-cp27-cp27m-manylinux1_i686.whl", hash = "sha256:f39caec26007a2d0efab6b8b1d74873ede9351962707afab622cc2285dd26ed0"}, 374 | {file = "simplejson-3.19.3-cp27-cp27m-manylinux1_x86_64.whl", hash = "sha256:83c87706265ae3028e8460d08b05f30254c569772e859e5ba61fe8af2c883468"}, 375 | {file = "simplejson-3.19.3-cp27-cp27m-manylinux2010_i686.whl", hash = "sha256:0b5ddd2c7d1d3f4d23224bc8a04bbf1430ae9a8149c05b90f8fc610f7f857a23"}, 376 | {file = "simplejson-3.19.3-cp27-cp27m-manylinux2010_x86_64.whl", hash = "sha256:ad0e0b1ce9bd3edb5cf64b5b5b76eacbfdac8c5367153aeeec8a8b1407f68342"}, 377 | {file = "simplejson-3.19.3-cp27-cp27mu-manylinux1_i686.whl", hash = "sha256:93be280fc69a952c76e261036312c20b910e7fa9e234f1d89bdfe3fa34f8a023"}, 378 | {file = "simplejson-3.19.3-cp27-cp27mu-manylinux1_x86_64.whl", hash = "sha256:6d43e24b88c80f997081503f693be832fc90854f278df277dd54f8a4c847ab61"}, 379 | {file = "simplejson-3.19.3-cp27-cp27mu-manylinux2010_i686.whl", hash = "sha256:2876027ebdd599d730d36464debe84619b0368e9a642ca6e7c601be55aed439e"}, 380 | {file = "simplejson-3.19.3-cp27-cp27mu-manylinux2010_x86_64.whl", hash = "sha256:0766ca6222b410e08e0053a0dda3606cafb3973d5d00538307f631bb59743396"}, 381 | {file = "simplejson-3.19.3-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:50d8b742d74c449c4dcac570d08ce0f21f6a149d2d9cf7652dbf2ba9a1bc729a"}, 382 | {file = "simplejson-3.19.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:dd011fc3c1d88b779645495fdb8189fb318a26981eebcce14109460e062f209b"}, 383 | {file = "simplejson-3.19.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:637c4d4b81825c1f4d651e56210bd35b5604034b192b02d2d8f17f7ce8c18f42"}, 384 | {file = "simplejson-3.19.3-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2f56eb03bc9e432bb81adc8ecff2486d39feb371abb442964ffb44f6db23b332"}, 385 | {file = "simplejson-3.19.3-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ef59a53be400c1fad2c914b8d74c9d42384fed5174f9321dd021b7017fd40270"}, 386 | {file = "simplejson-3.19.3-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:72e8abbc86fcac83629a030888b45fed3a404d54161118be52cb491cd6975d3e"}, 387 | {file = "simplejson-3.19.3-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f8efb03ca77bd7725dfacc9254df00d73e6f43013cf39bd37ef1a8ed0ebb5165"}, 388 | {file = "simplejson-3.19.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:add8850db04b98507a8b62d248a326ecc8561e6d24336d1ca5c605bbfaab4cad"}, 389 | {file = "simplejson-3.19.3-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:fc3dc9fb413fc34c396f52f4c87de18d0bd5023804afa8ab5cc224deeb6a9900"}, 390 | {file = "simplejson-3.19.3-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:4dfa420bb9225dd33b6efdabde7c6a671b51150b9b1d9c4e5cd74d3b420b3fe1"}, 391 | {file = "simplejson-3.19.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:7b5c472099b39b274dcde27f1113db8d818c9aa3ba8f78cbb8ad04a4c1ac2118"}, 392 | {file = "simplejson-3.19.3-cp310-cp310-win32.whl", hash = "sha256:817abad79241ed4a507b3caf4d3f2be5079f39d35d4c550a061988986bffd2ec"}, 393 | {file = "simplejson-3.19.3-cp310-cp310-win_amd64.whl", hash = "sha256:dd5b9b1783e14803e362a558680d88939e830db2466f3fa22df5c9319f8eea94"}, 394 | {file = "simplejson-3.19.3-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:e88abff510dcff903a18d11c2a75f9964e768d99c8d147839913886144b2065e"}, 395 | {file = "simplejson-3.19.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:934a50a614fb831614db5dbfba35127ee277624dda4d15895c957d2f5d48610c"}, 396 | {file = "simplejson-3.19.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:212fce86a22188b0c7f53533b0f693ea9605c1a0f02c84c475a30616f55a744d"}, 397 | {file = "simplejson-3.19.3-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5d9e8f836688a8fabe6a6b41b334aa550a6823f7b4ac3d3712fc0ad8655be9a8"}, 398 | {file = "simplejson-3.19.3-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:23228037dc5d41c36666384062904d74409a62f52283d9858fa12f4c22cffad1"}, 399 | {file = "simplejson-3.19.3-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0791f64fed7d4abad639491f8a6b1ba56d3c604eb94b50f8697359b92d983f36"}, 400 | {file = "simplejson-3.19.3-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c4f614581b61a26fbbba232a1391f6cee82bc26f2abbb6a0b44a9bba25c56a1c"}, 401 | {file = "simplejson-3.19.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1df0aaf1cb787fdf34484ed4a1f0c545efd8811f6028623290fef1a53694e597"}, 402 | {file = "simplejson-3.19.3-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:951095be8d4451a7182403354c22ec2de3e513e0cc40408b689af08d02611588"}, 403 | {file = "simplejson-3.19.3-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:2a954b30810988feeabde843e3263bf187697e0eb5037396276db3612434049b"}, 404 | {file = "simplejson-3.19.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:c40df31a75de98db2cdfead6074d4449cd009e79f54c1ebe5e5f1f153c68ad20"}, 405 | {file = "simplejson-3.19.3-cp311-cp311-win32.whl", hash = "sha256:7e2a098c21ad8924076a12b6c178965d88a0ad75d1de67e1afa0a66878f277a5"}, 406 | {file = "simplejson-3.19.3-cp311-cp311-win_amd64.whl", hash = "sha256:c9bedebdc5fdad48af8783022bae307746d54006b783007d1d3c38e10872a2c6"}, 407 | {file = "simplejson-3.19.3-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:66a0399e21c2112acacfebf3d832ebe2884f823b1c7e6d1363f2944f1db31a99"}, 408 | {file = "simplejson-3.19.3-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:6ef9383c5e05f445be60f1735c1816163c874c0b1ede8bb4390aff2ced34f333"}, 409 | {file = "simplejson-3.19.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:42e5acf80d4d971238d4df97811286a044d720693092b20a56d5e56b7dcc5d09"}, 410 | {file = "simplejson-3.19.3-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d0b0efc7279d768db7c74d3d07f0b5c81280d16ae3fb14e9081dc903e8360771"}, 411 | {file = "simplejson-3.19.3-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0552eb06e7234da892e1d02365cd2b7b2b1f8233aa5aabdb2981587b7cc92ea0"}, 412 | {file = "simplejson-3.19.3-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:5bf6a3b9a7d7191471b464fe38f684df10eb491ec9ea454003edb45a011ab187"}, 413 | {file = "simplejson-3.19.3-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7017329ca8d4dca94ad5e59f496e5fc77630aecfc39df381ffc1d37fb6b25832"}, 414 | {file = "simplejson-3.19.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:67a20641afebf4cfbcff50061f07daad1eace6e7b31d7622b6fa2c40d43900ba"}, 415 | {file = "simplejson-3.19.3-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:dd6a7dabcc4c32daf601bc45e01b79175dde4b52548becea4f9545b0a4428169"}, 416 | {file = "simplejson-3.19.3-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:08f9b443a94e72dd02c87098c96886d35790e79e46b24e67accafbf13b73d43b"}, 417 | {file = "simplejson-3.19.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fa97278ae6614346b5ca41a45a911f37a3261b57dbe4a00602048652c862c28b"}, 418 | {file = "simplejson-3.19.3-cp312-cp312-win32.whl", hash = "sha256:ef28c3b328d29b5e2756903aed888960bc5df39b4c2eab157ae212f70ed5bf74"}, 419 | {file = "simplejson-3.19.3-cp312-cp312-win_amd64.whl", hash = "sha256:1e662336db50ad665777e6548b5076329a94a0c3d4a0472971c588b3ef27de3a"}, 420 | {file = "simplejson-3.19.3-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:0959e6cb62e3994b5a40e31047ff97ef5c4138875fae31659bead691bed55896"}, 421 | {file = "simplejson-3.19.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:7a7bfad839c624e139a4863007233a3f194e7c51551081f9789cba52e4da5167"}, 422 | {file = "simplejson-3.19.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:afab2f7f2486a866ff04d6d905e9386ca6a231379181a3838abce1f32fbdcc37"}, 423 | {file = "simplejson-3.19.3-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d00313681015ac498e1736b304446ee6d1c72c5b287cd196996dad84369998f7"}, 424 | {file = "simplejson-3.19.3-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d936ae682d5b878af9d9eb4d8bb1fdd5e41275c8eb59ceddb0aeed857bb264a2"}, 425 | {file = "simplejson-3.19.3-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:01c6657485393f2e9b8177c77a7634f13ebe70d5e6de150aae1677d91516ce6b"}, 426 | {file = "simplejson-3.19.3-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2a6a750d3c7461b1c47cfc6bba8d9e57a455e7c5f80057d2a82f738040dd1129"}, 427 | {file = "simplejson-3.19.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ea7a4a998c87c5674a27089e022110a1a08a7753f21af3baf09efe9915c23c3c"}, 428 | {file = "simplejson-3.19.3-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:6300680d83a399be2b8f3b0ef7ef90b35d2a29fe6e9c21438097e0938bbc1564"}, 429 | {file = "simplejson-3.19.3-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:ab69f811a660c362651ae395eba8ce84f84c944cea0df5718ea0ba9d1e4e7252"}, 430 | {file = "simplejson-3.19.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:256e09d0f94d9c3d177d9e95fd27a68c875a4baa2046633df387b86b652f5747"}, 431 | {file = "simplejson-3.19.3-cp313-cp313-win32.whl", hash = "sha256:2c78293470313aefa9cfc5e3f75ca0635721fb016fb1121c1c5b0cb8cc74712a"}, 432 | {file = "simplejson-3.19.3-cp313-cp313-win_amd64.whl", hash = "sha256:3bbcdc438dc1683b35f7a8dc100960c721f922f9ede8127f63bed7dfded4c64c"}, 433 | {file = "simplejson-3.19.3-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:89b35433186e977fa86ff1fd179c1fadff39cfa3afa1648dab0b6ca53153acd9"}, 434 | {file = "simplejson-3.19.3-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d43c2d7504eda566c50203cdc9dc043aff6f55f1b7dae0dcd79dfefef9159d1c"}, 435 | {file = "simplejson-3.19.3-cp36-cp36m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6890ff9cf0bd2e1d487e2a8869ebd620a44684c0a9667fa5ee751d099d5d84c8"}, 436 | {file = "simplejson-3.19.3-cp36-cp36m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1069143a8fb3905e1bc0696c62be7e3adf812e9f1976ac9ae15b05112ff57cc9"}, 437 | {file = "simplejson-3.19.3-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cb324bb903330cbb35d87cce367a12631cd5720afa06e5b9c906483970946da6"}, 438 | {file = "simplejson-3.19.3-cp36-cp36m-musllinux_1_2_aarch64.whl", hash = "sha256:0a32859d45d7b85fb803bb68f6bee14526991a1190269116c33399fa0daf9bbf"}, 439 | {file = "simplejson-3.19.3-cp36-cp36m-musllinux_1_2_i686.whl", hash = "sha256:23833ee7e791ec968b744dfee2a2d39df7152050051096caf4296506d75608d8"}, 440 | {file = "simplejson-3.19.3-cp36-cp36m-musllinux_1_2_ppc64le.whl", hash = "sha256:d73efb03c5b39249c82488a994f0998f9e4399e3d085209d2120503305ba77a8"}, 441 | {file = "simplejson-3.19.3-cp36-cp36m-musllinux_1_2_x86_64.whl", hash = "sha256:7923878b7a0142d39763ec2dbecff3053c1bedd3653585a8474666e420fe83f5"}, 442 | {file = "simplejson-3.19.3-cp36-cp36m-win32.whl", hash = "sha256:7355c7203353c36d46c4e7b6055293b3d2be097bbc5e2874a2b8a7259f0325dd"}, 443 | {file = "simplejson-3.19.3-cp36-cp36m-win_amd64.whl", hash = "sha256:d1b8b4d6379fe55f471914345fe6171d81a18649dacf3248abfc9c349b4442eb"}, 444 | {file = "simplejson-3.19.3-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:d36608557b4dcd7a62c29ad4cd7c5a1720bbf7dc942eff9dc42d2c542a5f042d"}, 445 | {file = "simplejson-3.19.3-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7137e69c6781ecf23afab064be94a277236c9cba31aa48ff1a0ec3995c69171e"}, 446 | {file = "simplejson-3.19.3-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:76f8c28fe2d426182405b18ddf3001fce47835a557dc15c3d8bdea01c03361da"}, 447 | {file = "simplejson-3.19.3-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ff7bc1bbdaa3e487c9469128bf39408e91f5573901cb852e03af378d3582c52d"}, 448 | {file = "simplejson-3.19.3-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a0782cb9bf827f0c488b6aa0f2819f618308a3caf2973cfd792e45d631bec4db"}, 449 | {file = "simplejson-3.19.3-cp37-cp37m-musllinux_1_2_aarch64.whl", hash = "sha256:6fea0716c593dabb4392c4996d4e902a83b2428e6da82938cf28a523a11eb277"}, 450 | {file = "simplejson-3.19.3-cp37-cp37m-musllinux_1_2_i686.whl", hash = "sha256:8f41bb5370b34f63171e65fdb00e12be1d83675cecb23e627df26f4c88dfc021"}, 451 | {file = "simplejson-3.19.3-cp37-cp37m-musllinux_1_2_ppc64le.whl", hash = "sha256:37105d1d708365b91165e1a6e505bdecc88637091348cf4b6adcdcb4f5a5fb8b"}, 452 | {file = "simplejson-3.19.3-cp37-cp37m-musllinux_1_2_x86_64.whl", hash = "sha256:b9198c1f1f8910a3b86b60f4fe2556d9d28d3fefe35bffe6be509a27402e694d"}, 453 | {file = "simplejson-3.19.3-cp37-cp37m-win32.whl", hash = "sha256:bc164f32dd9691e7082ce5df24b4cf8c6c394bbf9bdeeb5d843127cd07ab8ad2"}, 454 | {file = "simplejson-3.19.3-cp37-cp37m-win_amd64.whl", hash = "sha256:1bd41f2cb1a2c57656ceff67b12d005cb255c728265e222027ad73193a04005a"}, 455 | {file = "simplejson-3.19.3-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:0733ecd95ae03ae718ec74aad818f5af5f3155d596f7b242acbc1621e765e5fb"}, 456 | {file = "simplejson-3.19.3-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:4a0710d1a5e41c4f829caa1572793dd3130c8d65c2b194c24ff29c4c305c26e0"}, 457 | {file = "simplejson-3.19.3-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:1a53a07320c5ff574d8b1a89c937ce33608832f166f39dff0581ac43dc979abd"}, 458 | {file = "simplejson-3.19.3-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1773cabfba66a6337b547e45dafbd471b09487370bcab75bd28f626520410d29"}, 459 | {file = "simplejson-3.19.3-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:7c0104b4b7d2c75ccedbf1d9d5a3bd2daa75e51053935a44ba012e2fd4c43752"}, 460 | {file = "simplejson-3.19.3-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1c49eeb94b8f09dc8a5843c156a22b8bde6aa1ddc65ca8ddc62dddcc001e6a2d"}, 461 | {file = "simplejson-3.19.3-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3dc5c1a85ff388e98ea877042daec3d157b6db0d85bac6ba5498034689793e7e"}, 462 | {file = "simplejson-3.19.3-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:49549e3d81ab4a58424405aa545602674d8c35c20e986b42bb8668e782a94bac"}, 463 | {file = "simplejson-3.19.3-cp38-cp38-musllinux_1_2_i686.whl", hash = "sha256:e1a1452ad5723ff129b081e3c8aa4ba56b8734fee4223355ed7b815a7ece69bc"}, 464 | {file = "simplejson-3.19.3-cp38-cp38-musllinux_1_2_ppc64le.whl", hash = "sha256:d0d5a63f1768fed7e78cf55712dee81f5a345e34d34224f3507ebf71df2b754d"}, 465 | {file = "simplejson-3.19.3-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:7e062767ac165df9a46963f5735aa4eee0089ec1e48b3f2ec46182754b96f55e"}, 466 | {file = "simplejson-3.19.3-cp38-cp38-win32.whl", hash = "sha256:56134bbafe458a7b21f6fddbf889d36bec6d903718f4430768e3af822f8e27c2"}, 467 | {file = "simplejson-3.19.3-cp38-cp38-win_amd64.whl", hash = "sha256:bcde83a553a96dc7533736c547bddaa35414a2566ab0ecf7d3964fc4bdb84c11"}, 468 | {file = "simplejson-3.19.3-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:b5587feda2b65a79da985ae6d116daf6428bf7489992badc29fc96d16cd27b05"}, 469 | {file = "simplejson-3.19.3-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:e0d2b00ecbcd1a3c5ea1abc8bb99a26508f758c1759fd01c3be482a3655a176f"}, 470 | {file = "simplejson-3.19.3-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:32a3ada8f3ea41db35e6d37b86dade03760f804628ec22e4fe775b703d567426"}, 471 | {file = "simplejson-3.19.3-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6f455672f4738b0f47183c5896e3606cd65c9ddee3805a4d18e8c96aa3f47c84"}, 472 | {file = "simplejson-3.19.3-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2b737a5fefedb8333fa50b8db3dcc9b1d18fd6c598f89fa7debff8b46bf4e511"}, 473 | {file = "simplejson-3.19.3-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:eb47ee773ce67476a960e2db4a0a906680c54f662521550828c0cc57d0099426"}, 474 | {file = "simplejson-3.19.3-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eed8cd98a7b24861da9d3d937f5fbfb6657350c547528a117297fe49e3960667"}, 475 | {file = "simplejson-3.19.3-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:619756f1dd634b5bdf57d9a3914300526c3b348188a765e45b8b08eabef0c94e"}, 476 | {file = "simplejson-3.19.3-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:dd7230d061e755d60a4d5445bae854afe33444cdb182f3815cff26ac9fb29a15"}, 477 | {file = "simplejson-3.19.3-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:101a3c8392028cd704a93c7cba8926594e775ca3c91e0bee82144e34190903f1"}, 478 | {file = "simplejson-3.19.3-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:1e557712fc79f251673aeb3fad3501d7d4da3a27eff0857af2e1d1afbbcf6685"}, 479 | {file = "simplejson-3.19.3-cp39-cp39-win32.whl", hash = "sha256:0bc5544e3128891bf613b9f71813ee2ec9c11574806f74dd8bb84e5e95bf64a2"}, 480 | {file = "simplejson-3.19.3-cp39-cp39-win_amd64.whl", hash = "sha256:06662392e4913dc8846d6a71a6d5de86db5fba244831abe1dd741d62a4136764"}, 481 | {file = "simplejson-3.19.3-py3-none-any.whl", hash = "sha256:49cc4c7b940d43bd12bf87ec63f28cbc4964fc4e12c031cc8cd01650f43eb94e"}, 482 | {file = "simplejson-3.19.3.tar.gz", hash = "sha256:8e086896c36210ab6050f2f9f095a5f1e03c83fa0e7f296d6cba425411364680"}, 483 | ] 484 | 485 | [[package]] 486 | name = "tomli" 487 | version = "2.1.0" 488 | description = "A lil' TOML parser" 489 | optional = false 490 | python-versions = ">=3.8" 491 | files = [ 492 | {file = "tomli-2.1.0-py3-none-any.whl", hash = "sha256:a5c57c3d1c56f5ccdf89f6523458f60ef716e210fc47c4cfb188c5ba473e0391"}, 493 | {file = "tomli-2.1.0.tar.gz", hash = "sha256:3f646cae2aec94e17d04973e4249548320197cfabdf130015d023de4b74d8ab8"}, 494 | ] 495 | 496 | [[package]] 497 | name = "typing-extensions" 498 | version = "4.12.2" 499 | description = "Backported and Experimental Type Hints for Python 3.8+" 500 | optional = false 501 | python-versions = ">=3.8" 502 | files = [ 503 | {file = "typing_extensions-4.12.2-py3-none-any.whl", hash = "sha256:04e5ca0351e0f3f85c6853954072df659d0d13fac324d0072316b67d7794700d"}, 504 | {file = "typing_extensions-4.12.2.tar.gz", hash = "sha256:1a7ead55c7e559dd4dee8856e3a88b41225abfe1ce8df57b7c13915fe121ffb8"}, 505 | ] 506 | 507 | [metadata] 508 | lock-version = "2.0" 509 | python-versions = "^3.10" 510 | content-hash = "3302ed65ac7d55ad8eb7dec9dc126ff7465983a5521f8bbf934da92a30dc894f" 511 | -------------------------------------------------------------------------------- /posts/1.sql: -------------------------------------------------------------------------------- 1 | -- https://stackoverflow.com/questions/59432964/relational-data-model-for-double-entry-accounting 2 | -- In your question you are effectively 3 | -- raising several related issues: 4 | 5 | -- 1. how to make an accounting system in general, 6 | -- 2. how to make it for a bank, 7 | -- 3. how to record transactions for current accounts, 8 | -- 4. how make a batch job on accounts, 9 | -- 5. make it all happen in a relational database. 10 | 11 | -- I would say also making an accountant and a data engineer happy 12 | -- at the same time and make the excercise code readable by us all are 13 | -- additional challanges. 14 | 15 | -- These requirements arehard to satisfy at once,and you have to 16 | -- make some trade-offs and sacrifices. Here what the trade-offs are from my 17 | -- point of view: 18 | -- - Monolith vs separate services. Older banking systems would have had a giant monolith schema 19 | -- under one roof, while newer ones are likely to separate 20 | -- the domains and products into separate systems with own databases and events running 21 | -- between them. In your example the currrent account product would be 22 | -- one system, will have a fee scheduler module inside it and the accounting 23 | -- system for reporting would be another system. 24 | 25 | -- - There is a difference between a transaction system and an analytical system. 26 | -- The current account banking product is a transaction system that "really shows" 27 | -- where the client money is, participates in event handling and provides information 28 | -- to the analytic system for the accounting report. The transaction system 29 | -- would need to follow own rules and contracts that satisfy should the accounting 30 | -- logic later, but not as a double entry explicitly. 31 | 32 | -- - You also have to make a decision on guarantees and reposnsibilities 33 | -- you want to have on a database level and what are on application level. 34 | -- For example, on a database level you can well enforce strictly double entry system 35 | -- (always two parts in an entry), but if you want a multiple entry system (where entry has many parts), 36 | -- you may have to do it on an application level or add extra checks within a database. 37 | -- When exchanging the data in event queue between transaction systems 38 | -- you might mark the event as recorded for accounting at current account 39 | -- product and at treasury or cash management product that handles bank own assets. 40 | 41 | -- Have said that, I still think there is a lot of value to model a bank accounting system as 42 | -- a relational database and an accounting system and see how far you can go with it as an excercise 43 | -- while keeking some note on how it may be different from real modern or legacy systems. 44 | 45 | -- I think the minimal example one may have is a table for account names 46 | -- and a table for double entries. We can disregard the account types for now 47 | -- and see the double entry in action. 48 | 49 | -- The account balances table is a unique text name of the account 50 | -- and the account balance is an interger as shown here, maybe a decimal 51 | -- and less likely a real number. Note for a example a programming language like 52 | -- Solidity used for blockain contracts does not have a native float type, just 53 | -- integers. 54 | 55 | DROP TABLE IF EXISTS accounts; 56 | CREATE TABLE accounts ( 57 | name TEXT PRIMARY KEY NOT NULL, 58 | balance INTEGER NOT NULL DEFAULT 0 59 | ); 60 | 61 | -- Let us put in several accounts with zero balances. 62 | INSERT INTO accounts (name) VALUES 63 | ('owner'), -- equity 64 | ('cash'), -- asset 65 | ('client.mary'), -- liabilty 66 | ('client.john'), -- liabilty 67 | ('fees'); -- income 68 | 69 | DROP TABLE IF EXISTS entries; 70 | CREATE TABLE entries ( 71 | id INTEGER PRIMARY KEY AUTOINCREMENT, 72 | title TEXT, 73 | debit TEXT, 74 | credit TEXT, 75 | amount INTEGER NOT NULL, 76 | FOREIGN KEY (debit) REFERENCES accounts(name), 77 | FOREIGN KEY (credit) REFERENCES accounts(name) 78 | ); 79 | 80 | INSERT INTO entries (title, debit, credit, amount) VALUES 81 | -- A bank is created - shareholders did put the money into bank capital 82 | ('Initial equity investment', 'cash', 'owner', 100000), 83 | -- A Client deposits cash to her account 84 | ('Client deposit', 'cash', 'client.mary', 6500), 85 | -- The Bank charges fees once a month to all Clients accounts (sample batch job), 86 | ('Monthly fees', 'client.mary', 'fees', 25), 87 | -- Note: the bank is aggresive enough to charge the fees 88 | -- to the clients with zero account balance 89 | ('Monthly fees', 'client.john', 'fees', 25), 90 | -- A Client does some operation over the counter, and the Bank charges a fee 91 | -- (cash withdrawal + withdrawal fee), 92 | ('Cash withdrawal', 'client.mary', 'cash', 5000), 93 | ('Withdrawal fee', 'client.mary', 'fees', 15), 94 | -- Mary sends some money from her account, to John's account, which is in the same bank 95 | ('Money transfer', 'client.mary', 'client.john', 1000); 96 | 97 | -- Trial balance will show the components of account balance for these accounts 98 | SELECT name, 99 | SUM(CASE WHEN name = debit THEN amount ELSE 0 END) AS debit, 100 | SUM(CASE WHEN name = credit THEN amount ELSE 0 END) AS credit 101 | FROM accounts 102 | JOIN entries ON name = debit OR name = credit 103 | GROUP BY name; 104 | 105 | -- This will result in 106 | -- sqlite3 < 1.sql 107 | 108 | -- cash|106500|5000 109 | -- client.john|25|1000 110 | -- client.mary|6040|6500 111 | -- fees|0|65 112 | -- owner|0|100000 113 | 114 | -- In our database we do not have this information about 115 | -- account types yet, just notes in comments. 116 | -- Some of the accounts will have their balance as credits less debits 117 | -- ('credit-normal' accounts -- bank equity, liabilities and income) 118 | -- and some will have the balance as debits less credits 119 | -- ('debit-normal' accounts -- bank assets and expenses). The debits and credits 120 | -- are a smart trick to guarantee the accounting identity always holds, 121 | -- but it is just a convention, a way to scale accounting records with 122 | -- less errors. 123 | 124 | -- I find an extended form of accounting equation very useful, which 125 | -- does exist within the reporting period before we close 126 | -- the temporary accounts (income and expenses) to retained earnings: 127 | -- 128 | -- ASSETS + EXPENSES = EQUITY + LIABILITIES + INCOME (1) 129 | -- 130 | -- let EQUITY = SHAREHOLDER + OTHER EQUITY (OE) + RETAINED EARNINGS (2) 131 | -- where SHAREHOLDER is the shareholder or owner equity 132 | -- and OTHER EQUITY is the sum of other equity components 133 | -- like reserves, additional capital raised selling share above nominal price, 134 | -- some revaluations, callable capital, etc. In bank especially 135 | -- the composition of capital is extremely important, because it is 136 | -- used to calculate the capital adequacy ratio, that reflects 137 | -- the bank's ability to absorb losses on bad loans. 138 | -- Before you go into exploring this topic 139 | -- you can assign OTHER EQUITY to zero and 140 | -- just have SHAREHOLDER and RETAINED EARNINGS in the EQUITY variable. 141 | -- Lets us mark EQUITY' = SHAREHOLDER + OE ("equity prime") (3) 142 | -- and rewrite the equation (1) as: 143 | 144 | -- ASSETS + EXPENSES = (EQUITY' + RETAINED EARNINGS) + LIABILITIES + INCOME (4) 145 | 146 | -- Whatever events we decide to put into accounting records 147 | -- we must keep the equation balanced. Double entry that changes 148 | -- exectly two variables in the equation by the same amount would 149 | -- provide a guarantee the equation holds. You would get this result 150 | -- by adding 10 to liabilities and 10 to assets, but if you decide 151 | -- adding 5 to assets and 5 expenses the equation will break. 152 | -- We need some clever way to comminicate what we are changinging in the 153 | -- accounnting equation and this method of comminication must preserve 154 | -- the identity of the equation. Let's call this a "notation challenge". 155 | -- 156 | -- Let us consider each account is represented by a pair, or a tuple, of amounts, 157 | -- (left, right), for the accounts on the left side of the equation (assets and expenses) 158 | -- let us calulate the acocunt balance as balance (left - right), and for the right side 159 | -- of the equation (equity, liabilities and income) let us calculate the balance as 160 | -- (right - left) and let all accounts be (0, 0) at the beginning when we create 161 | -- a ledger of accounts for the new company company. 162 | 163 | -- Our newly invented method of changing the accounting equation will be 164 | -- saying "Add the amount to the left side of the account X and 165 | -- add the same amount to the right side of the account Y", for example 166 | -- "Add 10 to the left side of the account 'cash' and add 10 to the right side of 167 | -- the account 'equity'", or process `dict(left='cash', right='equity', amount=10)` 168 | -- or "Add 5 to the left side of the account 'inventory' and add 5 to the 169 | -- right side of account 'expenses'", or process `dict(left='inventory', right='expenses', amount=5)`. 170 | 171 | -- One can see whatever you add through the (left, right, amount) tuples or dictionaries, 172 | -- the equation (4) will hold. 173 | 174 | -- Congratulations, we just invented debits and credits book-keeping system, 175 | -- as you can call "left" a "debit" and "right" a "credit". Note that debits and credits just satisfy the "notation challenge" 176 | -- above and do not provide further guarantees that you need to check elsewhere. 177 | 178 | -- More specifically, can you add meaningless transaction this way? 179 | -- Of course, something that changes to pair of accounts that where never meant to change together. 180 | -- Can you add a wrong transaction this way? Sure, you meant to say credit income, debit cash 181 | -- for accepting client payment, but you said it other way around credit cash, 182 | -- debit income and this looks something like a client refund. However, will the equation hold 183 | -- anyways? Yes it will. Is it a good thing? Mostly yes, because there is 184 | -- one thing less, the accounting identity, to worry about. 185 | 186 | 187 | -- Excercises: 188 | -- - handle 189 | -- ('interbank'), -- asset, an account at a correspondent bank or at central bank 190 | 191 | -- Extenstions: 192 | -- - why is this not a complete accounting system? 193 | -------------------------------------------------------------------------------- /prose/assumptions.md: -------------------------------------------------------------------------------- 1 | ### Assumptions 2 | 3 | Several assumptions and simplifications are used to make `abacus-minimal` easier to develop and reason about. Some of them are here stay, some may be relaxed in future versions. 4 | 5 | General: 6 | 7 | 1. one currency 8 | 1. one reporting period 9 | 1. one reporting entity 10 | 11 | Chart of accounts: 12 | 13 | 1. one level of accounts, no sub-accounts, no account aggregation for reports 14 | 1. account names must be globally unique (eg cannot have two accounts named "other") 15 | 1. chart always has current earnings account and retained earnings account 16 | 1. no account durations, current vs non-current accounts not distinguished 17 | 18 | Accounts and closing: 19 | 20 | 1. accounts are either debit normal or credit normal, no mixed accounts 21 | 1. no intermediate accounts except current earnings 22 | 1. period end closing will transfer current earnings to retained earnings 23 | 24 | Entries: 25 | 26 | 1. no journals, entries are posted to ledger directly 27 | 1. an entry can touch any accounts 28 | 1. entry amount can be positive, negative or zero 29 | 1. no date or any transaction metadata recorded 30 | 31 | Valuation changes: 32 | 33 | 1. valuations changes no recorded 34 | 1. other comprehensive income account (OCIA) not calculated 35 | 36 | Reporting: 37 | 38 | 1. net earnings are income less expenses, no gross profit or earnings before tax calculated 39 | 1. no cash flow statement 40 | 1. no statement of changes in equity 41 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.ruff] 2 | exclude = ["__init__.py"] 3 | 4 | [tool.pytest.ini_options] 5 | markers = [ 6 | "chart_dict", 7 | "entry", 8 | "ledger", 9 | "report", 10 | "mixed", 11 | "regression: something was wrong and got fixed", 12 | "cli: command line interfaces" 13 | ] 14 | 15 | [tool.poetry] 16 | name = "abacus-minimal" 17 | version = "0.14.2" 18 | description = "Ledger in Python that follows corporate accounting rules." 19 | authors = ["Evgeny Pogrebnyak "] 20 | license = "MIT" 21 | readme = "README.md" 22 | packages = [ 23 | {include = "abacus"} 24 | ] 25 | 26 | 27 | [tool.poetry.dependencies] 28 | python = "^3.10" 29 | pydantic = "^2.9.2" 30 | simplejson = "^3.19.3" 31 | 32 | [tool.poetry.group.dev.dependencies] 33 | pytest = "^8.3.3" 34 | isort = "^5.13.2" 35 | mypy = "^1.13.0" 36 | ruff = "^0.7.0" 37 | # must add black 38 | pyright = "^1.1.389" 39 | 40 | [build-system] 41 | requires = ["poetry-core"] 42 | build-backend = "poetry.core.masonry.api" 43 | -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from abacus import T5, Asset, Chart, Double, Equity, Ledger 4 | from abacus.ledger import ChartDict 5 | 6 | 7 | @pytest.fixture 8 | def toy_dict() -> ChartDict: 9 | return ChartDict(cash=T5.Asset, equity=T5.Equity, re=T5.Equity) 10 | 11 | 12 | @pytest.fixture 13 | def toy_ledger(): 14 | return Ledger.from_list( 15 | [ 16 | Asset("cash"), 17 | Equity("equity"), 18 | Equity("re"), 19 | Double("cash", "equity", 10), 20 | ] 21 | ) 22 | 23 | 24 | @pytest.fixture 25 | def realistic_chart(): 26 | return Chart( 27 | retained_earnings="re", 28 | current_earnings="profit", 29 | assets=["cash", "inventory", "ar"], 30 | equity=["equity"], 31 | liabilities=["vat", "ap"], 32 | income=["sales"], 33 | expenses=["wages"], 34 | contra_accounts={"sales": ["refunds", "voids"], "equity": ["ts"]}, 35 | names={ 36 | "vat": "VAT payable", 37 | "ar": "Accounts receivable", 38 | "ap": "Accounts payable", 39 | "ts": "Treasury stock", 40 | }, 41 | ) 42 | -------------------------------------------------------------------------------- /tests/test_book.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from abacus import Book, Chart, Entry 4 | from abacus.chart import Earnings 5 | from abacus.ledger import BalanceSheet, IncomeStatement, ReportDict 6 | 7 | 8 | def test_balances_dict_loads(): 9 | d = ReportDict(cash=(50)) 10 | j = d.model_dump_json() 11 | d2 = ReportDict.model_validate_json(j) 12 | assert d2["cash"] == (50) 13 | 14 | 15 | def test_balances_dict_load(tmp_path): 16 | d = ReportDict(cash=(50)) 17 | path = tmp_path / "d.json" 18 | d.save(path) 19 | d3 = ReportDict.load(path) 20 | assert d3["cash"] == (50) 21 | 22 | 23 | def test_balances_dict_serialisation(toy_ledger): 24 | content = ReportDict(toy_ledger.balances).model_dump_json() 25 | assert ReportDict.model_validate_json(content) == dict(cash=10, equity=10, re=0) 26 | 27 | 28 | def test_balances_load_save(tmp_path): 29 | path = str(tmp_path / "b.json") 30 | b = ReportDict(a=1) 31 | b.save(path) 32 | assert b == ReportDict.load(path) 33 | 34 | 35 | @pytest.fixture 36 | def this_chart(): 37 | chart = Chart( 38 | assets=["cash"], 39 | equity=["equity"], 40 | income=["sales"], 41 | expenses=["salaries"], 42 | retained_earnings="retained_earnings", 43 | current_earnings="current_earnings", 44 | ) 45 | chart.offset("sales", "refunds") 46 | return chart 47 | 48 | 49 | def test_earnings(this_chart): 50 | assert this_chart.earnings == Earnings( 51 | current="current_earnings", retained="retained_earnings" 52 | ) 53 | 54 | 55 | def test_book_may_open_with_retained_earnings(this_chart): 56 | opening_balances = {"cash": 10_000, "equity": 8_000, "retained_earnings": 2_000} 57 | book = Book.from_chart(this_chart) 58 | print(book) 59 | book.open(opening_balances) 60 | assert book.balances == { 61 | "cash": 10_000, 62 | "equity": 8_000, 63 | "sales": 0, 64 | "salaries": 0, 65 | "retained_earnings": 2_000, 66 | "refunds": 0, 67 | } 68 | 69 | 70 | @pytest.fixture 71 | def book_before_close(this_chart): 72 | entries = [ 73 | Entry("Initial investment").amount(300).debit("cash").credit("equity"), 74 | Entry("Sold services with VAT").amount(125).debit("cash").credit("sales"), 75 | Entry("Made refund").amount(25).debit("refunds").credit("cash"), 76 | Entry("Paid salaries").amount(50).debit("salaries").credit("cash"), 77 | ] 78 | book = Book.from_chart(this_chart) 79 | book.post_many(entries) 80 | return book 81 | 82 | 83 | @pytest.fixture 84 | def book_after_close(book_before_close): 85 | book_before_close.close() 86 | return book_before_close 87 | 88 | 89 | def test_book_now_closed(book_after_close): 90 | assert book_after_close.ledger.is_closed() is True 91 | 92 | 93 | def test_income_statement_before_close(book_before_close): 94 | assert book_before_close.income_statement == IncomeStatement( 95 | income={"sales": 100}, expenses={"salaries": 50} 96 | ) 97 | 98 | 99 | def test_income_statement_after_close(book_after_close): 100 | assert book_after_close.income_statement == IncomeStatement( 101 | income={"sales": 100}, expenses={"salaries": 50} 102 | ) 103 | 104 | 105 | def test_balance_sheet_before_close(book_before_close): 106 | assert book_before_close.balance_sheet == BalanceSheet( 107 | assets={"cash": 350}, 108 | equity={"equity": 300, "current_earnings": 50, "retained_earnings": 0}, 109 | liabilities={}, 110 | ) 111 | 112 | 113 | def test_balance_sheet_after_close(book_after_close): 114 | assert book_after_close.balance_sheet == BalanceSheet( 115 | assets={"cash": 350}, 116 | equity={"equity": 300, "retained_earnings": 50}, 117 | liabilities={}, 118 | ) 119 | 120 | 121 | def test_balances_before_close(book_before_close): 122 | assert book_before_close.ledger.balances == { 123 | "cash": 350, 124 | "equity": 300, 125 | "sales": 125, 126 | "salaries": 50, 127 | "retained_earnings": 0, 128 | "refunds": 25, 129 | } 130 | 131 | 132 | def test_balances_after_close(book_after_close): 133 | assert book_after_close.ledger.balances == { 134 | "cash": 350, 135 | "equity": 300, 136 | "retained_earnings": 50, 137 | } 138 | 139 | 140 | def test_book_similar_to_readme(tmp_path): 141 | chart = Chart( 142 | retained_earnings="retained_earnings", 143 | current_earnings="current_earnings", 144 | assets=["cash"], 145 | equity=["equity"], 146 | income=["sales"], 147 | expenses=["salaries"], 148 | ) 149 | chart.offset("sales", "refunds") 150 | chart.save(tmp_path / "chart.json") 151 | book = Book.from_chart(chart) 152 | book.post(Entry("Initial investment").debit("cash", 10000).credit("equity", 10000)) 153 | book.balances.save(tmp_path / "balances.json") 154 | book.ledger.history.save(tmp_path / "history.json") 155 | entries = [ 156 | Entry("Sold services with VAT").amount(6500).debit("cash").credit("sales"), 157 | Entry("Made refund").double(amount=500, debit="refunds", credit="cash"), 158 | Entry("Paid salaries").double(amount=1000, debit="salaries", credit="cash"), 159 | ] 160 | book.post_many(entries) 161 | book.close() 162 | assert book.ledger.balances == { 163 | "cash": 15000, 164 | "equity": 10000, 165 | "retained_earnings": 5000, 166 | } 167 | book.save_history(tmp_path / "history.json", allow_overwrite=True) 168 | -------------------------------------------------------------------------------- /tests/test_chart.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from pydantic import ValidationError 3 | 4 | from abacus import AbacusError, Asset, Chart, Equity 5 | from abacus.chart import BaseChart, Earnings 6 | 7 | 8 | def test_chart_base_name(): 9 | chart = BaseChart() 10 | chart.add_account(Asset("ar", title="AR")) 11 | assert chart.names["ar"] == "AR" 12 | 13 | 14 | def test_chart_base_contra(): 15 | chart = BaseChart() 16 | chart.add_account(Asset("ppe", contra_accounts=["depreciation"])) 17 | assert chart.contra_accounts == {"ppe": ["depreciation"]} 18 | 19 | 20 | def test_chart_base_offset(): 21 | chart = BaseChart(income=["sales"]).offset("sales", "refunds") 22 | assert chart.contra_accounts["sales"] == ["refunds"] 23 | 24 | 25 | def test_post_init_on_dublicate(): 26 | with pytest.raises(AbacusError): 27 | Chart( 28 | retained_earnings="retained_earnings", 29 | current_earnings="current_earnings", 30 | assets=["cash", "cash"], 31 | ) 32 | 33 | 34 | def test_all_contra_accounts_point_to_existing_accounts(): 35 | with pytest.raises(AbacusError): 36 | Chart( 37 | retained_earnings="retained_earnings", 38 | current_earnings="current_earnings", 39 | contra_accounts={"reference_does_not_exist": ["depreciation"]}, 40 | ) 41 | 42 | 43 | def test_chart_on_empty_list(): 44 | chart = Chart(retained_earnings="re", current_earnings="profit") 45 | assert chart.account_names == ["re"] 46 | 47 | 48 | def test_pydantic_will_not_accept_extra_fields(): 49 | with pytest.raises(ValidationError): 50 | Chart( 51 | retained_earnings="re", 52 | current_earnings="profit", 53 | accounts=[], 54 | haha=["equity"], 55 | ) 56 | 57 | 58 | def test_chart_to_list(): 59 | assert Chart( 60 | retained_earnings="re", 61 | current_earnings="profit", 62 | assets=["cash"], 63 | equity=["equity"], 64 | contra_accounts={"equity": ["ts"]}, 65 | ).accounts == [ 66 | Asset("cash"), 67 | Equity("equity", ["ts"]), 68 | Equity("re"), 69 | ] 70 | 71 | 72 | def test_cannot_overwrite_chart(tmp_path): 73 | chart = Chart(retained_earnings="re", current_earnings="profit") 74 | path = tmp_path / "chart.json" 75 | chart.save(path) 76 | with pytest.raises(FileExistsError): 77 | chart.save(path) 78 | 79 | 80 | def test_earnings_qfn(): 81 | assert Chart( 82 | retained_earnings="re", 83 | current_earnings="profit", 84 | assets=["cash"], 85 | equity=["equity"], 86 | contra_accounts={"equity": ["ts"]}, 87 | ).earnings == Earnings(current="profit", retained="re") 88 | 89 | 90 | def test_chart_base_udr(): 91 | assert Chart( 92 | retained_earnings="re", 93 | current_earnings="profit", 94 | assets=["cash"], 95 | equity=["equity"], 96 | contra_accounts={"equity": ["ts"]}, 97 | ).base == BaseChart( 98 | assets=["cash"], 99 | equity=["equity", "re"], 100 | liabilities=[], 101 | income=[], 102 | expenses=[], 103 | contra_accounts={"equity": ["ts"]}, 104 | names={}, 105 | ) 106 | -------------------------------------------------------------------------------- /tests/test_entry.py: -------------------------------------------------------------------------------- 1 | from decimal import Decimal 2 | 3 | import pytest 4 | from pytest import fixture 5 | 6 | from abacus import AbacusError, Credit, Debit, Double, Entry, Multiple 7 | from abacus.ledger import DebitAccount, Initial 8 | 9 | 10 | @pytest.mark.skip 11 | def test_unbalanced_entry_will_not_pass(toy_ledger): 12 | with pytest.raises(AbacusError): 13 | toy_ledger.apply([Credit("cash", 1)]) 14 | 15 | 16 | def test_entry_double(): 17 | assert list(Double(debit="cash", credit="equity", amount=10)) == [ 18 | Debit("cash", 10), 19 | Credit("equity", 10), 20 | ] 21 | 22 | 23 | @pytest.mark.entry 24 | def test_closing_entry_for_debit_account(): 25 | assert DebitAccount(20).transfer("this", "that") == Double("that", "this", 20) 26 | 27 | 28 | def test_entry_amount(): 29 | entry = Entry("Entry with amount").amount(10).debit("cash").credit("equity") 30 | assert list(entry) == [Debit("cash", 10), Credit("equity", 10)] 31 | 32 | 33 | def test_entry_no_amount_raises_error(): 34 | with pytest.raises(AbacusError): 35 | Entry("Entry with no amount").debit("cash") 36 | 37 | 38 | def test_double(): 39 | d = Double("cash", "equity", 10) 40 | assert list(d) == [ 41 | Debit(account="cash", amount=Decimal(10)), 42 | Credit(account="equity", amount=Decimal(10)), 43 | ] 44 | 45 | 46 | @fixture 47 | def reference_entry(): 48 | return [ 49 | Debit(account="cash", amount=Decimal(10)), 50 | Credit(account="equity", amount=Decimal(8)), 51 | Credit(account="retained_earnings", amount=Decimal(2)), 52 | ] 53 | 54 | 55 | def test_multiple(reference_entry): 56 | m = Multiple( 57 | debits=[("cash", Decimal(10))], 58 | credits=[("equity", Decimal(8)), ("retained_earnings", Decimal(2))], 59 | ) 60 | assert list(m) == reference_entry 61 | 62 | 63 | def test_entry(reference_entry): 64 | e = ( 65 | Entry("Some entry") 66 | .debit("cash", 10) 67 | .credit("equity", 8) 68 | .credit("retained_earnings", 2) 69 | ) 70 | assert list(e) == reference_entry 71 | 72 | 73 | @pytest.mark.entry 74 | def test_how_it_fails(): 75 | with pytest.raises(AbacusError): 76 | Multiple.from_list([Debit("cash", 10)]) 77 | 78 | 79 | @pytest.mark.entry 80 | def test_opening_fails(toy_dict): 81 | with pytest.raises(AbacusError): 82 | Initial(dict(cash=10, equity=8)).to_entry(toy_dict) 83 | 84 | 85 | @pytest.mark.entry 86 | def test_opening_entry(toy_dict): 87 | opening_dict = dict(cash=10, equity=8, re=2) 88 | entry = Initial(opening_dict).to_entry(toy_dict) 89 | assert list(entry) == [Debit("cash", 10), Credit("equity", 8), Credit("re", 2)] 90 | -------------------------------------------------------------------------------- /tests/test_generate.py: -------------------------------------------------------------------------------- 1 | # TODO: make fake chart and random entry pairs 2 | 3 | account_names = ["this", "that", "shoe", "vest", "ice", "gum", "silver", "liver"] 4 | -------------------------------------------------------------------------------- /tests/test_ledger.py: -------------------------------------------------------------------------------- 1 | from abacus import Double, Income, Ledger 2 | from abacus.ledger import BalanceSheet, CreditAccount, DebitAccount, Initial, ReportDict 3 | 4 | 5 | def test_ledger_keys(toy_ledger): 6 | assert list(toy_ledger.accounts) == ["cash", "equity", "re"] 7 | 8 | 9 | def test_ledger_open(toy_ledger): 10 | entry = Initial(dict(cash=2, equity=2)).to_entry(toy_ledger.chart) 11 | toy_ledger.apply(entry) 12 | assert toy_ledger.balances == dict(re=0, cash=12, equity=12) 13 | 14 | 15 | def test_balance_sheet_is_not_balanced(): 16 | assert ( 17 | BalanceSheet( 18 | assets=ReportDict({"cash": 350}), 19 | equity=ReportDict({"equity": 300, "retained_earnings": 50}), 20 | liabilities=ReportDict({"extra": 1}), 21 | ).is_balanced() 22 | is False 23 | ) 24 | 25 | 26 | def test_net_earnings(toy_ledger): 27 | toy_ledger.apply_many([Income("sales"), Double("cash", "sales", 10)]) 28 | assert toy_ledger.income_statement().net_earnings == 10 29 | 30 | 31 | def test_balance_sheet(toy_dict): 32 | ledger = Ledger() 33 | ledger.chart = toy_dict 34 | ledger.accounts = { 35 | "cash": DebitAccount(10), 36 | "equity": CreditAccount(10), 37 | "re": CreditAccount(0), 38 | } 39 | 40 | assert ledger.balance_sheet("cu") == BalanceSheet( 41 | assets=dict(cash=10), equity=dict(equity=10, re=0, cu=0), liabilities=dict() 42 | ) 43 | 44 | 45 | def test_balance_sheet_again(toy_ledger): 46 | assert toy_ledger.close("re").balance_sheet() == BalanceSheet( 47 | assets=dict(cash=10), equity=dict(equity=10, re=0), liabilities=dict() 48 | ) 49 | 50 | 51 | def test_balances(toy_ledger): 52 | assert toy_ledger.balances == dict(cash=10, equity=10, re=0) 53 | -------------------------------------------------------------------------------- /tests/test_mixed.py: -------------------------------------------------------------------------------- 1 | from abacus import ( 2 | T5, 3 | Add, 4 | Asset, 5 | Book, 6 | Chart, 7 | Close, 8 | Contra, 9 | Double, 10 | Entry, 11 | Equity, 12 | Event, 13 | Expense, 14 | History, 15 | Income, 16 | Initial, 17 | Ledger, 18 | Liability, 19 | Multiple, 20 | ) 21 | from abacus.ledger import BalanceSheet 22 | 23 | 24 | def test_readme(): 25 | chart = Chart( 26 | retained_earnings="re", 27 | current_earnings="profit", 28 | assets=["cash"], 29 | equity=["equity"], 30 | income=["sales"], 31 | contra_accounts=dict(equity=["ts"], sales=["refunds"]), 32 | ) 33 | book = Book.from_chart(chart) 34 | book.post(Entry("Launch").debit("cash", 10).credit("equity", 10)) 35 | book.post(Entry("Sold services").double(debit="cash", credit="sales", amount=50)) 36 | book.post(Entry("Issued refund").debit("refunds", 40).credit("cash", 40)) 37 | book.post(Entry("Made buyback").double(debit="ts", credit="cash", amount=8)) 38 | assert book.income_statement.net_earnings == 10 39 | book.close() 40 | assert book.balance_sheet == BalanceSheet( 41 | assets={"cash": 12}, 42 | equity={"equity": 2, "re": 10}, 43 | liabilities={}, 44 | ) 45 | 46 | 47 | def test_end_to_end(realistic_chart): 48 | ledger = Ledger.from_accounts(realistic_chart.accounts) 49 | entries = [ 50 | Entry("Start").debit("cash", 20).credit("equity", 20), 51 | Entry("Accepted payment") 52 | .debit("cash", 120) 53 | .credit("sales", 100) 54 | .credit("vat", 20), 55 | Entry("Refund").double("refunds", "cash", 5), 56 | Entry("Paid salaries").double(debit="wages", credit="cash", amount=10), 57 | Entry("Paid VAT due").double(debit="vat", credit="cash", amount=20), 58 | ] 59 | for entry in entries: 60 | ledger.apply(entry.to_multiple()) 61 | ledger.close("re") 62 | assert ledger.balances == { 63 | "cash": 105, 64 | "inventory": 0, 65 | "ar": 0, 66 | "vat": 0, 67 | "ap": 0, 68 | "equity": 20, 69 | "re": 85, 70 | "ts": 0, 71 | } 72 | 73 | 74 | def test_very_mixed(): 75 | # Create accounts 76 | accounts = [ 77 | Asset("cash"), 78 | Equity("equity"), 79 | Income("services", contra_accounts=["refunds", "voids"]), 80 | Liability("vat", title="VAT due to tax authorities"), 81 | Expense("salaries"), 82 | Equity("retained_earnings"), 83 | ] 84 | 85 | entries = [ 86 | # Start ledger with initial balances 87 | Initial({"cash": 10, "equity": 8, "retained_earnings": 2}), 88 | # Make transactions 89 | Multiple(debits=[("cash", 120)], credits=[("services", 100), ("vat", 20)]), 90 | Double("refunds", "cash", 15), 91 | Double("voids", "cash", 15), 92 | Double("salaries", "cash", 50), 93 | # Close period end 94 | Close("retained_earnings"), 95 | ] 96 | 97 | ledger = Ledger.from_accounts(accounts).apply_many(entries) 98 | print(ledger.chart) 99 | print(ledger.balances) 100 | assert len(ledger.history.events) == len(accounts) + len(entries) 101 | assert ledger.balances == { 102 | "cash": 50, 103 | "equity": 8, 104 | "vat": 20, 105 | "retained_earnings": 22, 106 | } 107 | assert ledger.chart == { 108 | "cash": T5.Asset, 109 | "equity": T5.Equity, 110 | "services": T5.Income, 111 | "refunds": Contra(name="services"), 112 | "voids": Contra(name="services"), 113 | "salaries": T5.Expense, 114 | "retained_earnings": T5.Equity, 115 | "vat": T5.Liability, 116 | } 117 | ledger2 = ledger.history.to_ledger() 118 | assert ledger2.balances == ledger.balances 119 | if ledger.accounts_before_close is not None: 120 | assert len(ledger.accounts_before_close) > 3 121 | assert ledger2.is_closed() is True 122 | assert ledger2.income_statement().net_earnings == 20 123 | assert ledger2.balance_sheet().is_balanced() is True 124 | content = ledger.history.model_dump_json(indent=2) 125 | history2 = History.model_validate_json(content) 126 | for a, b in zip(history2, ledger.history): 127 | assert a == b 128 | history2.save("history2.json", allow_overwrite=True) 129 | history3 = History.load("history2.json") 130 | for a, b in zip(history2, history3): 131 | assert a == b 132 | 133 | e = Event( 134 | action=Add(name="cash", t=T5.Asset, tag="add"), 135 | primitives=[Add(name="cash", t=T5.Asset, tag="add")], 136 | note=None, 137 | ) 138 | h = History(events=[e]) 139 | d = h.model_dump() 140 | h2 = History.model_validate(d) 141 | print(h2) 142 | --------------------------------------------------------------------------------