django-ledger 0.5.5.5__py3-none-any.whl → 0.5.6.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.

Potentially problematic release.


This version of django-ledger might be problematic. Click here for more details.

Files changed (45) hide show
  1. django_ledger/__init__.py +1 -1
  2. django_ledger/admin/entity.py +16 -2
  3. django_ledger/admin/ledger.py +2 -1
  4. django_ledger/forms/entity.py +4 -12
  5. django_ledger/forms/ledger.py +19 -0
  6. django_ledger/forms/transactions.py +1 -1
  7. django_ledger/io/__init__.py +4 -1
  8. django_ledger/io/{io_mixin.py → io_core.py} +49 -28
  9. django_ledger/io/io_digest.py +7 -0
  10. django_ledger/io/{data_generator.py → io_generator.py} +51 -8
  11. django_ledger/io/io_library.py +317 -0
  12. django_ledger/io/{io_context.py → io_middleware.py} +16 -9
  13. django_ledger/migrations/0001_initial.py +413 -132
  14. django_ledger/migrations/0014_ledgermodel_ledger_xid_and_more.py +22 -0
  15. django_ledger/models/accounts.py +8 -7
  16. django_ledger/models/bank_account.py +12 -11
  17. django_ledger/models/bill.py +5 -9
  18. django_ledger/models/closing_entry.py +14 -14
  19. django_ledger/models/coa.py +1 -1
  20. django_ledger/models/customer.py +5 -11
  21. django_ledger/models/data_import.py +12 -6
  22. django_ledger/models/entity.py +88 -10
  23. django_ledger/models/estimate.py +12 -9
  24. django_ledger/models/invoice.py +10 -4
  25. django_ledger/models/items.py +11 -6
  26. django_ledger/models/journal_entry.py +6 -13
  27. django_ledger/models/ledger.py +65 -15
  28. django_ledger/models/mixins.py +2 -3
  29. django_ledger/models/purchase_order.py +11 -7
  30. django_ledger/models/transactions.py +3 -1
  31. django_ledger/models/unit.py +13 -14
  32. django_ledger/models/vendor.py +12 -11
  33. django_ledger/templates/django_ledger/journal_entry/je_list.html +3 -0
  34. django_ledger/templatetags/django_ledger.py +1 -1
  35. django_ledger/tests/base.py +1 -1
  36. django_ledger/urls/ledger.py +3 -0
  37. django_ledger/views/entity.py +9 -3
  38. django_ledger/views/ledger.py +14 -7
  39. django_ledger/views/mixins.py +9 -1
  40. {django_ledger-0.5.5.5.dist-info → django_ledger-0.5.6.0.dist-info}/METADATA +8 -8
  41. {django_ledger-0.5.5.5.dist-info → django_ledger-0.5.6.0.dist-info}/RECORD +45 -43
  42. {django_ledger-0.5.5.5.dist-info → django_ledger-0.5.6.0.dist-info}/AUTHORS.md +0 -0
  43. {django_ledger-0.5.5.5.dist-info → django_ledger-0.5.6.0.dist-info}/LICENSE +0 -0
  44. {django_ledger-0.5.5.5.dist-info → django_ledger-0.5.6.0.dist-info}/WHEEL +0 -0
  45. {django_ledger-0.5.5.5.dist-info → django_ledger-0.5.6.0.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,317 @@
1
+ """
2
+ Django Ledger created by Miguel Sanda <msanda@arrobalytics.com>.
3
+ Copyright© EDMA Group Inc licensed under the GPLv3 Agreement.
4
+
5
+ Contributions to this module:
6
+ * Miguel Sanda <msanda@arrobalytics.com>
7
+ """
8
+ from collections import defaultdict
9
+ from dataclasses import dataclass
10
+ from decimal import Decimal
11
+ from itertools import chain
12
+ from typing import Union, Dict, Callable, Optional
13
+ from uuid import UUID
14
+ from datetime import date, datetime
15
+
16
+ from django.core.exceptions import ValidationError
17
+ from django.db.models import Q
18
+ from django.utils.timezone import localtime
19
+ from django.utils.translation import gettext_lazy as _
20
+
21
+ from django_ledger.models.accounts import AccountModel, AccountModelQuerySet, CREDIT, DEBIT
22
+ from django_ledger.models.coa import ChartOfAccountModel
23
+ from django_ledger.models.entity import EntityModel
24
+ from django_ledger.models.ledger import LedgerModel, LedgerModelQuerySet
25
+
26
+
27
+ @dataclass
28
+ class TransactionInstructionItem:
29
+ """
30
+ A class to represent a transaction instruction used during the development of transaction blueprints.
31
+
32
+ Attributes
33
+ ----------
34
+ account_code: str
35
+ The account code of the AccountModel as a String.
36
+ amount: Decimal
37
+ The transaction amount as a Decimal value. Will be rounded to the nearest decimal place.
38
+ tx_type: str
39
+ A choice of 'debit' or 'credit' transaction.
40
+ description: str
41
+ Description of the transaction.
42
+ account_model: AccountModel
43
+ The resolved account model for the transaction. Not to be modified. Defaults to None.
44
+ """
45
+ account_code: str
46
+ amount: Union[Decimal, float]
47
+ tx_type: str
48
+ description: Optional[str]
49
+ account_model: Optional[AccountModel] = None
50
+
51
+ def to_dict(self) -> Dict:
52
+ return {
53
+ 'account': self.account_model,
54
+ 'amount': self.amount,
55
+ 'tx_type': self.tx_type,
56
+ 'description': self.description
57
+ }
58
+
59
+
60
+ class IOCursorValidationError(ValidationError):
61
+ pass
62
+
63
+
64
+ class IOCursor:
65
+
66
+ def __init__(self,
67
+ io_library,
68
+ entity_model: EntityModel,
69
+ user_model,
70
+ coa_model: Optional[Union[ChartOfAccountModel, UUID, str]] = None):
71
+ self.IO_LIBRARY = io_library
72
+ self.ENTITY_MODEL = entity_model
73
+ self.USER_MODEL = user_model
74
+ self.COA_MODEL = coa_model
75
+ self.__COMMITTED: bool = False
76
+ self.blueprints = defaultdict(list)
77
+ self.ledger_model_qs: Optional[LedgerModelQuerySet] = None
78
+ self.account_model_qs: Optional[AccountModelQuerySet] = None
79
+ self.ledger_map = dict()
80
+ self.commit_plan = dict()
81
+ self.instructions = None
82
+
83
+ def get_ledger_model_qs(self) -> LedgerModelQuerySet:
84
+ return LedgerModel.objects.for_entity(
85
+ self.ENTITY_MODEL,
86
+ self.USER_MODEL
87
+ )
88
+
89
+ def get_account_model_qs(self) -> AccountModelQuerySet:
90
+ return self.ENTITY_MODEL.get_coa_accounts(
91
+ coa_model=self.COA_MODEL
92
+ )
93
+
94
+ def resolve_account_model_qs(self, codes):
95
+ if self.account_model_qs is None:
96
+ # codes = self.get_account_codes()
97
+ qs = self.get_account_model_qs()
98
+ qs = qs.filter(code__in=codes)
99
+ self.account_model_qs = qs
100
+ return self.account_model_qs
101
+
102
+ def resolve_ledger_model_qs(self):
103
+ if self.ledger_model_qs is None:
104
+ qs = self.get_ledger_model_qs()
105
+ by_uuid = [k for k in self.blueprints.keys() if isinstance(k, UUID)]
106
+ by_xid = [k for k in self.blueprints.keys() if isinstance(k, str)]
107
+ self.ledger_model_qs = qs.filter(
108
+ Q(uuid__in=by_uuid) | Q(ledger_xid__in=by_xid)
109
+ )
110
+ return self.ledger_model_qs
111
+
112
+ def dispatch(self,
113
+ name,
114
+ ledger_model: Optional[Union[str, LedgerModel, UUID]] = None,
115
+ **kwargs):
116
+
117
+ if not isinstance(ledger_model, (str, UUID, LedgerModel)):
118
+ raise IOCursorValidationError(
119
+ message=_('Ledger Model must be a string or UUID or LedgerModel')
120
+ )
121
+
122
+ if isinstance(ledger_model, LedgerModel):
123
+ self.ENTITY_MODEL.validate_ledger_model_for_entity(ledger_model)
124
+
125
+ blueprint_gen = self.IO_LIBRARY.get_blueprint(name)
126
+ blueprint = blueprint_gen(**kwargs)
127
+ self.blueprints[ledger_model].append(blueprint)
128
+
129
+ def compile_instructions(self):
130
+
131
+ if self.instructions is None:
132
+ instructions = {
133
+ ledger_model: list(chain.from_iterable(
134
+ io_blueprint.registry for io_blueprint in instructions
135
+ )) for ledger_model, instructions in self.commit_plan.items()
136
+ }
137
+
138
+ for ledger_model, txs in instructions.items():
139
+ total_credits = sum(t.amount for t in txs if t.tx_type == CREDIT)
140
+ total_debits = sum(t.amount for t in txs if t.tx_type == DEBIT)
141
+
142
+ # print("{} credits, {} debits".format(total_credits, total_debits))
143
+
144
+ if total_credits != total_debits:
145
+ raise IOCursorValidationError(
146
+ message=_('Total transactions Credits and Debits must equal: ')
147
+ )
148
+
149
+ self.instructions = instructions
150
+ return self.instructions
151
+
152
+ def is_committed(self) -> bool:
153
+ return self.__COMMITTED
154
+
155
+ def commit(self,
156
+ je_timestamp: Optional[Union[datetime, date, str]] = None,
157
+ post_new_ledgers: bool = False,
158
+ post_journal_entries: bool = False):
159
+ if self.is_committed():
160
+ raise IOCursorValidationError(
161
+ message=_('Transactions already committed')
162
+ )
163
+ qs = self.resolve_ledger_model_qs()
164
+ self.ledger_map = {l.ledger_xid: l for l in qs if l.ledger_xid} | {l.uuid: l for l in qs}
165
+
166
+ # checks for any locked ledgers...
167
+ for k, ledger_model in self.ledger_map.items():
168
+ if ledger_model.is_locked():
169
+ raise IOCursorValidationError(
170
+ message=_(f'Cannot transact on a locked ledger: {ledger_model}')
171
+ )
172
+
173
+ for k, txs in self.blueprints.items():
174
+ if k is None:
175
+
176
+ # no specified xid, ledger or UUID... create one...
177
+ self.commit_plan[
178
+ self.ENTITY_MODEL.create_ledger(
179
+ name='Blueprint Commitment',
180
+ commit=False,
181
+ posted=post_new_ledgers
182
+ )
183
+ ] = txs
184
+
185
+ elif isinstance(k, str):
186
+ try:
187
+ # ledger with xid already exists...
188
+ self.commit_plan[self.ledger_map[k]] = txs
189
+ except KeyError:
190
+ # create ledger with xid provided...
191
+ self.commit_plan[
192
+ self.ENTITY_MODEL.create_ledger(
193
+ name=f'Blueprint Commitment {k}',
194
+ ledger_xid=k,
195
+ commit=False,
196
+ posted=post_new_ledgers
197
+ )
198
+ ] = txs
199
+ elif isinstance(k, LedgerModel):
200
+ self.commit_plan[k] = txs
201
+
202
+ else:
203
+ raise IOLibraryError('Unsupported ledger of type {x}'.format(x=type(k)))
204
+
205
+ instructions = self.compile_instructions()
206
+ account_codes = set(tx.account_code for tx in chain.from_iterable(tr for _, tr in instructions.items()))
207
+ account_models = {acc.code: acc for acc in self.resolve_account_model_qs(codes=account_codes)}
208
+
209
+ for tx in chain.from_iterable(tr for _, tr in instructions.items()):
210
+ tx.account_model = account_models[tx.account_code]
211
+
212
+ results = dict()
213
+ for ledger_model, tr_items in instructions.items():
214
+ if ledger_model._state.adding:
215
+ ledger_model.save()
216
+ je_txs = [t.to_dict() for t in tr_items]
217
+
218
+ # where the magic happens...
219
+ je, txs_models = ledger_model.commit_txs(
220
+ je_timestamp=je_timestamp if je_timestamp else localtime(),
221
+ je_txs=je_txs,
222
+ je_posted=post_journal_entries
223
+ )
224
+
225
+ results[ledger_model] = {
226
+ 'journal_entry': je,
227
+ 'txs_models': txs_models,
228
+ 'instructions': tr_items
229
+ }
230
+ results['account_model_qs'] = self.account_model_qs
231
+ self.__COMMITTED = True
232
+ return results
233
+
234
+
235
+ class IOLibraryError(ValidationError):
236
+ pass
237
+
238
+
239
+ class IOBluePrintValidationError(ValidationError):
240
+ pass
241
+
242
+
243
+ class IOBluePrint:
244
+
245
+ def __init__(self, precision_decimals: int = 2):
246
+ self.precision_decimals = precision_decimals
247
+ self.registry = list()
248
+
249
+ def _round_amount(self, amount: Decimal) -> Decimal:
250
+ return round(amount, self.precision_decimals)
251
+
252
+ def _amount(self, amount: Union[float, Decimal]) -> Decimal:
253
+ if amount <= 0:
254
+ raise IOBluePrintValidationError(
255
+ message='Amounts must be greater than 0'
256
+ )
257
+
258
+ if isinstance(amount, float):
259
+ return self._round_amount(Decimal.from_float(amount))
260
+
261
+ elif isinstance(amount, Decimal):
262
+ return self._round_amount(amount)
263
+
264
+ raise IOBluePrintValidationError(
265
+ message='Amounts must be float or Decimal'
266
+ )
267
+
268
+ def credit(self, account_code: str, amount: Union[float, Decimal], description: str = None):
269
+
270
+ self.registry.append(
271
+ TransactionInstructionItem(
272
+ account_code=account_code,
273
+ amount=self._amount(amount),
274
+ tx_type=CREDIT,
275
+ description=description
276
+ ))
277
+
278
+ def debit(self, account_code: str, amount: Union[float, Decimal], description: str = None):
279
+
280
+ self.registry.append(
281
+ TransactionInstructionItem(
282
+ account_code=account_code,
283
+ amount=self._amount(amount),
284
+ tx_type=DEBIT,
285
+ description=description
286
+ ))
287
+
288
+
289
+ class IOLibrary:
290
+
291
+ def __init__(self, name: str):
292
+ self.name = name
293
+ self.registry: Dict[str, Callable] = {}
294
+
295
+ def _check_func_name(self, name) -> bool:
296
+ return name in self.registry
297
+
298
+ def register(self, func: Callable):
299
+ self.registry[func.__name__] = func
300
+
301
+ def get_blueprint(self, name: str) -> Callable:
302
+ if not self._check_func_name(name):
303
+ raise IOLibraryError(message=f'Function "{name}" is not registered in IO library {self.name}')
304
+ return self.registry[name]
305
+
306
+ def get_cursor(
307
+ self,
308
+ entity_model: EntityModel,
309
+ user_model,
310
+ coa_model: Optional[Union[ChartOfAccountModel, UUID, str]] = None
311
+ ) -> IOCursor:
312
+ return IOCursor(
313
+ io_library=self,
314
+ entity_model=entity_model,
315
+ user_model=user_model,
316
+ coa_model=coa_model,
317
+ )
@@ -1,3 +1,10 @@
1
+ """
2
+ Django Ledger created by Miguel Sanda <msanda@arrobalytics.com>.
3
+ Copyright© EDMA Group Inc licensed under the GPLv3 Agreement.
4
+
5
+ Contributions to this module:
6
+ * Miguel Sanda <msanda@arrobalytics.com>
7
+ """
1
8
  from collections import defaultdict
2
9
  from itertools import groupby, chain
3
10
 
@@ -9,7 +16,7 @@ from django_ledger.models.utils import LazyLoader, lazy_loader
9
16
  lazy_importer = LazyLoader()
10
17
 
11
18
 
12
- class RoleContextManager:
19
+ class AccountRoleIOMiddleware:
13
20
 
14
21
  def __init__(self,
15
22
  io_data: dict,
@@ -75,7 +82,7 @@ class RoleContextManager:
75
82
  acc['balance'] for acc in acc_list if acc['unit_uuid'] == key[0])
76
83
 
77
84
 
78
- class GroupContextManager:
85
+ class AccountGroupIOMiddleware:
79
86
  GROUP_ACCOUNTS_KEY = 'group_account'
80
87
  GROUP_BALANCE_KEY = 'group_balance'
81
88
  GROUP_BALANCE_BY_UNIT_KEY = 'group_balance_by_unit'
@@ -148,7 +155,7 @@ class GroupContextManager:
148
155
  )
