lib-ledger-core 0.3.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- lib_ledger_core-0.3.0/.gitignore +61 -0
- lib_ledger_core-0.3.0/.gitlab-ci.yml +53 -0
- lib_ledger_core-0.3.0/CHANGELOG.md +42 -0
- lib_ledger_core-0.3.0/PKG-INFO +267 -0
- lib_ledger_core-0.3.0/README.md +231 -0
- lib_ledger_core-0.3.0/alembic.ini +41 -0
- lib_ledger_core-0.3.0/ledger_core/__init__.py +53 -0
- lib_ledger_core-0.3.0/ledger_core/adapters/__init__.py +17 -0
- lib_ledger_core-0.3.0/ledger_core/adapters/kurrent.py +105 -0
- lib_ledger_core-0.3.0/ledger_core/adapters/registry.py +38 -0
- lib_ledger_core-0.3.0/ledger_core/adapters/sqlalchemy.py +355 -0
- lib_ledger_core-0.3.0/ledger_core/adapters/tigerbeetle.py +380 -0
- lib_ledger_core-0.3.0/ledger_core/ddl/__init__.py +15 -0
- lib_ledger_core-0.3.0/ledger_core/ddl/generic.py +59 -0
- lib_ledger_core-0.3.0/ledger_core/ddl/plugin.py +19 -0
- lib_ledger_core-0.3.0/ledger_core/ddl/postgres.py +58 -0
- lib_ledger_core-0.3.0/ledger_core/ddl/registry.py +37 -0
- lib_ledger_core-0.3.0/ledger_core/exceptions.py +31 -0
- lib_ledger_core-0.3.0/ledger_core/interfaces.py +32 -0
- lib_ledger_core-0.3.0/ledger_core/migrations/__init__.py +39 -0
- lib_ledger_core-0.3.0/ledger_core/migrations/env.py +72 -0
- lib_ledger_core-0.3.0/ledger_core/migrations/script.py.mako +26 -0
- lib_ledger_core-0.3.0/ledger_core/migrations/versions/001_baseline.py +40 -0
- lib_ledger_core-0.3.0/ledger_core/models.py +28 -0
- lib_ledger_core-0.3.0/ledger_core/py.typed +0 -0
- lib_ledger_core-0.3.0/ledger_core/schema.py +40 -0
- lib_ledger_core-0.3.0/pyproject.toml +71 -0
- lib_ledger_core-0.3.0/tests/__init__.py +0 -0
- lib_ledger_core-0.3.0/tests/integration/__init__.py +0 -0
- lib_ledger_core-0.3.0/tests/integration/conftest.py +132 -0
- lib_ledger_core-0.3.0/tests/integration/contract.py +208 -0
- lib_ledger_core-0.3.0/tests/integration/entrypoint-tigerbeetle.sh +14 -0
- lib_ledger_core-0.3.0/tests/integration/test_kurrent.py +216 -0
- lib_ledger_core-0.3.0/tests/integration/test_sqlalchemy.py +68 -0
- lib_ledger_core-0.3.0/tests/integration/test_sqlite.py +139 -0
- lib_ledger_core-0.3.0/tests/integration/test_tigerbeetle.py +185 -0
- lib_ledger_core-0.3.0/tests/unit/__init__.py +0 -0
- lib_ledger_core-0.3.0/tests/unit/test_adapters_registry.py +45 -0
- lib_ledger_core-0.3.0/tests/unit/test_ddl.py +59 -0
- lib_ledger_core-0.3.0/tests/unit/test_exceptions.py +47 -0
- lib_ledger_core-0.3.0/tests/unit/test_migrations.py +128 -0
- lib_ledger_core-0.3.0/tests/unit/test_models.py +70 -0
- lib_ledger_core-0.3.0/tests/unit/test_schema.py +45 -0
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
# System files
|
|
2
|
+
.DS_Store
|
|
3
|
+
Thumbs.db
|
|
4
|
+
.history
|
|
5
|
+
|
|
6
|
+
# IDEs and Editors
|
|
7
|
+
.vscode/
|
|
8
|
+
.idea/
|
|
9
|
+
*.swp
|
|
10
|
+
*.swo
|
|
11
|
+
.antigravitycli/
|
|
12
|
+
|
|
13
|
+
# Tigerbeetle test data
|
|
14
|
+
.tigerbeetle
|
|
15
|
+
|
|
16
|
+
# --- uv / Virtual Environments ---
|
|
17
|
+
.venv/
|
|
18
|
+
.uv/
|
|
19
|
+
# uv.lock <-- Keep this UNLESS you have a specific reason to ignore it
|
|
20
|
+
|
|
21
|
+
# --- Python ---
|
|
22
|
+
__pycache__/
|
|
23
|
+
*.py[cod]
|
|
24
|
+
*$py.class
|
|
25
|
+
dist/
|
|
26
|
+
build/
|
|
27
|
+
*.egg-info/
|
|
28
|
+
# Allow committing .gitkeep in frontend dist folders to satisfy Hatchling forced includes
|
|
29
|
+
!**/frontend/dist/.gitkeep
|
|
30
|
+
*.spec
|
|
31
|
+
|
|
32
|
+
# --- Tooling ---
|
|
33
|
+
.pytest_cache/
|
|
34
|
+
.ruff_cache/
|
|
35
|
+
.mypy_cache/
|
|
36
|
+
.coverage
|
|
37
|
+
node_modules/
|
|
38
|
+
**/.m/
|
|
39
|
+
.pnpm-store/
|
|
40
|
+
|
|
41
|
+
# --- Secrets ---
|
|
42
|
+
.env
|
|
43
|
+
.env.*
|
|
44
|
+
!.env.example
|
|
45
|
+
dev.db
|
|
46
|
+
|
|
47
|
+
# --- Rust & Tauri ---
|
|
48
|
+
**/src-tauri/target/
|
|
49
|
+
**/src-tauri/bin/*
|
|
50
|
+
!**/src-tauri/bin/.gitkeep
|
|
51
|
+
**/src-tauri/lib/
|
|
52
|
+
**/src-tauri/share/
|
|
53
|
+
temp_sidecars/
|
|
54
|
+
|
|
55
|
+
# --- Local Development Symlinks ---
|
|
56
|
+
apps/business-m/src/business_m/static
|
|
57
|
+
|
|
58
|
+
# --- Generated Documentation (CI/CD Artifacts) ---
|
|
59
|
+
website/public/docs/machine/
|
|
60
|
+
website/src/content/docs/developer/generated/
|
|
61
|
+
.aider*
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
ledger-core:lint:
|
|
2
|
+
stage: lint
|
|
3
|
+
image: python:3.12
|
|
4
|
+
script:
|
|
5
|
+
- pip install uv
|
|
6
|
+
- cd libs/ledger-core
|
|
7
|
+
- uv sync --all-packages --all-extras --all-groups
|
|
8
|
+
- uv tool run ruff check .
|
|
9
|
+
- uv tool run ruff format --check .
|
|
10
|
+
- uv run mypy ledger_core --strict
|
|
11
|
+
rules:
|
|
12
|
+
- if: $CI_COMMIT_MESSAGE =~ /^chore\(release\):/
|
|
13
|
+
when: never
|
|
14
|
+
- if: $CI_COMMIT_BRANCH =~ /^release\/.*/
|
|
15
|
+
when: never
|
|
16
|
+
- if: $CI_MERGE_REQUEST_SOURCE_BRANCH_NAME =~ /^release\/.*/
|
|
17
|
+
when: never
|
|
18
|
+
- if: $CI_COMMIT_TAG
|
|
19
|
+
when: never
|
|
20
|
+
- changes:
|
|
21
|
+
- libs/ledger-core/ledger_core/**/*
|
|
22
|
+
- libs/ledger-core/tests/**/*
|
|
23
|
+
- libs/ledger-core/pyproject.toml
|
|
24
|
+
|
|
25
|
+
ledger-core:test:
|
|
26
|
+
stage: test
|
|
27
|
+
image: python:3.12
|
|
28
|
+
needs: []
|
|
29
|
+
services:
|
|
30
|
+
- name: docker:dind
|
|
31
|
+
command: ["--tls=false"]
|
|
32
|
+
variables:
|
|
33
|
+
DOCKER_HOST: tcp://docker:2375
|
|
34
|
+
DOCKER_TLS_CERTDIR: ""
|
|
35
|
+
DOCKER_DRIVER: overlay2
|
|
36
|
+
script:
|
|
37
|
+
- pip install uv
|
|
38
|
+
- cd libs/ledger-core
|
|
39
|
+
- uv sync --all-packages --all-extras --all-groups
|
|
40
|
+
- uv run pytest --cov=ledger_core tests
|
|
41
|
+
rules:
|
|
42
|
+
- if: $CI_COMMIT_MESSAGE =~ /^chore\(release\):/
|
|
43
|
+
when: never
|
|
44
|
+
- if: $CI_COMMIT_BRANCH =~ /^release\/.*/
|
|
45
|
+
when: never
|
|
46
|
+
- if: $CI_MERGE_REQUEST_SOURCE_BRANCH_NAME =~ /^release\/.*/
|
|
47
|
+
when: never
|
|
48
|
+
- if: $CI_COMMIT_TAG
|
|
49
|
+
when: never
|
|
50
|
+
- changes:
|
|
51
|
+
- libs/ledger-core/ledger_core/**/*
|
|
52
|
+
- libs/ledger-core/tests/**/*
|
|
53
|
+
- libs/ledger-core/pyproject.toml
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
## ledger-core v0.3.0
|
|
2
|
+
|
|
3
|
+
### Features
|
|
4
|
+
|
|
5
|
+
- publish lib with documentation (df7e378)
|
|
6
|
+
- rename published package from ledger-core to lib-ledger-core (00ce765)
|
|
7
|
+
|
|
8
|
+
## ledger-core v0.2.3
|
|
9
|
+
|
|
10
|
+
### Bug Fixes
|
|
11
|
+
|
|
12
|
+
- release lib-ledger-core (1b1fa2c)
|
|
13
|
+
|
|
14
|
+
## ledger-core v0.2.2
|
|
15
|
+
|
|
16
|
+
### Bug Fixes
|
|
17
|
+
|
|
18
|
+
- serialize json parameters correctly across db dialects (9978aa9)
|
|
19
|
+
|
|
20
|
+
## ledger-core v0.2.1
|
|
21
|
+
|
|
22
|
+
### Bug Fixes
|
|
23
|
+
|
|
24
|
+
- alembic.ini packing location (ca78963)
|
|
25
|
+
|
|
26
|
+
## ledger-core v0.2.0
|
|
27
|
+
|
|
28
|
+
### Features
|
|
29
|
+
|
|
30
|
+
- introduce generic ledger and event-store primitives (327760f)
|
|
31
|
+
|
|
32
|
+
### Bug Fixes
|
|
33
|
+
|
|
34
|
+
- missing framework app entry point (b7cf7b2)
|
|
35
|
+
- remove ledger-level insufficient balance constraints (9b7092f)
|
|
36
|
+
- update core adapter interfaces for TigerBeetle and SQLAlchemy (0817e49)
|
|
37
|
+
- use kurrentdbclient and aiomysql for mysql/mariadb extras (47a4658)
|
|
38
|
+
- final unified CD DAG release pipeline (5beb5ea)
|
|
39
|
+
- decouple builds from helm and secure OCI credentials (d0c9e2f)
|
|
40
|
+
- resolve package registry (ad30d6c)
|
|
41
|
+
- resolve package registry URL dynamically, optimize builds, and skip redundant prepare jobs on tags (b6c9a50)
|
|
42
|
+
|
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: lib-ledger-core
|
|
3
|
+
Version: 0.3.0
|
|
4
|
+
Summary: Generic ledger primitives and event-store ports for Business-M keepers
|
|
5
|
+
Author: Business M Contributors
|
|
6
|
+
Requires-Python: >=3.12
|
|
7
|
+
Requires-Dist: alembic>=1.19.1
|
|
8
|
+
Requires-Dist: msgspec>=0.21.1
|
|
9
|
+
Requires-Dist: sqlalchemy[asyncio]>=2.0.51
|
|
10
|
+
Provides-Extra: kurrent
|
|
11
|
+
Requires-Dist: kurrentdbclient>=1.3.3; extra == 'kurrent'
|
|
12
|
+
Provides-Extra: mariadb
|
|
13
|
+
Requires-Dist: aiomysql>=0.3.2; extra == 'mariadb'
|
|
14
|
+
Provides-Extra: mssql
|
|
15
|
+
Requires-Dist: aioodbc>=0.5.0; extra == 'mssql'
|
|
16
|
+
Provides-Extra: mysql
|
|
17
|
+
Requires-Dist: aiomysql>=0.3.2; extra == 'mysql'
|
|
18
|
+
Provides-Extra: oracle
|
|
19
|
+
Requires-Dist: oracledb>=4.0.2; extra == 'oracle'
|
|
20
|
+
Provides-Extra: postgres
|
|
21
|
+
Requires-Dist: asyncpg>=0.31.0; extra == 'postgres'
|
|
22
|
+
Provides-Extra: sqlite
|
|
23
|
+
Requires-Dist: aiosqlite>=0.22.1; extra == 'sqlite'
|
|
24
|
+
Provides-Extra: test
|
|
25
|
+
Requires-Dist: aiosqlite>=0.22.1; extra == 'test'
|
|
26
|
+
Requires-Dist: anyio>=4.14.2; extra == 'test'
|
|
27
|
+
Requires-Dist: asyncpg>=0.31.0; extra == 'test'
|
|
28
|
+
Requires-Dist: psycopg2-binary>=2.9.12; extra == 'test'
|
|
29
|
+
Requires-Dist: pytest-asyncio>=1.4.0; extra == 'test'
|
|
30
|
+
Requires-Dist: pytest-cov>=7.1.0; extra == 'test'
|
|
31
|
+
Requires-Dist: pytest>=9.1.1; extra == 'test'
|
|
32
|
+
Requires-Dist: testcontainers[postgres]>=4.15.0; extra == 'test'
|
|
33
|
+
Provides-Extra: tigerbeetle
|
|
34
|
+
Requires-Dist: tigerbeetle>=0.17.3; extra == 'tigerbeetle'
|
|
35
|
+
Description-Content-Type: text/markdown
|
|
36
|
+
|
|
37
|
+
# `lib-ledger-core`
|
|
38
|
+
|
|
39
|
+
`lib-ledger-core` is an extensible, asynchronous Python library that provides standard domain models, ports, and storage adapters for immutable double-entry accounting and event-sourced state tracking.
|
|
40
|
+
|
|
41
|
+
Built around the **Ports and Adapters (Hexagonal Architecture)** design pattern, `lib-ledger-core` decouples high-level business rules from storage drivers—allowing you to easily swap underlying databases, log engines, or external financial backends without altering domain logic.
|
|
42
|
+
|
|
43
|
+
---
|
|
44
|
+
|
|
45
|
+
## System Philosophy: CQRS & Event Sourcing
|
|
46
|
+
|
|
47
|
+
At its core, `lib-ledger-core` enforces a clean **Command-Query Responsibility Segregation (CQRS)** pattern coupled with **Event Sourcing**:
|
|
48
|
+
|
|
49
|
+
```mermaid
|
|
50
|
+
flowchart TD
|
|
51
|
+
A["Incoming Instruction"] --> B
|
|
52
|
+
|
|
53
|
+
subgraph Command ["1. COMMAND / VALIDATION SIDE (Ledger Backend)"]
|
|
54
|
+
direction TB
|
|
55
|
+
B["Accepts TransferCommand Instructions"] --> C["Validates Domain Invariants<br><i>(Account Verification, Double-Entry Rules)</i>"]
|
|
56
|
+
C --> D["Commits State Transition & Returns Result"]
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
D -->|"Valid Transfer Result"| E
|
|
60
|
+
|
|
61
|
+
subgraph Query ["2. QUERY / READ SIDE (Event Store Backend)"]
|
|
62
|
+
direction TB
|
|
63
|
+
E["Receives Validated Ledger Execution State"] --> F["Appends Immutable Events to Streams<br><i>(Enforces Optimistic Concurrency Control)</i>"]
|
|
64
|
+
F --> G["Acts as Single Source of Truth<br><i>(Powers Projections & Aggregates)</i>"]
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
style Command fill:#1e1e2e,stroke:#89b4fa,stroke-width:2px,color:#cdd6f4
|
|
68
|
+
style Query fill:#1e1e2e,stroke:#a6e3a1,stroke-width:2px,color:#cdd6f4
|
|
69
|
+
style A fill:#313244,stroke:#f5e0dc,stroke-width:1px,color:#cdd6f4
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
### Why Event Sourced Aggregates?
|
|
73
|
+
1. **Schema-less Aggregate Evolution**: Instead of modifying SQL relational table schemas whenever business rules change, domain aggregates (such as account balances, tenant summaries, or risk profiles) are projected directly by reading and replaying historical event streams. Adding new aggregate views requires no database migrations.
|
|
74
|
+
2. **Complete Auditability**: Traditional database updates overwrite previous state. The event store retains every historical event sequentially, providing a permanent, tamper-evident audit trail required for compliance and financial reconciliation.
|
|
75
|
+
3. **Optimistic Concurrency Control (OCC)**: Event appending guarantees that concurrent attempts to modify the same stream are safely rejected if the sequence version changes unexpectedly.
|
|
76
|
+
|
|
77
|
+
---
|
|
78
|
+
|
|
79
|
+
## Universal Balance Tracking: Use Cases
|
|
80
|
+
|
|
81
|
+
While designed to handle double-entry financial bookkeeping and warehouse inventory movements for ERP environments, the transfer command abstraction fits any system that moves units between two balance states:
|
|
82
|
+
|
|
83
|
+
* **Financial & Double-Entry Bookkeeping**: Manages credits and debits across general ledger accounts, revenue tracking, and accounts payable.
|
|
84
|
+
* **Stock Keeping & Warehouse Movements**: Validates and records inventory transfers between physical warehouses, storage bins, or supply chain nodes.
|
|
85
|
+
* **Crypto & Coin Wallets**: Manages balances, gas fees, and token movements between user wallets, cold storage, and hot pools.
|
|
86
|
+
* **Carbon Credits & Offsets**: Tracks issuance, transfer, and retirement of verified metric tons of carbon emissions ($tCO_2e$) between reserves and corporate accounts.
|
|
87
|
+
* **Loyalty Points & Rewards**: Handles issuance, transfers, holds, and redemptions of promotional rewards points.
|
|
88
|
+
* **Compute & API Quotas**: Controls consumption, allocation, and rate-limiting credits for multi-tenant microservices.
|
|
89
|
+
|
|
90
|
+
---
|
|
91
|
+
|
|
92
|
+
## Core Architectural Components
|
|
93
|
+
|
|
94
|
+
### 1. Abstract Ports (Interfaces)
|
|
95
|
+
* **`LedgerPort` (Validation & Command Side)**: Defines operations for evaluating transfer instructions, checking current balance snapshots, processing pending holds, and executing multi-leg compound movements.
|
|
96
|
+
* **`EventStorePort` (Audit & Read Side)**: Defines append-only operations for persisting versioned event streams and querying stream history.
|
|
97
|
+
|
|
98
|
+
### 2. Data Models
|
|
99
|
+
* **`TransferCommand`**: Immutable instruction specifying debit/credit target accounts, transaction reference, metadata, pending status, and multi-leg transfer definitions.
|
|
100
|
+
* **`Entry`**: Immutable transaction record representing an individual debit or credit line item.
|
|
101
|
+
|
|
102
|
+
### 3. Backend Adapters
|
|
103
|
+
* **`SqlAlchemyLedger` & `SqlAlchemyEventStore`**: Relational backends utilizing async SQL engines to process transfers and maintain versioned JSON event streams.
|
|
104
|
+
* **`TigerBeetleLedger`**: High-throughput ledger integration utilizing TigerBeetle for low-latency balance tracking and account flags.
|
|
105
|
+
* **`KurrentEventStore`**: Event-sourcing integration built on top of KurrentDB (EventStoreDB) utilizing `msgspec` for fast serialization.
|
|
106
|
+
|
|
107
|
+
---
|
|
108
|
+
|
|
109
|
+
## Dynamic Adapter Registry
|
|
110
|
+
|
|
111
|
+
Custom adapters can be created by implementing either the `LedgerPort` or `EventStorePort` interface. Third-party modules can register their custom drivers using Python entry-point groups (`ledger_core.ledger` and `ledger_core.event_store`). Once registered, the core library can dynamically discover and load adapters at runtime.
|
|
112
|
+
|
|
113
|
+
---
|
|
114
|
+
|
|
115
|
+
## Exception & Error Hierarchy
|
|
116
|
+
|
|
117
|
+
* **`LedgerError`**: Base exception class for all errors generated by the library.
|
|
118
|
+
* **`InsufficientBalanceError`**: Raised when a transfer command violates non-negative balance constraints.
|
|
119
|
+
* **`OccError`**: Raised when a stream version mismatch occurs during an append operation to the event store.
|
|
120
|
+
|
|
121
|
+
# Using `lib-ledger-core`
|
|
122
|
+
|
|
123
|
+
The `lib-ledger-core` library provides generic ledger primitives, event sourcing ports, dynamic adapter registration, and programmatic database migrations.
|
|
124
|
+
|
|
125
|
+
## 1. Database Migrations Programmatically
|
|
126
|
+
|
|
127
|
+
`lib-ledger-core` encapsulates its Alembic migration scripts internally. Higher-level applications like `book-keeper` use the `run_migrations` helper to initialize or upgrade the schema without maintaining duplicate SQL migration files:
|
|
128
|
+
|
|
129
|
+
```python
|
|
130
|
+
import asyncio
|
|
131
|
+
from ledger_core.migrations import run_downgrade, run_migrations
|
|
132
|
+
from sqlalchemy.ext.asyncio import create_async_engine
|
|
133
|
+
|
|
134
|
+
DATABASE_URL = "postgresql+asyncpg://postgres:postgres@localhost:5432/book_keeper"
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
async def setup_database():
|
|
138
|
+
engine = create_async_engine(DATABASE_URL, future=True)
|
|
139
|
+
|
|
140
|
+
# Run all pending ledger-core Alembic migrations
|
|
141
|
+
await run_migrations(engine)
|
|
142
|
+
|
|
143
|
+
# ... application setup ...
|
|
144
|
+
|
|
145
|
+
await engine.dispose()
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
if __name__ == "__main__":
|
|
149
|
+
asyncio.run(setup_database())
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
---
|
|
153
|
+
|
|
154
|
+
## 2. Basic Ledger & Event Store Usage
|
|
155
|
+
|
|
156
|
+
`lib-ledger-core` exposes `LedgerPort` and `EventStorePort` implementations (such as `SqlAlchemyLedger`, `TigerBeetleLedger`, `SqlAlchemyEventStore`, and `KurrentEventStore`).
|
|
157
|
+
|
|
158
|
+
```python
|
|
159
|
+
import asyncio
|
|
160
|
+
from decimal import Decimal
|
|
161
|
+
from ledger_core.adapters.sqlalchemy import SqlAlchemyEventStore, SqlAlchemyLedger
|
|
162
|
+
from ledger_core.models import TransferCommand
|
|
163
|
+
from sqlalchemy.ext.asyncio import create_async_engine
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
async def main():
|
|
167
|
+
engine = create_async_engine("sqlite+aiosqlite:///:memory:", future=True)
|
|
168
|
+
|
|
169
|
+
# Instantiate Adapters
|
|
170
|
+
ledger = SqlAlchemyLedger(engine)
|
|
171
|
+
event_store = SqlAlchemyEventStore(engine)
|
|
172
|
+
|
|
173
|
+
# Seed an account with initial funds
|
|
174
|
+
await ledger.seed_account(
|
|
175
|
+
tenant_id="tenant_1", account="CASH", amount=Decimal("1000.00")
|
|
176
|
+
)
|
|
177
|
+
|
|
178
|
+
# Execute a Transfer
|
|
179
|
+
cmd = TransferCommand(
|
|
180
|
+
tenant_id="tenant_1",
|
|
181
|
+
debit_account="EQUIPMENT",
|
|
182
|
+
credit_account="CASH",
|
|
183
|
+
amount=Decimal("250.00"),
|
|
184
|
+
reference="INV-2026-001",
|
|
185
|
+
description="Purchased office equipment",
|
|
186
|
+
)
|
|
187
|
+
transfer_id = await ledger.transfer(cmd)
|
|
188
|
+
print(f"Executed Transfer ID: {transfer_id}")
|
|
189
|
+
|
|
190
|
+
# Record Domain Event
|
|
191
|
+
await event_store.append(
|
|
192
|
+
tenant_id="tenant_1",
|
|
193
|
+
stream_id="equipment-purchases",
|
|
194
|
+
events=[
|
|
195
|
+
{
|
|
196
|
+
"type": "EquipmentPurchased",
|
|
197
|
+
"transfer_id": transfer_id,
|
|
198
|
+
"amount": "250.00",
|
|
199
|
+
}
|
|
200
|
+
],
|
|
201
|
+
expected_version=0,
|
|
202
|
+
)
|
|
203
|
+
|
|
204
|
+
# Check Balances
|
|
205
|
+
cash_bal = await ledger.get_balance("tenant_1", "CASH")
|
|
206
|
+
equipment_bal = await ledger.get_balance("tenant_1", "EQUIPMENT")
|
|
207
|
+
print(f"CASH Balance: {cash_bal}") # Outputs: 750.00
|
|
208
|
+
print(f"EQUIPMENT Balance: {equipment_bal}") # Outputs: 250.00
|
|
209
|
+
|
|
210
|
+
await ledger.close()
|
|
211
|
+
await event_store.close()
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
if __name__ == "__main__":
|
|
215
|
+
asyncio.run(main())
|
|
216
|
+
```
|
|
217
|
+
|
|
218
|
+
---
|
|
219
|
+
|
|
220
|
+
## 3. Loading Adapters via Registry
|
|
221
|
+
|
|
222
|
+
Adapters can also be loaded dynamically using the entry-point registry:
|
|
223
|
+
|
|
224
|
+
```python
|
|
225
|
+
from ledger_core import load_event_store_adapter, load_ledger_adapter
|
|
226
|
+
from sqlalchemy.ext.asyncio import create_async_engine
|
|
227
|
+
|
|
228
|
+
# Dynamically resolve factories using entry-point identifiers
|
|
229
|
+
ledger_factory = load_ledger_adapter("sqlalchemy")
|
|
230
|
+
event_store_factory = load_event_store_adapter("kurrent")
|
|
231
|
+
|
|
232
|
+
engine = create_async_engine("postgresql+asyncpg://...")
|
|
233
|
+
ledger = ledger_factory(engine)
|
|
234
|
+
event_store = event_store_factory("esdb://localhost:2113?tls=false")
|
|
235
|
+
```
|
|
236
|
+
|
|
237
|
+
---
|
|
238
|
+
|
|
239
|
+
## 4. Application Integration (`book-keeper` example)
|
|
240
|
+
|
|
241
|
+
Inside `book-keeper`, `lib-ledger-core` adapters are conditionally selected during application startup based on settings:
|
|
242
|
+
|
|
243
|
+
```python
|
|
244
|
+
from ledger_core.adapters.kurrent import KurrentEventStore
|
|
245
|
+
from ledger_core.adapters.sqlalchemy import SqlAlchemyEventStore, SqlAlchemyLedger
|
|
246
|
+
from ledger_core.adapters.tigerbeetle import TigerBeetleLedger
|
|
247
|
+
from ledger_core.interfaces import EventStorePort, LedgerPort
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def create_ledger(engine, settings) -> LedgerPort:
|
|
251
|
+
if settings.ledger_type == "postgres":
|
|
252
|
+
return SqlAlchemyLedger(engine)
|
|
253
|
+
return TigerBeetleLedger(
|
|
254
|
+
addresses=settings.tigerbeetle_addresses,
|
|
255
|
+
account_namespace=settings.account_namespace,
|
|
256
|
+
)
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def create_event_store(engine, settings) -> EventStorePort:
|
|
260
|
+
if settings.event_store_type == "postgres":
|
|
261
|
+
return SqlAlchemyEventStore(engine)
|
|
262
|
+
return KurrentEventStore(connection_string=settings.kurrent_connection_string)
|
|
263
|
+
```
|
|
264
|
+
|
|
265
|
+
# License
|
|
266
|
+
|
|
267
|
+
Apache 2.0
|
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
# `lib-ledger-core`
|
|
2
|
+
|
|
3
|
+
`lib-ledger-core` is an extensible, asynchronous Python library that provides standard domain models, ports, and storage adapters for immutable double-entry accounting and event-sourced state tracking.
|
|
4
|
+
|
|
5
|
+
Built around the **Ports and Adapters (Hexagonal Architecture)** design pattern, `lib-ledger-core` decouples high-level business rules from storage drivers—allowing you to easily swap underlying databases, log engines, or external financial backends without altering domain logic.
|
|
6
|
+
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
## System Philosophy: CQRS & Event Sourcing
|
|
10
|
+
|
|
11
|
+
At its core, `lib-ledger-core` enforces a clean **Command-Query Responsibility Segregation (CQRS)** pattern coupled with **Event Sourcing**:
|
|
12
|
+
|
|
13
|
+
```mermaid
|
|
14
|
+
flowchart TD
|
|
15
|
+
A["Incoming Instruction"] --> B
|
|
16
|
+
|
|
17
|
+
subgraph Command ["1. COMMAND / VALIDATION SIDE (Ledger Backend)"]
|
|
18
|
+
direction TB
|
|
19
|
+
B["Accepts TransferCommand Instructions"] --> C["Validates Domain Invariants<br><i>(Account Verification, Double-Entry Rules)</i>"]
|
|
20
|
+
C --> D["Commits State Transition & Returns Result"]
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
D -->|"Valid Transfer Result"| E
|
|
24
|
+
|
|
25
|
+
subgraph Query ["2. QUERY / READ SIDE (Event Store Backend)"]
|
|
26
|
+
direction TB
|
|
27
|
+
E["Receives Validated Ledger Execution State"] --> F["Appends Immutable Events to Streams<br><i>(Enforces Optimistic Concurrency Control)</i>"]
|
|
28
|
+
F --> G["Acts as Single Source of Truth<br><i>(Powers Projections & Aggregates)</i>"]
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
style Command fill:#1e1e2e,stroke:#89b4fa,stroke-width:2px,color:#cdd6f4
|
|
32
|
+
style Query fill:#1e1e2e,stroke:#a6e3a1,stroke-width:2px,color:#cdd6f4
|
|
33
|
+
style A fill:#313244,stroke:#f5e0dc,stroke-width:1px,color:#cdd6f4
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
### Why Event Sourced Aggregates?
|
|
37
|
+
1. **Schema-less Aggregate Evolution**: Instead of modifying SQL relational table schemas whenever business rules change, domain aggregates (such as account balances, tenant summaries, or risk profiles) are projected directly by reading and replaying historical event streams. Adding new aggregate views requires no database migrations.
|
|
38
|
+
2. **Complete Auditability**: Traditional database updates overwrite previous state. The event store retains every historical event sequentially, providing a permanent, tamper-evident audit trail required for compliance and financial reconciliation.
|
|
39
|
+
3. **Optimistic Concurrency Control (OCC)**: Event appending guarantees that concurrent attempts to modify the same stream are safely rejected if the sequence version changes unexpectedly.
|
|
40
|
+
|
|
41
|
+
---
|
|
42
|
+
|
|
43
|
+
## Universal Balance Tracking: Use Cases
|
|
44
|
+
|
|
45
|
+
While designed to handle double-entry financial bookkeeping and warehouse inventory movements for ERP environments, the transfer command abstraction fits any system that moves units between two balance states:
|
|
46
|
+
|
|
47
|
+
* **Financial & Double-Entry Bookkeeping**: Manages credits and debits across general ledger accounts, revenue tracking, and accounts payable.
|
|
48
|
+
* **Stock Keeping & Warehouse Movements**: Validates and records inventory transfers between physical warehouses, storage bins, or supply chain nodes.
|
|
49
|
+
* **Crypto & Coin Wallets**: Manages balances, gas fees, and token movements between user wallets, cold storage, and hot pools.
|
|
50
|
+
* **Carbon Credits & Offsets**: Tracks issuance, transfer, and retirement of verified metric tons of carbon emissions ($tCO_2e$) between reserves and corporate accounts.
|
|
51
|
+
* **Loyalty Points & Rewards**: Handles issuance, transfers, holds, and redemptions of promotional rewards points.
|
|
52
|
+
* **Compute & API Quotas**: Controls consumption, allocation, and rate-limiting credits for multi-tenant microservices.
|
|
53
|
+
|
|
54
|
+
---
|
|
55
|
+
|
|
56
|
+
## Core Architectural Components
|
|
57
|
+
|
|
58
|
+
### 1. Abstract Ports (Interfaces)
|
|
59
|
+
* **`LedgerPort` (Validation & Command Side)**: Defines operations for evaluating transfer instructions, checking current balance snapshots, processing pending holds, and executing multi-leg compound movements.
|
|
60
|
+
* **`EventStorePort` (Audit & Read Side)**: Defines append-only operations for persisting versioned event streams and querying stream history.
|
|
61
|
+
|
|
62
|
+
### 2. Data Models
|
|
63
|
+
* **`TransferCommand`**: Immutable instruction specifying debit/credit target accounts, transaction reference, metadata, pending status, and multi-leg transfer definitions.
|
|
64
|
+
* **`Entry`**: Immutable transaction record representing an individual debit or credit line item.
|
|
65
|
+
|
|
66
|
+
### 3. Backend Adapters
|
|
67
|
+
* **`SqlAlchemyLedger` & `SqlAlchemyEventStore`**: Relational backends utilizing async SQL engines to process transfers and maintain versioned JSON event streams.
|
|
68
|
+
* **`TigerBeetleLedger`**: High-throughput ledger integration utilizing TigerBeetle for low-latency balance tracking and account flags.
|
|
69
|
+
* **`KurrentEventStore`**: Event-sourcing integration built on top of KurrentDB (EventStoreDB) utilizing `msgspec` for fast serialization.
|
|
70
|
+
|
|
71
|
+
---
|
|
72
|
+
|
|
73
|
+
## Dynamic Adapter Registry
|
|
74
|
+
|
|
75
|
+
Custom adapters can be created by implementing either the `LedgerPort` or `EventStorePort` interface. Third-party modules can register their custom drivers using Python entry-point groups (`ledger_core.ledger` and `ledger_core.event_store`). Once registered, the core library can dynamically discover and load adapters at runtime.
|
|
76
|
+
|
|
77
|
+
---
|
|
78
|
+
|
|
79
|
+
## Exception & Error Hierarchy
|
|
80
|
+
|
|
81
|
+
* **`LedgerError`**: Base exception class for all errors generated by the library.
|
|
82
|
+
* **`InsufficientBalanceError`**: Raised when a transfer command violates non-negative balance constraints.
|
|
83
|
+
* **`OccError`**: Raised when a stream version mismatch occurs during an append operation to the event store.
|
|
84
|
+
|
|
85
|
+
# Using `lib-ledger-core`
|
|
86
|
+
|
|
87
|
+
The `lib-ledger-core` library provides generic ledger primitives, event sourcing ports, dynamic adapter registration, and programmatic database migrations.
|
|
88
|
+
|
|
89
|
+
## 1. Database Migrations Programmatically
|
|
90
|
+
|
|
91
|
+
`lib-ledger-core` encapsulates its Alembic migration scripts internally. Higher-level applications like `book-keeper` use the `run_migrations` helper to initialize or upgrade the schema without maintaining duplicate SQL migration files:
|
|
92
|
+
|
|
93
|
+
```python
|
|
94
|
+
import asyncio
|
|
95
|
+
from ledger_core.migrations import run_downgrade, run_migrations
|
|
96
|
+
from sqlalchemy.ext.asyncio import create_async_engine
|
|
97
|
+
|
|
98
|
+
DATABASE_URL = "postgresql+asyncpg://postgres:postgres@localhost:5432/book_keeper"
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
async def setup_database():
|
|
102
|
+
engine = create_async_engine(DATABASE_URL, future=True)
|
|
103
|
+
|
|
104
|
+
# Run all pending ledger-core Alembic migrations
|
|
105
|
+
await run_migrations(engine)
|
|
106
|
+
|
|
107
|
+
# ... application setup ...
|
|
108
|
+
|
|
109
|
+
await engine.dispose()
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
if __name__ == "__main__":
|
|
113
|
+
asyncio.run(setup_database())
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
---
|
|
117
|
+
|
|
118
|
+
## 2. Basic Ledger & Event Store Usage
|
|
119
|
+
|
|
120
|
+
`lib-ledger-core` exposes `LedgerPort` and `EventStorePort` implementations (such as `SqlAlchemyLedger`, `TigerBeetleLedger`, `SqlAlchemyEventStore`, and `KurrentEventStore`).
|
|
121
|
+
|
|
122
|
+
```python
|
|
123
|
+
import asyncio
|
|
124
|
+
from decimal import Decimal
|
|
125
|
+
from ledger_core.adapters.sqlalchemy import SqlAlchemyEventStore, SqlAlchemyLedger
|
|
126
|
+
from ledger_core.models import TransferCommand
|
|
127
|
+
from sqlalchemy.ext.asyncio import create_async_engine
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
async def main():
|
|
131
|
+
engine = create_async_engine("sqlite+aiosqlite:///:memory:", future=True)
|
|
132
|
+
|
|
133
|
+
# Instantiate Adapters
|
|
134
|
+
ledger = SqlAlchemyLedger(engine)
|
|
135
|
+
event_store = SqlAlchemyEventStore(engine)
|
|
136
|
+
|
|
137
|
+
# Seed an account with initial funds
|
|
138
|
+
await ledger.seed_account(
|
|
139
|
+
tenant_id="tenant_1", account="CASH", amount=Decimal("1000.00")
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
# Execute a Transfer
|
|
143
|
+
cmd = TransferCommand(
|
|
144
|
+
tenant_id="tenant_1",
|
|
145
|
+
debit_account="EQUIPMENT",
|
|
146
|
+
credit_account="CASH",
|
|
147
|
+
amount=Decimal("250.00"),
|
|
148
|
+
reference="INV-2026-001",
|
|
149
|
+
description="Purchased office equipment",
|
|
150
|
+
)
|
|
151
|
+
transfer_id = await ledger.transfer(cmd)
|
|
152
|
+
print(f"Executed Transfer ID: {transfer_id}")
|
|
153
|
+
|
|
154
|
+
# Record Domain Event
|
|
155
|
+
await event_store.append(
|
|
156
|
+
tenant_id="tenant_1",
|
|
157
|
+
stream_id="equipment-purchases",
|
|
158
|
+
events=[
|
|
159
|
+
{
|
|
160
|
+
"type": "EquipmentPurchased",
|
|
161
|
+
"transfer_id": transfer_id,
|
|
162
|
+
"amount": "250.00",
|
|
163
|
+
}
|
|
164
|
+
],
|
|
165
|
+
expected_version=0,
|
|
166
|
+
)
|
|
167
|
+
|
|
168
|
+
# Check Balances
|
|
169
|
+
cash_bal = await ledger.get_balance("tenant_1", "CASH")
|
|
170
|
+
equipment_bal = await ledger.get_balance("tenant_1", "EQUIPMENT")
|
|
171
|
+
print(f"CASH Balance: {cash_bal}") # Outputs: 750.00
|
|
172
|
+
print(f"EQUIPMENT Balance: {equipment_bal}") # Outputs: 250.00
|
|
173
|
+
|
|
174
|
+
await ledger.close()
|
|
175
|
+
await event_store.close()
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
if __name__ == "__main__":
|
|
179
|
+
asyncio.run(main())
|
|
180
|
+
```
|
|
181
|
+
|
|
182
|
+
---
|
|
183
|
+
|
|
184
|
+
## 3. Loading Adapters via Registry
|
|
185
|
+
|
|
186
|
+
Adapters can also be loaded dynamically using the entry-point registry:
|
|
187
|
+
|
|
188
|
+
```python
|
|
189
|
+
from ledger_core import load_event_store_adapter, load_ledger_adapter
|
|
190
|
+
from sqlalchemy.ext.asyncio import create_async_engine
|
|
191
|
+
|
|
192
|
+
# Dynamically resolve factories using entry-point identifiers
|
|
193
|
+
ledger_factory = load_ledger_adapter("sqlalchemy")
|
|
194
|
+
event_store_factory = load_event_store_adapter("kurrent")
|
|
195
|
+
|
|
196
|
+
engine = create_async_engine("postgresql+asyncpg://...")
|
|
197
|
+
ledger = ledger_factory(engine)
|
|
198
|
+
event_store = event_store_factory("esdb://localhost:2113?tls=false")
|
|
199
|
+
```
|
|
200
|
+
|
|
201
|
+
---
|
|
202
|
+
|
|
203
|
+
## 4. Application Integration (`book-keeper` example)
|
|
204
|
+
|
|
205
|
+
Inside `book-keeper`, `lib-ledger-core` adapters are conditionally selected during application startup based on settings:
|
|
206
|
+
|
|
207
|
+
```python
|
|
208
|
+
from ledger_core.adapters.kurrent import KurrentEventStore
|
|
209
|
+
from ledger_core.adapters.sqlalchemy import SqlAlchemyEventStore, SqlAlchemyLedger
|
|
210
|
+
from ledger_core.adapters.tigerbeetle import TigerBeetleLedger
|
|
211
|
+
from ledger_core.interfaces import EventStorePort, LedgerPort
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def create_ledger(engine, settings) -> LedgerPort:
|
|
215
|
+
if settings.ledger_type == "postgres":
|
|
216
|
+
return SqlAlchemyLedger(engine)
|
|
217
|
+
return TigerBeetleLedger(
|
|
218
|
+
addresses=settings.tigerbeetle_addresses,
|
|
219
|
+
account_namespace=settings.account_namespace,
|
|
220
|
+
)
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def create_event_store(engine, settings) -> EventStorePort:
|
|
224
|
+
if settings.event_store_type == "postgres":
|
|
225
|
+
return SqlAlchemyEventStore(engine)
|
|
226
|
+
return KurrentEventStore(connection_string=settings.kurrent_connection_string)
|
|
227
|
+
```
|
|
228
|
+
|
|
229
|
+
# License
|
|
230
|
+
|
|
231
|
+
Apache 2.0
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
[alembic]
|
|
2
|
+
script_location = ledger_core/migrations
|
|
3
|
+
prepend_sys_path = .
|
|
4
|
+
path_separator = os
|
|
5
|
+
version_path_separator = os
|
|
6
|
+
|
|
7
|
+
[post_write_hooks]
|
|
8
|
+
|
|
9
|
+
[loggers]
|
|
10
|
+
keys = root,sqlalchemy,alembic
|
|
11
|
+
|
|
12
|
+
[handlers]
|
|
13
|
+
keys = console
|
|
14
|
+
|
|
15
|
+
[formatters]
|
|
16
|
+
keys = generic
|
|
17
|
+
|
|
18
|
+
[logger_root]
|
|
19
|
+
level = WARN
|
|
20
|
+
handlers = console
|
|
21
|
+
qualname =
|
|
22
|
+
|
|
23
|
+
[logger_sqlalchemy]
|
|
24
|
+
level = WARN
|
|
25
|
+
handlers =
|
|
26
|
+
qualname = sqlalchemy.engine
|
|
27
|
+
|
|
28
|
+
[logger_alembic]
|
|
29
|
+
level = INFO
|
|
30
|
+
handlers =
|
|
31
|
+
qualname = alembic
|
|
32
|
+
|
|
33
|
+
[handler_console]
|
|
34
|
+
class = StreamHandler
|
|
35
|
+
args = (sys.stderr,)
|
|
36
|
+
level = NOTSET
|
|
37
|
+
formatter = generic
|
|
38
|
+
|
|
39
|
+
[formatter_generic]
|
|
40
|
+
format = %(levelname)-5.5s [%(name)s] %(message)s
|
|
41
|
+
datefmt = %H:%M:%S
|