simplebooks 0.1.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.
- simplebooks/__init__.py +51 -0
- simplebooks/asyncql/Account.py +91 -0
- simplebooks/asyncql/AccountType.py +16 -0
- simplebooks/asyncql/Currency.py +67 -0
- simplebooks/asyncql/Customer.py +23 -0
- simplebooks/asyncql/Entry.py +86 -0
- simplebooks/asyncql/EntryType.py +7 -0
- simplebooks/asyncql/Identity.py +26 -0
- simplebooks/asyncql/Ledger.py +54 -0
- simplebooks/asyncql/Transaction.py +121 -0
- simplebooks/asyncql/Vendor.py +23 -0
- simplebooks/asyncql/__init__.py +53 -0
- simplebooks/models/Account.py +88 -0
- simplebooks/models/AccountType.py +16 -0
- simplebooks/models/Currency.py +67 -0
- simplebooks/models/Customer.py +23 -0
- simplebooks/models/Entry.py +75 -0
- simplebooks/models/EntryType.py +7 -0
- simplebooks/models/Identity.py +26 -0
- simplebooks/models/Ledger.py +64 -0
- simplebooks/models/Transaction.py +121 -0
- simplebooks/models/Vendor.py +23 -0
- simplebooks/models/__init__.py +32 -0
- simplebooks-0.1.0.dist-info/METADATA +187 -0
- simplebooks-0.1.0.dist-info/RECORD +26 -0
- simplebooks-0.1.0.dist-info/WHEEL +4 -0
simplebooks/__init__.py
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
from .models import (
|
|
2
|
+
Account,
|
|
3
|
+
AccountType,
|
|
4
|
+
Currency,
|
|
5
|
+
Customer,
|
|
6
|
+
Entry,
|
|
7
|
+
EntryType,
|
|
8
|
+
Identity,
|
|
9
|
+
Ledger,
|
|
10
|
+
Transaction,
|
|
11
|
+
Vendor,
|
|
12
|
+
)
|
|
13
|
+
import sqloquent.tools
|
|
14
|
+
|
|
15
|
+
__version__ = '0.1.0'
|
|
16
|
+
|
|
17
|
+
def set_connection_info(db_file_path: str):
|
|
18
|
+
"""Set the connection info for all models to use the specified
|
|
19
|
+
sqlite3 database file path.
|
|
20
|
+
"""
|
|
21
|
+
Account.connection_info = db_file_path
|
|
22
|
+
Currency.connection_info = db_file_path
|
|
23
|
+
Customer.connection_info = db_file_path
|
|
24
|
+
Entry.connection_info = db_file_path
|
|
25
|
+
Identity.connection_info = db_file_path
|
|
26
|
+
Ledger.connection_info = db_file_path
|
|
27
|
+
Transaction.connection_info = db_file_path
|
|
28
|
+
Vendor.connection_info = db_file_path
|
|
29
|
+
|
|
30
|
+
def publish_migrations(migration_folder_path: str):
|
|
31
|
+
"""Writes migration files for the models."""
|
|
32
|
+
sqloquent.tools.publish_migrations(migration_folder_path)
|
|
33
|
+
models = [
|
|
34
|
+
Account,
|
|
35
|
+
Currency,
|
|
36
|
+
Customer,
|
|
37
|
+
Entry,
|
|
38
|
+
Identity,
|
|
39
|
+
Ledger,
|
|
40
|
+
Transaction,
|
|
41
|
+
Vendor,
|
|
42
|
+
]
|
|
43
|
+
for model in models:
|
|
44
|
+
name = model.__name__
|
|
45
|
+
m = sqloquent.tools.make_migration_from_model(model)
|
|
46
|
+
with open(f'{migration_folder_path}/create_{name}.py', 'w') as f:
|
|
47
|
+
f.write(m)
|
|
48
|
+
|
|
49
|
+
def automigrate(migration_folder_path: str, db_file_path: str):
|
|
50
|
+
"""Executes the sqloquent automigrate tool."""
|
|
51
|
+
sqloquent.tools.automigrate(migration_folder_path, db_file_path)
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
from sqloquent.asyncql import (
|
|
3
|
+
AsyncSqlModel, AsyncRelatedModel, AsyncRelatedCollection,
|
|
4
|
+
)
|
|
5
|
+
from .AccountType import AccountType
|
|
6
|
+
from .Entry import Entry
|
|
7
|
+
from .EntryType import EntryType
|
|
8
|
+
import packify
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class Account(AsyncSqlModel):
|
|
12
|
+
connection_info: str = ''
|
|
13
|
+
table: str = 'accounts'
|
|
14
|
+
id_column: str = 'id'
|
|
15
|
+
columns: tuple[str] = (
|
|
16
|
+
'id', 'name', 'type', 'ledger_id', 'parent_id', 'code',
|
|
17
|
+
'category', 'details'
|
|
18
|
+
)
|
|
19
|
+
id: str
|
|
20
|
+
name: str
|
|
21
|
+
type: str
|
|
22
|
+
ledger_id: str
|
|
23
|
+
parent_id: str
|
|
24
|
+
code: str|None
|
|
25
|
+
category: str|None
|
|
26
|
+
details: bytes|None
|
|
27
|
+
ledger: AsyncRelatedModel
|
|
28
|
+
parent: AsyncRelatedModel
|
|
29
|
+
children: AsyncRelatedCollection
|
|
30
|
+
entries: AsyncRelatedCollection
|
|
31
|
+
|
|
32
|
+
# override automatic property
|
|
33
|
+
@property
|
|
34
|
+
def type(self) -> AccountType:
|
|
35
|
+
"""The AccountType of the Account."""
|
|
36
|
+
return AccountType(self.data['type'])
|
|
37
|
+
@type.setter
|
|
38
|
+
def type(self, val: AccountType):
|
|
39
|
+
if type(val) is AccountType:
|
|
40
|
+
self.data['type'] = val.value
|
|
41
|
+
|
|
42
|
+
# override automatic property
|
|
43
|
+
@property
|
|
44
|
+
def details(self) -> packify.SerializableType:
|
|
45
|
+
"""A packify.SerializableType stored in the database as a blob."""
|
|
46
|
+
return packify.unpack(self.data.get('details', None) or b'n\x00\x00\x00\x00')
|
|
47
|
+
@details.setter
|
|
48
|
+
def details(self, val: packify.SerializableType):
|
|
49
|
+
if isinstance(val, packify.SerializableType):
|
|
50
|
+
self.data['details'] = packify.pack(val)
|
|
51
|
+
|
|
52
|
+
@staticmethod
|
|
53
|
+
def _encode(data: dict|None) -> dict|None:
|
|
54
|
+
if type(data) is not dict:
|
|
55
|
+
return data
|
|
56
|
+
if type(data.get('type', None)) is AccountType:
|
|
57
|
+
data['type'] = data['type'].value
|
|
58
|
+
return data
|
|
59
|
+
|
|
60
|
+
@classmethod
|
|
61
|
+
async def insert(cls, data: dict) -> Account | None:
|
|
62
|
+
"""Ensure data is encoded before inserting."""
|
|
63
|
+
result = await super().insert(cls._encode(data))
|
|
64
|
+
return result
|
|
65
|
+
|
|
66
|
+
async def balance(self, include_sub_accounts: bool = True) -> int:
|
|
67
|
+
"""Tally all entries for this account. Includes the balances of
|
|
68
|
+
all sub-accounts if include_sub_accounts is True.
|
|
69
|
+
"""
|
|
70
|
+
totals = {
|
|
71
|
+
EntryType.CREDIT: 0,
|
|
72
|
+
EntryType.DEBIT: 0,
|
|
73
|
+
'subaccounts': 0,
|
|
74
|
+
}
|
|
75
|
+
async for entries in self.entries().query().chunk(500):
|
|
76
|
+
entry: Entry
|
|
77
|
+
for entry in entries:
|
|
78
|
+
totals[entry.type] += entry.amount
|
|
79
|
+
|
|
80
|
+
if include_sub_accounts:
|
|
81
|
+
for acct in self.children:
|
|
82
|
+
acct: Account
|
|
83
|
+
totals['subaccounts'] += await acct.balance(include_sub_accounts=True)
|
|
84
|
+
|
|
85
|
+
if self.type in (
|
|
86
|
+
AccountType.ASSET, AccountType.DEBIT_BALANCE,
|
|
87
|
+
AccountType.CONTRA_LIABILITY, AccountType.CONTRA_EQUITY,
|
|
88
|
+
):
|
|
89
|
+
return totals[EntryType.DEBIT] - totals[EntryType.CREDIT] + totals['subaccounts']
|
|
90
|
+
|
|
91
|
+
return totals[EntryType.CREDIT] - totals[EntryType.DEBIT] + totals['subaccounts']
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
from enum import Enum
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class AccountType(Enum):
|
|
5
|
+
"""Enum of valid Account types: DEBIT_BALANCE, ASSET, CONTRA_ASSET,
|
|
6
|
+
CREDIT_BALANCE, LIABILITY, EQUITY, CONTRA_LIABILITY,
|
|
7
|
+
CONTRA_EQUITY.
|
|
8
|
+
"""
|
|
9
|
+
DEBIT_BALANCE = 'd'
|
|
10
|
+
ASSET = 'a'
|
|
11
|
+
CONTRA_ASSET = '-a'
|
|
12
|
+
CREDIT_BALANCE = 'c'
|
|
13
|
+
LIABILITY = 'l'
|
|
14
|
+
EQUITY = 'e'
|
|
15
|
+
CONTRA_LIABILITY = '-l'
|
|
16
|
+
CONTRA_EQUITY = '-e'
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
from decimal import Decimal
|
|
2
|
+
from sqloquent.asyncql import AsyncSqlModel
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class Currency(AsyncSqlModel):
|
|
6
|
+
connection_info: str = ''
|
|
7
|
+
table: str = 'currencies'
|
|
8
|
+
id_column: str = 'id'
|
|
9
|
+
columns: tuple[str] = (
|
|
10
|
+
'id', 'name', 'prefix_symbol', 'postfix_symbol',
|
|
11
|
+
'fx_symbol', 'unit_divisions', 'base', 'details'
|
|
12
|
+
)
|
|
13
|
+
id: str
|
|
14
|
+
name: str
|
|
15
|
+
prefix_symbol: str|None
|
|
16
|
+
postfix_symbol: str|None
|
|
17
|
+
fx_symbol: str|None
|
|
18
|
+
unit_divisions: int
|
|
19
|
+
base: int|None
|
|
20
|
+
details: str|None
|
|
21
|
+
|
|
22
|
+
def to_decimal(self, amount: int) -> Decimal:
|
|
23
|
+
"""Convert the amount into a Decimal representation."""
|
|
24
|
+
base = self.base or 10
|
|
25
|
+
return Decimal(amount) / Decimal(base**self.unit_divisions)
|
|
26
|
+
|
|
27
|
+
def get_units(self, amount: int) -> tuple[int,]:
|
|
28
|
+
"""Get the full units and subunits. The number of subunit
|
|
29
|
+
figures will be equal to unit_divisions; e.g. if base=10
|
|
30
|
+
and unit_divisions=2, get_units(200) will return (2, 0, 0);
|
|
31
|
+
if base=60 and unit_divisions=2, get_units(200) will return
|
|
32
|
+
(0, 3, 20).
|
|
33
|
+
"""
|
|
34
|
+
def get_subunits(amount, base, unit_divisions):
|
|
35
|
+
units_and_change = divmod(amount, base ** unit_divisions)
|
|
36
|
+
if unit_divisions > 1:
|
|
37
|
+
units_and_change = (units_and_change[0], *get_subunits(units_and_change[1], base, unit_divisions-1))
|
|
38
|
+
return units_and_change
|
|
39
|
+
base = self.base or 10
|
|
40
|
+
unit_divisions = self.unit_divisions
|
|
41
|
+
return get_subunits(amount, base, unit_divisions)
|
|
42
|
+
|
|
43
|
+
def format(self, amount: int, *, decimal_places: int = 2,
|
|
44
|
+
use_prefix: bool = True, use_postfix: bool = False,
|
|
45
|
+
use_fx_symbol: bool = False) -> str:
|
|
46
|
+
"""Format an amount using the correct number of decimal_places."""
|
|
47
|
+
amount: str = str(self.to_decimal(amount))
|
|
48
|
+
if '.' not in amount:
|
|
49
|
+
amount += '.'
|
|
50
|
+
digits = amount.split('.')[1]
|
|
51
|
+
|
|
52
|
+
while len(digits) < decimal_places:
|
|
53
|
+
digits += '0'
|
|
54
|
+
|
|
55
|
+
digits = digits[:decimal_places]
|
|
56
|
+
amount = f"{amount.split('.')[0]}.{digits}"
|
|
57
|
+
|
|
58
|
+
if self.postfix_symbol and use_postfix:
|
|
59
|
+
return f"{amount}{self.postfix_symbol}"
|
|
60
|
+
|
|
61
|
+
if self.fx_symbol and use_fx_symbol:
|
|
62
|
+
return f"{amount} {self.fx_symbol}"
|
|
63
|
+
|
|
64
|
+
if self.prefix_symbol and use_prefix:
|
|
65
|
+
return f"{self.prefix_symbol}{amount}"
|
|
66
|
+
|
|
67
|
+
return amount
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
from sqloquent.asyncql import AsyncSqlModel
|
|
2
|
+
import packify
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class Customer(AsyncSqlModel):
|
|
6
|
+
connection_info: str = ''
|
|
7
|
+
table: str = 'customers'
|
|
8
|
+
id_column: str = 'id'
|
|
9
|
+
columns: tuple[str] = ('id', 'name', 'code', 'details')
|
|
10
|
+
id: str
|
|
11
|
+
name: str
|
|
12
|
+
code: str|None
|
|
13
|
+
details: str|None
|
|
14
|
+
|
|
15
|
+
# override automatic property
|
|
16
|
+
@property
|
|
17
|
+
def details(self) -> packify.SerializableType:
|
|
18
|
+
"""A packify.SerializableType stored in the database as a blob."""
|
|
19
|
+
return packify.unpack(self.data.get('details', None) or b'n\x00\x00\x00\x00')
|
|
20
|
+
@details.setter
|
|
21
|
+
def details(self, val: packify.SerializableType):
|
|
22
|
+
if isinstance(val, packify.SerializableType):
|
|
23
|
+
self.data['details'] = packify.pack(val)
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
from sqloquent.asyncql import (
|
|
3
|
+
AsyncSqlModel, AsyncRelatedModel, AsyncRelatedCollection, AsyncQueryBuilderProtocol
|
|
4
|
+
)
|
|
5
|
+
from .EntryType import EntryType
|
|
6
|
+
import packify
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class Entry(AsyncSqlModel):
|
|
10
|
+
connection_info: str = ''
|
|
11
|
+
table: str = 'entries'
|
|
12
|
+
id_column: str = 'id'
|
|
13
|
+
columns: tuple[str] = ('id', 'type', 'amount', 'nonce', 'account_id', 'details')
|
|
14
|
+
id: str
|
|
15
|
+
type: str
|
|
16
|
+
amount: int
|
|
17
|
+
nonce: bytes
|
|
18
|
+
account_id: str
|
|
19
|
+
details: bytes
|
|
20
|
+
account: AsyncRelatedModel
|
|
21
|
+
transactions: AsyncRelatedCollection
|
|
22
|
+
|
|
23
|
+
def __hash__(self) -> int:
|
|
24
|
+
data = self.encode_value(self._encode(self.data))
|
|
25
|
+
return hash(bytes(data, 'utf-8'))
|
|
26
|
+
|
|
27
|
+
# override automatic properties
|
|
28
|
+
@property
|
|
29
|
+
def type(self) -> EntryType:
|
|
30
|
+
"""The EntryType of the Entry."""
|
|
31
|
+
return EntryType(self.data['type'])
|
|
32
|
+
@type.setter
|
|
33
|
+
def type(self, val: EntryType):
|
|
34
|
+
if type(val) is not EntryType:
|
|
35
|
+
return
|
|
36
|
+
self.data['type'] = val.value
|
|
37
|
+
|
|
38
|
+
@property
|
|
39
|
+
def details(self) -> packify.SerializableType:
|
|
40
|
+
"""A packify.SerializableType stored in the database as a blob."""
|
|
41
|
+
return packify.unpack(self.data.get('details', b'n\x00\x00\x00\x00'))
|
|
42
|
+
@details.setter
|
|
43
|
+
def details(self, val: packify.SerializableType):
|
|
44
|
+
self.data['details'] = packify.pack(val)
|
|
45
|
+
|
|
46
|
+
@staticmethod
|
|
47
|
+
def _encode(data: dict|None) -> dict|None:
|
|
48
|
+
if type(data) is not dict:
|
|
49
|
+
return data
|
|
50
|
+
if type(data.get('type', None)) is EntryType:
|
|
51
|
+
data['type'] = data['type'].value
|
|
52
|
+
if type(data.get('details', {})) is not bytes:
|
|
53
|
+
data['details'] = packify.pack(data.get('details', None))
|
|
54
|
+
return data
|
|
55
|
+
|
|
56
|
+
@staticmethod
|
|
57
|
+
def _parse(data: dict|None) -> dict|None:
|
|
58
|
+
if type(data) is dict and type(data['amount']) is str:
|
|
59
|
+
data['amount'] = int(data['amount'])
|
|
60
|
+
return data
|
|
61
|
+
|
|
62
|
+
@staticmethod
|
|
63
|
+
def parse(models: Entry|list[Entry]) -> Entry|list[Entry]:
|
|
64
|
+
if type(models) is list:
|
|
65
|
+
for model in models:
|
|
66
|
+
model.data = Entry._parse(model.data)
|
|
67
|
+
else:
|
|
68
|
+
models.data = Entry._parse(models.data)
|
|
69
|
+
return models
|
|
70
|
+
|
|
71
|
+
@classmethod
|
|
72
|
+
async def insert(cls, data: dict) -> Entry | None:
|
|
73
|
+
"""Ensure data is encoded before inserting."""
|
|
74
|
+
result = await super().insert(cls._encode(data))
|
|
75
|
+
return result
|
|
76
|
+
|
|
77
|
+
@classmethod
|
|
78
|
+
async def insert_many(cls, items: list[dict]) -> int:
|
|
79
|
+
"""Ensure data is encoded before inserting."""
|
|
80
|
+
items = [Entry._encode(data) for data in list]
|
|
81
|
+
return await super().insert_many(items)
|
|
82
|
+
|
|
83
|
+
@classmethod
|
|
84
|
+
def query(cls, conditions: dict = None) -> AsyncQueryBuilderProtocol:
|
|
85
|
+
"""Ensure conditions are encoded properly before querying."""
|
|
86
|
+
return super().query(cls._encode(conditions))
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
from .Account import Account, AccountType
|
|
3
|
+
from .Ledger import Ledger
|
|
4
|
+
from sqloquent.asyncql import AsyncSqlModel, AsyncRelatedCollection
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class Identity(AsyncSqlModel):
|
|
8
|
+
connection_info: str = ''
|
|
9
|
+
table: str = 'identities'
|
|
10
|
+
id_column: str = 'id'
|
|
11
|
+
columns: tuple[str] = ('id', 'name', 'details', 'pubkey', 'seed', 'secret_details')
|
|
12
|
+
columns_excluded_from_hash: tuple[str] = ('seed', 'secret_details')
|
|
13
|
+
id: str
|
|
14
|
+
name: str
|
|
15
|
+
details: bytes
|
|
16
|
+
pubkey: bytes|None
|
|
17
|
+
seed: bytes|None
|
|
18
|
+
secret_details: bytes|None
|
|
19
|
+
ledgers: AsyncRelatedCollection
|
|
20
|
+
|
|
21
|
+
def public(self) -> dict:
|
|
22
|
+
"""Return the public data for cloning the Identity."""
|
|
23
|
+
return {
|
|
24
|
+
k:v for k,v in self.data.items()
|
|
25
|
+
if k not in self.columns_excluded_from_hash
|
|
26
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
from .Account import Account, AccountType
|
|
3
|
+
from sqloquent.asyncql import AsyncSqlModel, AsyncRelatedModel, AsyncRelatedCollection
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class Ledger(AsyncSqlModel):
|
|
7
|
+
connection_info: str = ''
|
|
8
|
+
table: str = 'ledgers'
|
|
9
|
+
id_column: str = 'id'
|
|
10
|
+
columns: tuple[str] = ('id', 'name', 'identity_id', 'currency_id')
|
|
11
|
+
id: str
|
|
12
|
+
name: str
|
|
13
|
+
identity_id: str
|
|
14
|
+
currency_id: str
|
|
15
|
+
owner: AsyncRelatedModel
|
|
16
|
+
currency: AsyncRelatedModel
|
|
17
|
+
accounts: AsyncRelatedCollection
|
|
18
|
+
transactions: AsyncRelatedCollection
|
|
19
|
+
|
|
20
|
+
async def balances(self, reload: bool = False) -> dict[str, tuple[int, AccountType]]:
|
|
21
|
+
"""Return a dict mapping account ids to their balances. Accounts
|
|
22
|
+
with sub-accounts will not include the sub-account balances;
|
|
23
|
+
the sub-account balances will be returned separately.
|
|
24
|
+
"""
|
|
25
|
+
balances = {}
|
|
26
|
+
if reload:
|
|
27
|
+
await self.accounts().reload()
|
|
28
|
+
for account in self.accounts:
|
|
29
|
+
balances[account.id] = (await account.balance(False), account.type)
|
|
30
|
+
return balances
|
|
31
|
+
|
|
32
|
+
def setup_basic_accounts(self) -> list[Account]:
|
|
33
|
+
"""Creates and returns a list of 3 unsaved Accounts covering the
|
|
34
|
+
3 basic categories: Asset, Liability, Equity.
|
|
35
|
+
"""
|
|
36
|
+
asset = Account({
|
|
37
|
+
'name': f'General Asset ({self.owner.name})',
|
|
38
|
+
'type': AccountType.ASSET,
|
|
39
|
+
'ledger_id': self.id,
|
|
40
|
+
'code': '1xx'
|
|
41
|
+
})
|
|
42
|
+
liability = Account({
|
|
43
|
+
'name': f'General Liability ({self.owner.name})',
|
|
44
|
+
'type': AccountType.LIABILITY,
|
|
45
|
+
'ledger_id': self.id,
|
|
46
|
+
'code': '2xx'
|
|
47
|
+
})
|
|
48
|
+
equity = Account({
|
|
49
|
+
'name': f'General Equity ({self.owner.name})',
|
|
50
|
+
'type': AccountType.EQUITY,
|
|
51
|
+
'ledger_id': self.id,
|
|
52
|
+
'code': '28x'
|
|
53
|
+
})
|
|
54
|
+
return [asset, liability, equity]
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
from hashlib import sha256
|
|
3
|
+
from sqloquent.asyncql import AsyncSqlModel, AsyncRelatedCollection
|
|
4
|
+
from sqloquent.errors import vert, tert
|
|
5
|
+
from .Account import Account, AccountType
|
|
6
|
+
from .Entry import Entry, EntryType
|
|
7
|
+
from .Identity import Identity
|
|
8
|
+
import packify
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class Transaction(AsyncSqlModel):
|
|
12
|
+
connection_info: str = ''
|
|
13
|
+
table: str = 'transactions'
|
|
14
|
+
id_column: str = 'id'
|
|
15
|
+
columns: tuple[str] = ('id', 'entry_ids', 'ledger_ids', 'timestamp', 'details')
|
|
16
|
+
id: str
|
|
17
|
+
entry_ids: str
|
|
18
|
+
ledger_ids: str
|
|
19
|
+
timestamp: str
|
|
20
|
+
details: bytes
|
|
21
|
+
entries: AsyncRelatedCollection
|
|
22
|
+
ledgers: AsyncRelatedCollection
|
|
23
|
+
|
|
24
|
+
# override automatic properties
|
|
25
|
+
@property
|
|
26
|
+
def details(self) -> dict[str, bytes]:
|
|
27
|
+
"""A packify.SerializableType stored in the database as a blob."""
|
|
28
|
+
return packify.unpack(self.data.get('details', b'd\x00\x00\x00\x00'))
|
|
29
|
+
@details.setter
|
|
30
|
+
def details(self, val: dict[str, bytes]):
|
|
31
|
+
if type(val) is not dict:
|
|
32
|
+
return
|
|
33
|
+
if not all([type(k) is str and type(v) is bytes for k, v in val.items()]):
|
|
34
|
+
return
|
|
35
|
+
self.data['details'] = packify.pack(val)
|
|
36
|
+
|
|
37
|
+
@classmethod
|
|
38
|
+
def _encode(cls, data: dict|None) -> dict|None:
|
|
39
|
+
"""Encode values for saving."""
|
|
40
|
+
if type(data) is not dict:
|
|
41
|
+
return None
|
|
42
|
+
if type(data.get('details', {})) is dict:
|
|
43
|
+
data['details'] = packify.pack(data.get('details', {}))
|
|
44
|
+
return data
|
|
45
|
+
|
|
46
|
+
@classmethod
|
|
47
|
+
async def prepare(cls, entries: list[Entry], timestamp: str,
|
|
48
|
+
details: packify.SerializableType = None,
|
|
49
|
+
reload: bool = False) -> Transaction:
|
|
50
|
+
"""Prepare a transaction. Raises TypeError for invalid arguments.
|
|
51
|
+
Raises ValueError if the entries do not balance for each
|
|
52
|
+
ledger; if a required auth script is missing; or if any of
|
|
53
|
+
the entries is contained within an existing Transaction.
|
|
54
|
+
Entries and Transaction will have IDs generated but will not
|
|
55
|
+
be persisted to the database and must be saved separately.
|
|
56
|
+
"""
|
|
57
|
+
tert(type(entries) is list and all([type(e) is Entry for e in entries]),
|
|
58
|
+
'entries must be list[Entry]')
|
|
59
|
+
tert(type(timestamp) is str, 'timestamp must be str')
|
|
60
|
+
|
|
61
|
+
ledgers = set()
|
|
62
|
+
for entry in entries:
|
|
63
|
+
entry.id = entry.id if entry.id else entry.generate_id()
|
|
64
|
+
if reload:
|
|
65
|
+
await entry.account().reload()
|
|
66
|
+
vert(await Transaction.query().contains('entry_ids', entry.id).count() == 0,
|
|
67
|
+
f"entry {entry.id} is already contained within a Transaction")
|
|
68
|
+
ledgers.add(entry.account.ledger_id)
|
|
69
|
+
|
|
70
|
+
txn = cls({
|
|
71
|
+
'entry_ids': ",".join(sorted([
|
|
72
|
+
e.id if e.id else e.generate_id()
|
|
73
|
+
for e in entries
|
|
74
|
+
])),
|
|
75
|
+
'ledger_ids': ",".join(sorted(list(ledgers))),
|
|
76
|
+
'timestamp': timestamp,
|
|
77
|
+
})
|
|
78
|
+
txn.details = details
|
|
79
|
+
txn.entries = entries
|
|
80
|
+
assert await txn.validate(reload), \
|
|
81
|
+
'transaction validation failed'
|
|
82
|
+
txn.id = txn.generate_id()
|
|
83
|
+
return txn
|
|
84
|
+
|
|
85
|
+
async def validate(self, reload: bool = False) -> bool:
|
|
86
|
+
"""Determines if a Transaction is valid using the rules of accounting.
|
|
87
|
+
Raises TypeError for invalid arguments. Raises ValueError if the
|
|
88
|
+
entries do not balance for each ledger; or if any of the entries is
|
|
89
|
+
contained within an existing Transaction. If reload is set to True,
|
|
90
|
+
entries and accounts will be reloaded from the database.
|
|
91
|
+
"""
|
|
92
|
+
if reload:
|
|
93
|
+
await self.entries().reload()
|
|
94
|
+
|
|
95
|
+
# first check that all ledgers balance
|
|
96
|
+
ledgers = {}
|
|
97
|
+
entry: Entry
|
|
98
|
+
for entry in self.entries:
|
|
99
|
+
if reload:
|
|
100
|
+
await entry.account().reload()
|
|
101
|
+
if entry.account.ledger_id not in ledgers:
|
|
102
|
+
ledgers[entry.account.ledger_id] = {'Dr': 0, 'Cr': 0}
|
|
103
|
+
if entry.type in (EntryType.CREDIT, EntryType.CREDIT.value):
|
|
104
|
+
ledgers[entry.account.ledger_id]['Cr'] += entry.amount
|
|
105
|
+
else:
|
|
106
|
+
ledgers[entry.account.ledger_id]['Dr'] += entry.amount
|
|
107
|
+
|
|
108
|
+
for ledger_id, balances in ledgers.items():
|
|
109
|
+
vert(balances['Cr'] == balances['Dr'],
|
|
110
|
+
f"ledger {ledger_id} unbalanced: {balances['Cr']} Cr != {balances['Dr']} Dr")
|
|
111
|
+
|
|
112
|
+
return True
|
|
113
|
+
|
|
114
|
+
async def save(self, reload: bool = False) -> Transaction:
|
|
115
|
+
"""Validate the transaction, save the entries, then save the
|
|
116
|
+
transaction.
|
|
117
|
+
"""
|
|
118
|
+
assert await self.validate(reload), 'cannot save an invalid Transaction'
|
|
119
|
+
for e in self.entries:
|
|
120
|
+
await e.save()
|
|
121
|
+
return await super().save()
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
from sqloquent.asyncql import AsyncSqlModel
|
|
2
|
+
import packify
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class Vendor(AsyncSqlModel):
|
|
6
|
+
connection_info: str = ''
|
|
7
|
+
table: str = 'vendors'
|
|
8
|
+
id_column: str = 'id'
|
|
9
|
+
columns: tuple[str] = ('id', 'name', 'code', 'details')
|
|
10
|
+
id: str
|
|
11
|
+
name: str
|
|
12
|
+
code: str|None
|
|
13
|
+
details: str|None
|
|
14
|
+
|
|
15
|
+
# override automatic property
|
|
16
|
+
@property
|
|
17
|
+
def details(self) -> packify.SerializableType:
|
|
18
|
+
"""A packify.SerializableType stored in the database as a blob."""
|
|
19
|
+
return packify.unpack(self.data.get('details', None) or b'n\x00\x00\x00\x00')
|
|
20
|
+
@details.setter
|
|
21
|
+
def details(self, val: packify.SerializableType):
|
|
22
|
+
if isinstance(val, packify.SerializableType):
|
|
23
|
+
self.data['details'] = packify.pack(val)
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
from .Account import Account, AccountType
|
|
2
|
+
from .Currency import Currency
|
|
3
|
+
from .Customer import Customer
|
|
4
|
+
from .Entry import Entry, EntryType
|
|
5
|
+
from .Identity import Identity
|
|
6
|
+
from .Ledger import Ledger
|
|
7
|
+
from .Transaction import Transaction
|
|
8
|
+
from .Vendor import Vendor
|
|
9
|
+
from sqloquent.asyncql import (
|
|
10
|
+
async_contains, async_within, async_has_many, async_belongs_to,
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
Identity.ledgers = async_has_many(Identity, Ledger, 'identity_id')
|
|
15
|
+
Ledger.owner = async_belongs_to(Ledger, Identity, 'identity_id')
|
|
16
|
+
|
|
17
|
+
Ledger.currency = async_belongs_to(Ledger, Currency, 'currency_id')
|
|
18
|
+
|
|
19
|
+
Ledger.accounts = async_has_many(Ledger, Account, 'ledger_id')
|
|
20
|
+
Account.ledger = async_belongs_to(Account, Ledger, 'ledger_id')
|
|
21
|
+
|
|
22
|
+
Account.children = async_has_many(Account, Account, 'parent_id')
|
|
23
|
+
Account.parent = async_belongs_to(Account, Account, 'parent_id')
|
|
24
|
+
|
|
25
|
+
Account.entries = async_has_many(Account, Entry, 'account_id')
|
|
26
|
+
Entry.account = async_belongs_to(Entry, Account, 'account_id')
|
|
27
|
+
|
|
28
|
+
Entry.transactions = async_within(Entry, Transaction, 'entry_ids')
|
|
29
|
+
Transaction.entries = async_contains(Transaction, Entry, 'entry_ids')
|
|
30
|
+
|
|
31
|
+
Transaction.ledgers = async_contains(Transaction, Ledger, 'ledger_ids')
|
|
32
|
+
Ledger.transactions = async_within(Ledger, Transaction, 'ledger_ids')
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def set_connection_info(db_file_path: str):
|
|
36
|
+
"""Set the connection info for all models to use the specified
|
|
37
|
+
sqlite3 database file path.
|
|
38
|
+
"""
|
|
39
|
+
Account.connection_info = db_file_path
|
|
40
|
+
Currency.connection_info = db_file_path
|
|
41
|
+
Customer.connection_info = db_file_path
|
|
42
|
+
Entry.connection_info = db_file_path
|
|
43
|
+
Identity.connection_info = db_file_path
|
|
44
|
+
Ledger.connection_info = db_file_path
|
|
45
|
+
Transaction.connection_info = db_file_path
|
|
46
|
+
Vendor.connection_info = db_file_path
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
# no longer needed
|
|
50
|
+
del async_contains
|
|
51
|
+
del async_belongs_to
|
|
52
|
+
del async_within
|
|
53
|
+
del async_has_many
|