lib-ledger-core 0.3.0__py3-none-any.whl

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.
@@ -0,0 +1,53 @@
1
+ from ledger_core.adapters import (
2
+ EVENT_STORE_ADAPTER_GROUP,
3
+ LEDGER_ADAPTER_GROUP,
4
+ list_event_store_adapters,
5
+ list_ledger_adapters,
6
+ load_event_store_adapter,
7
+ load_ledger_adapter,
8
+ )
9
+ from ledger_core.ddl import (
10
+ DDL_PLUGIN_GROUP,
11
+ DdlPlugin,
12
+ apply_ddl_plugins,
13
+ list_ddl_plugins,
14
+ load_ddl_plugins,
15
+ )
16
+ from ledger_core.exceptions import (
17
+ InsufficientBalanceError,
18
+ LedgerError,
19
+ OccError,
20
+ )
21
+ from ledger_core.interfaces import EventStorePort, LedgerPort
22
+ from ledger_core.models import Entry, TransferCommand
23
+ from ledger_core.schema import (
24
+ event_store_events,
25
+ ledger_accounts,
26
+ ledger_transfers,
27
+ metadata,
28
+ )
29
+
30
+ __all__ = [
31
+ "LedgerPort",
32
+ "EventStorePort",
33
+ "TransferCommand",
34
+ "Entry",
35
+ "LedgerError",
36
+ "InsufficientBalanceError",
37
+ "OccError",
38
+ "metadata",
39
+ "ledger_accounts",
40
+ "ledger_transfers",
41
+ "event_store_events",
42
+ "LEDGER_ADAPTER_GROUP",
43
+ "EVENT_STORE_ADAPTER_GROUP",
44
+ "load_ledger_adapter",
45
+ "load_event_store_adapter",
46
+ "list_ledger_adapters",
47
+ "list_event_store_adapters",
48
+ "DDL_PLUGIN_GROUP",
49
+ "DdlPlugin",
50
+ "load_ddl_plugins",
51
+ "apply_ddl_plugins",
52
+ "list_ddl_plugins",
53
+ ]
@@ -0,0 +1,17 @@
1
+ from ledger_core.adapters.registry import (
2
+ EVENT_STORE_ADAPTER_GROUP,
3
+ LEDGER_ADAPTER_GROUP,
4
+ list_event_store_adapters,
5
+ list_ledger_adapters,
6
+ load_event_store_adapter,
7
+ load_ledger_adapter,
8
+ )
9
+
10
+ __all__ = [
11
+ "LEDGER_ADAPTER_GROUP",
12
+ "EVENT_STORE_ADAPTER_GROUP",
13
+ "load_ledger_adapter",
14
+ "load_event_store_adapter",
15
+ "list_ledger_adapters",
16
+ "list_event_store_adapters",
17
+ ]
@@ -0,0 +1,105 @@
1
+ from __future__ import annotations
2
+
3
+ import uuid
4
+ from typing import Any
5
+
6
+ from ledger_core.exceptions import OccError
7
+
8
+
9
+ class KurrentEventStore:
10
+ """EventStorePort implementation backed by KurrentDB."""
11
+
12
+ def __init__(self, connection_string: str) -> None:
13
+ self.connection_string = connection_string
14
+ self._client: Any | None = None
15
+
16
+ @property
17
+ def client(self) -> Any:
18
+ if self._client is None:
19
+ from kurrentdbclient import AsyncKurrentDBClient
20
+
21
+ self._client = AsyncKurrentDBClient(uri=self.connection_string)
22
+ return self._client
23
+
24
+ async def close(self) -> None:
25
+ if self._client is not None:
26
+ await self._client.close()
27
+ self._client = None
28
+
29
+ async def ping(self) -> bool:
30
+ try:
31
+ await self.client.get_current_version(stream_name="__ping__")
32
+ return True
33
+ except Exception:
34
+ return False
35
+
36
+ def _stream_name(self, tenant_id: str, stream_id: str) -> str:
37
+ return f"{tenant_id}-{stream_id}"
38
+
39
+ async def _current_version(self, tenant_id: str, stream_id: str) -> int:
40
+ from kurrentdbclient import StreamState
41
+
42
+ version = await self.client.get_current_version(
43
+ stream_name=self._stream_name(tenant_id, stream_id)
44
+ )
45
+ if version is StreamState.NO_STREAM:
46
+ return 0
47
+ return int(version) + 1
48
+
49
+ async def append(
50
+ self,
51
+ tenant_id: str,
52
+ stream_id: str,
53
+ events: list[dict[str, Any]],
54
+ expected_version: int | None = None,
55
+ ) -> None:
56
+ import msgspec
57
+ from kurrentdbclient import NewEvent, StreamState
58
+ from kurrentdbclient.exceptions import WrongCurrentVersionError
59
+
60
+ full_stream = self._stream_name(tenant_id, stream_id)
61
+ new_events = []
62
+ for event in events:
63
+ data = dict(event)
64
+ event_type = data.pop("type", "Unknown")
65
+ new_events.append(
66
+ NewEvent(
67
+ id=uuid.uuid4(),
68
+ type=event_type,
69
+ data=msgspec.json.encode(data),
70
+ )
71
+ )
72
+
73
+ current_version: int | StreamState = StreamState.ANY
74
+ if expected_version is not None:
75
+ if expected_version == 0:
76
+ current_version = StreamState.NO_STREAM
77
+ else:
78
+ current_version = expected_version - 1
79
+
80
+ try:
81
+ await self.client.append_to_stream(
82
+ stream_name=full_stream,
83
+ events=new_events,
84
+ current_version=current_version,
85
+ )
86
+ except WrongCurrentVersionError as e:
87
+ actual = await self._current_version(tenant_id, stream_id)
88
+ raise OccError(stream_id, expected_version, actual) from e
89
+
90
+ async def read(self, tenant_id: str, stream_id: str) -> list[dict[str, Any]]:
91
+ import msgspec
92
+ from kurrentdbclient.exceptions import NotFoundError
93
+
94
+ full_stream = self._stream_name(tenant_id, stream_id)
95
+ try:
96
+ events: list[dict[str, Any]] = []
97
+ async for recorded in await self.client.read_stream(
98
+ stream_name=full_stream
99
+ ):
100
+ data = msgspec.json.decode(recorded.data)
101
+ data["type"] = recorded.type
102
+ events.append(data)
103
+ return events
104
+ except NotFoundError:
105
+ return []
@@ -0,0 +1,38 @@
1
+ from __future__ import annotations
2
+
3
+ import importlib.metadata as metadata
4
+ from collections.abc import Callable
5
+ from typing import Any
6
+
7
+ from ledger_core.interfaces import EventStorePort, LedgerPort
8
+
9
+ LEDGER_ADAPTER_GROUP = "ledger_core.ledger"
10
+ EVENT_STORE_ADAPTER_GROUP = "ledger_core.event_store"
11
+
12
+
13
+ def _load_factory(group: str, name: str) -> Callable[..., Any]:
14
+ eps = metadata.entry_points(group=group)
15
+ for ep in eps:
16
+ if ep.name == name:
17
+ return ep.load() # type: ignore[no-any-return]
18
+ raise KeyError(f"No adapter named {name!r} in group {group!r}")
19
+
20
+
21
+ def load_ledger_adapter(name: str) -> Callable[..., LedgerPort]:
22
+ """Load a ledger adapter factory by entry-point name."""
23
+ return _load_factory(LEDGER_ADAPTER_GROUP, name)
24
+
25
+
26
+ def load_event_store_adapter(name: str) -> Callable[..., EventStorePort]:
27
+ """Load an event-store adapter factory by entry-point name."""
28
+ return _load_factory(EVENT_STORE_ADAPTER_GROUP, name)
29
+
30
+
31
+ def list_ledger_adapters() -> list[str]:
32
+ """Return names of all registered ledger adapters."""
33
+ return [ep.name for ep in metadata.entry_points(group=LEDGER_ADAPTER_GROUP)]
34
+
35
+
36
+ def list_event_store_adapters() -> list[str]:
37
+ """Return names of all registered event-store adapters."""
38
+ return [ep.name for ep in metadata.entry_points(group=EVENT_STORE_ADAPTER_GROUP)]
@@ -0,0 +1,355 @@
1
+ from __future__ import annotations
2
+
3
+ import uuid
4
+ from datetime import UTC
5
+ from decimal import Decimal
6
+ from typing import Any
7
+
8
+ import sqlalchemy as sa
9
+ from sqlalchemy.ext.asyncio import AsyncConnection, AsyncEngine, create_async_engine
10
+
11
+ from ledger_core.exceptions import LedgerError, OccError
12
+ from ledger_core.models import Entry, TransferCommand
13
+ from ledger_core.schema import event_store_events, ledger_accounts, ledger_transfers
14
+
15
+
16
+ class SqlAlchemyLedger:
17
+ """LedgerPort implementation using SQLAlchemy Core async.
18
+
19
+ Balance convention: balance(account) = credits_into(account) - debits_from(account).
20
+ A transfer credits the source account and debits the destination account.
21
+ The source account (credit_account) must have a non-negative balance after
22
+ the transfer. This matches the stock-keeper semantics where the source
23
+ warehouse must have enough stock.
24
+ """
25
+
26
+ def __init__(self, engine: AsyncEngine) -> None:
27
+ self._engine = engine
28
+
29
+ @classmethod
30
+ def from_url(cls, url: str | sa.URL, **kwargs: Any) -> SqlAlchemyLedger:
31
+ return cls(create_async_engine(url, **kwargs))
32
+
33
+ async def ping(self) -> bool:
34
+ async with self._engine.connect() as conn:
35
+ await conn.execute(sa.text("SELECT 1"))
36
+ return True
37
+
38
+ async def close(self) -> None:
39
+ await self._engine.dispose()
40
+
41
+ async def seed_account(self, tenant_id: str, account: str, amount: Decimal) -> None:
42
+ """Create balance for an account without enforcing source balance.
43
+
44
+ Useful in tests and for bootstrapping opening balances.
45
+ """
46
+ async with self._engine.begin() as conn:
47
+ await self._ensure_account(conn, tenant_id, account)
48
+ await conn.execute(
49
+ sa.insert(ledger_transfers).values(
50
+ id=uuid.uuid4(),
51
+ tenant_id=tenant_id,
52
+ debit_account=account,
53
+ credit_account="EXTERNAL",
54
+ amount=amount,
55
+ reference="seed",
56
+ status="committed",
57
+ )
58
+ )
59
+
60
+ async def _ensure_account(
61
+ self,
62
+ conn: AsyncConnection,
63
+ tenant_id: str,
64
+ account_code: str,
65
+ ) -> None:
66
+ stmt = sa.select(ledger_accounts.c.id).where(
67
+ ledger_accounts.c.tenant_id == tenant_id,
68
+ ledger_accounts.c.account_code == account_code,
69
+ )
70
+ result = await conn.execute(stmt)
71
+ if result.scalar_one_or_none() is None:
72
+ await conn.execute(
73
+ sa.insert(ledger_accounts).values(
74
+ id=uuid.uuid4(),
75
+ tenant_id=tenant_id,
76
+ account_code=account_code,
77
+ )
78
+ )
79
+
80
+ async def _balance_at(
81
+ self,
82
+ conn: AsyncConnection,
83
+ tenant_id: str,
84
+ account: str,
85
+ exclude_transfer_ids: list[uuid.UUID] | None = None,
86
+ ) -> Decimal:
87
+ """Balance = debits_into_account - credits_from_account."""
88
+ debit_sum = sa.select(
89
+ sa.func.coalesce(sa.func.sum(ledger_transfers.c.amount), Decimal("0"))
90
+ ).where(
91
+ ledger_transfers.c.tenant_id == tenant_id,
92
+ ledger_transfers.c.debit_account == account,
93
+ ledger_transfers.c.status == "committed",
94
+ )
95
+ credit_sum = sa.select(
96
+ sa.func.coalesce(sa.func.sum(ledger_transfers.c.amount), Decimal("0"))
97
+ ).where(
98
+ ledger_transfers.c.tenant_id == tenant_id,
99
+ ledger_transfers.c.credit_account == account,
100
+ ledger_transfers.c.status == "committed",
101
+ )
102
+ if exclude_transfer_ids:
103
+ debit_sum = debit_sum.where(
104
+ ledger_transfers.c.id.notin_(exclude_transfer_ids)
105
+ )
106
+ credit_sum = credit_sum.where(
107
+ ledger_transfers.c.id.notin_(exclude_transfer_ids)
108
+ )
109
+
110
+ debit = Decimal((await conn.execute(debit_sum)).scalar_one())
111
+ credit = Decimal((await conn.execute(credit_sum)).scalar_one())
112
+ return debit - credit
113
+
114
+ async def transfer(self, cmd: TransferCommand) -> str:
115
+ if cmd.legs:
116
+ return await self._compound_transfer(cmd)
117
+
118
+ transfer_id = uuid.uuid4()
119
+ status = "pending" if cmd.is_pending else "committed"
120
+ async with self._engine.begin() as conn:
121
+ await self._ensure_account(conn, cmd.tenant_id, cmd.debit_account)
122
+ await self._ensure_account(conn, cmd.tenant_id, cmd.credit_account)
123
+
124
+ if status == "committed":
125
+ # Removed InsufficientBalanceError check;
126
+ # ERP accounts naturally have credit balances
127
+ pass
128
+
129
+ await conn.execute(
130
+ sa.insert(ledger_transfers).values(
131
+ id=transfer_id,
132
+ tenant_id=cmd.tenant_id,
133
+ debit_account=cmd.debit_account,
134
+ credit_account=cmd.credit_account,
135
+ amount=cmd.amount,
136
+ reference=cmd.reference,
137
+ description=cmd.description,
138
+ status=status,
139
+ )
140
+ )
141
+
142
+ return str(transfer_id)
143
+
144
+ async def _compound_transfer(self, cmd: TransferCommand) -> str:
145
+ group_id = uuid.uuid4()
146
+ rows = []
147
+ for leg in cmd.legs:
148
+ rows.append(
149
+ {
150
+ "id": uuid.uuid4(),
151
+ "tenant_id": cmd.tenant_id,
152
+ "debit_account": leg["debit_account"],
153
+ "credit_account": leg["credit_account"],
154
+ "amount": Decimal(leg["amount"]),
155
+ "reference": cmd.reference,
156
+ "description": cmd.description,
157
+ "status": "committed",
158
+ }
159
+ )
160
+
161
+ async with self._engine.begin() as conn:
162
+ for row in rows:
163
+ await self._ensure_account(conn, cmd.tenant_id, row["debit_account"])
164
+ await self._ensure_account(conn, cmd.tenant_id, row["credit_account"])
165
+
166
+ await conn.execute(sa.insert(ledger_transfers), rows)
167
+
168
+ return str(group_id)
169
+
170
+ async def get_balance(self, tenant_id: str, account: str) -> Decimal:
171
+ async with self._engine.begin() as conn:
172
+ return await self._balance_at(conn, tenant_id, account)
173
+
174
+ async def get_history(
175
+ self, tenant_id: str, account: str, limit: int = 100
176
+ ) -> list[Entry]:
177
+ stmt = (
178
+ sa.select(
179
+ ledger_transfers.c.id,
180
+ ledger_transfers.c.created_at,
181
+ ledger_transfers.c.debit_account,
182
+ ledger_transfers.c.credit_account,
183
+ ledger_transfers.c.amount,
184
+ ledger_transfers.c.reference,
185
+ ledger_transfers.c.status,
186
+ )
187
+ .where(
188
+ ledger_transfers.c.tenant_id == tenant_id,
189
+ ledger_transfers.c.status != "voided",
190
+ ledger_transfers.c.reference != "seed",
191
+ sa.or_(
192
+ ledger_transfers.c.debit_account == account,
193
+ ledger_transfers.c.credit_account == account,
194
+ ),
195
+ )
196
+ .order_by(
197
+ ledger_transfers.c.created_at.desc(),
198
+ ledger_transfers.c.id.desc(),
199
+ )
200
+ .limit(limit)
201
+ )
202
+ async with self._engine.connect() as conn:
203
+ result = await conn.execute(stmt)
204
+ rows = result.mappings().all()
205
+
206
+ entries: list[Entry] = []
207
+ for row in rows:
208
+ ts = row["created_at"]
209
+ if ts.tzinfo is None:
210
+ ts = ts.replace(tzinfo=UTC)
211
+ if row["debit_account"] == account:
212
+ entries.append(
213
+ Entry(
214
+ id=str(row["id"]),
215
+ timestamp=ts,
216
+ account=account,
217
+ amount=Decimal(row["amount"]),
218
+ type="debit",
219
+ reference=row["reference"] or "",
220
+ )
221
+ )
222
+ else:
223
+ entries.append(
224
+ Entry(
225
+ id=str(row["id"]),
226
+ timestamp=ts,
227
+ account=account,
228
+ amount=Decimal(row["amount"]),
229
+ type="credit",
230
+ reference=row["reference"] or "",
231
+ )
232
+ )
233
+ return entries
234
+
235
+ async def post_pending(self, tenant_id: str, pending_id: str) -> str:
236
+ transfer_id = uuid.UUID(pending_id)
237
+ async with self._engine.begin() as conn:
238
+ row = (
239
+ (
240
+ await conn.execute(
241
+ sa.select(
242
+ ledger_transfers.c.credit_account,
243
+ ledger_transfers.c.amount,
244
+ ).where(
245
+ ledger_transfers.c.id == transfer_id,
246
+ ledger_transfers.c.tenant_id == tenant_id,
247
+ )
248
+ )
249
+ )
250
+ .mappings()
251
+ .one_or_none()
252
+ )
253
+
254
+ if row is None:
255
+ raise LedgerError(f"Pending transfer {pending_id} not found")
256
+
257
+ # Removed InsufficientBalanceError check
258
+
259
+ await conn.execute(
260
+ sa.update(ledger_transfers)
261
+ .where(ledger_transfers.c.id == transfer_id)
262
+ .values(status="committed")
263
+ )
264
+ return pending_id
265
+
266
+ async def void_pending(self, tenant_id: str, pending_id: str) -> str:
267
+ transfer_id = uuid.UUID(pending_id)
268
+ async with self._engine.begin() as conn:
269
+ result = await conn.execute(
270
+ sa.update(ledger_transfers)
271
+ .where(
272
+ ledger_transfers.c.id == transfer_id,
273
+ ledger_transfers.c.tenant_id == tenant_id,
274
+ )
275
+ .values(status="voided")
276
+ )
277
+ if result.rowcount == 0:
278
+ raise LedgerError(f"Pending transfer {pending_id} not found")
279
+ return pending_id
280
+
281
+
282
+ class SqlAlchemyEventStore:
283
+ """EventStorePort implementation using SQLAlchemy Core async."""
284
+
285
+ def __init__(self, engine: AsyncEngine) -> None:
286
+ self._engine = engine
287
+
288
+ @classmethod
289
+ def from_url(cls, url: str | sa.URL, **kwargs: Any) -> SqlAlchemyEventStore:
290
+ return cls(create_async_engine(url, **kwargs))
291
+
292
+ async def ping(self) -> bool:
293
+ async with self._engine.connect() as conn:
294
+ await conn.execute(sa.text("SELECT 1"))
295
+ return True
296
+
297
+ async def close(self) -> None:
298
+ await self._engine.dispose()
299
+
300
+ async def append(
301
+ self,
302
+ tenant_id: str,
303
+ stream_id: str,
304
+ events: list[dict[str, Any]],
305
+ expected_version: int | None = None,
306
+ ) -> None:
307
+ async with self._engine.begin() as conn:
308
+ max_version_stmt = sa.select(
309
+ sa.func.coalesce(sa.func.max(event_store_events.c.version), 0)
310
+ ).where(
311
+ event_store_events.c.tenant_id == tenant_id,
312
+ event_store_events.c.stream_id == stream_id,
313
+ )
314
+ current_version = (await conn.execute(max_version_stmt)).scalar_one()
315
+
316
+ if expected_version is not None and current_version != expected_version:
317
+ raise OccError(stream_id, expected_version, current_version)
318
+
319
+ def _sanitize_for_json(obj: Any) -> Any:
320
+ if isinstance(obj, dict):
321
+ return {k: _sanitize_for_json(v) for k, v in obj.items()}
322
+ elif isinstance(obj, (list, tuple)):
323
+ return [_sanitize_for_json(v) for v in obj]
324
+ elif isinstance(obj, Decimal):
325
+ return str(obj)
326
+ elif isinstance(obj, uuid.UUID):
327
+ return str(obj)
328
+ return obj
329
+
330
+ rows = []
331
+ for idx, event in enumerate(events, start=1):
332
+ rows.append(
333
+ {
334
+ "id": uuid.uuid4(),
335
+ "tenant_id": tenant_id,
336
+ "stream_id": stream_id,
337
+ "version": current_version + idx,
338
+ "event_type": event.get("type"),
339
+ "data": _sanitize_for_json(dict(event)),
340
+ }
341
+ )
342
+ await conn.execute(sa.insert(event_store_events), rows)
343
+
344
+ async def read(self, tenant_id: str, stream_id: str) -> list[dict[str, Any]]:
345
+ stmt = (
346
+ sa.select(event_store_events.c.data)
347
+ .where(
348
+ event_store_events.c.tenant_id == tenant_id,
349
+ event_store_events.c.stream_id == stream_id,
350
+ )
351
+ .order_by(event_store_events.c.version.asc())
352
+ )
353
+ async with self._engine.connect() as conn:
354
+ result = await conn.execute(stmt)
355
+ return [row[0] for row in result.all()]