├── .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 | 
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 |
--------------------------------------------------------------------------------