149
156
 
150
157
 
151
- class ActivityContextManager:
158
+ class JEActivityIOMiddleware:
152
159
 
153
160
  def __init__(self,
154
161
  io_data: dict,
@@ -210,7 +217,7 @@ class ActivityContextManager:
210
217
  acc['balance'] for acc in acc_list if acc['unit_uuid'] == key[0])
211
218
 
212
219
 
213
- class BalanceSheetStatementContextManager:
220
+ class BalanceSheetIOMiddleware:
214
221
  BS_DIGEST_KEY = 'balance_sheet'
215
222
 
216
223
  def __init__(self, io_data: dict):
@@ -256,7 +263,7 @@ class BalanceSheetStatementContextManager:
256
263
  return self.IO_DATA
257
264
 
258
265
 
259
- class IncomeStatementContextManager:
266
+ class IncomeStatementIOMiddleware:
260
267
  IC_DIGEST_KEY = 'income_statement'
261
268
 
262
269
  def __init__(self, io_data: dict):
@@ -341,7 +348,7 @@ class IncomeStatementContextManager:
341
348
  return self.IO_DATA
342
349
 
343
350
 
344
- class CashFlowStatementContextManager:
351
+ class CashFlowStatementIOMiddleware:
345
352
  CFS_DIGEST_KEY = 'cash_flow_statement'
