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.
- ledger_core/__init__.py +53 -0
- ledger_core/adapters/__init__.py +17 -0
- ledger_core/adapters/kurrent.py +105 -0
- ledger_core/adapters/registry.py +38 -0
- ledger_core/adapters/sqlalchemy.py +355 -0
- ledger_core/adapters/tigerbeetle.py +380 -0
- ledger_core/ddl/__init__.py +15 -0
- ledger_core/ddl/generic.py +59 -0
- ledger_core/ddl/plugin.py +19 -0
- ledger_core/ddl/postgres.py +58 -0
- ledger_core/ddl/registry.py +37 -0
- ledger_core/exceptions.py +31 -0
- ledger_core/interfaces.py +32 -0
- ledger_core/migrations/__init__.py +39 -0
- ledger_core/migrations/alembic.ini +41 -0
- ledger_core/migrations/env.py +72 -0
- ledger_core/migrations/script.py.mako +26 -0
- ledger_core/migrations/versions/001_baseline.py +40 -0
- ledger_core/models.py +28 -0
- ledger_core/py.typed +0 -0
- ledger_core/schema.py +40 -0
- lib_ledger_core-0.3.0.dist-info/METADATA +267 -0
- lib_ledger_core-0.3.0.dist-info/RECORD +25 -0
- lib_ledger_core-0.3.0.dist-info/WHEEL +4 -0
- lib_ledger_core-0.3.0.dist-info/entry_points.txt +14 -0
ledger_core/__init__.py
ADDED
|
@@ -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()]
|