346
353
 
347
354
  def __init__(self, io_data: dict):
@@ -350,13 +357,13 @@ class CashFlowStatementContextManager:
350
357
  self.JE_MODEL = lazy_loader.get_journal_entry_model()
351
358
 
352
359
  def check_io_digest(self):
353
- if GroupContextManager.GROUP_BALANCE_KEY not in self.IO_DATA:
360
+ if AccountGroupIOMiddleware.GROUP_BALANCE_KEY not in self.IO_DATA:
354
361
  raise ValidationError(
355
362
  'IO Digest must have groups for Cash Flow Statement'
356
363
  )
357
364
 
358
365
  def operating(self):
359
- group_balances = self.IO_DATA[GroupContextManager.GROUP_BALANCE_KEY]
366
+ group_balances = self.IO_DATA[AccountGroupIOMiddleware.GROUP_BALANCE_KEY]
360
367
  operating_activities = dict()
361
368
  operating_activities['GROUP_CFS_NET_INCOME'] = {
362
369
  'description': 'Net Income',
@@ -424,7 +431,7 @@ class CashFlowStatementContextManager:
424
431
  self.IO_DATA[self.CFS_DIGEST_KEY]['net_cash_by_activity']['FINANCING'] = net_cash
425
432
 
426
433
  def investing(self):
427
- group_balances = self.IO_DATA[GroupContextManager.GROUP_BALANCE_KEY]
434
+ group_balances = self.IO_DATA[AccountGroupIOMiddleware.GROUP_BALANCE_KEY]
428
435
  investing_activities = dict()
429
436
  investing_activities['GROUP_CFS_INVESTING_SECURITIES'] = {
430
437
  'description': 'Purchase, Maturity and Sales of Investments & Securities',