django-ledger 0.7.3__py3-none-any.whl → 0.7.4__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 (42) hide show
  1. django_ledger/__init__.py +1 -1
  2. django_ledger/contrib/django_ledger_graphene/journal_entry/schema.py +2 -3
  3. django_ledger/contrib/django_ledger_graphene/transaction/schema.py +9 -7
  4. django_ledger/forms/journal_entry.py +19 -12
  5. django_ledger/forms/transactions.py +8 -12
  6. django_ledger/io/io_core.py +14 -11
  7. django_ledger/io/io_library.py +3 -3
  8. django_ledger/migrations/0001_initial.py +1 -1
  9. django_ledger/migrations/0019_alter_transactionmodel_amount_and_more.py +33 -0
  10. django_ledger/models/bill.py +17 -2
  11. django_ledger/models/chart_of_accounts.py +4 -0
  12. django_ledger/models/closing_entry.py +8 -6
  13. django_ledger/models/invoice.py +12 -4
  14. django_ledger/models/journal_entry.py +843 -481
  15. django_ledger/models/ledger.py +45 -4
  16. django_ledger/models/transactions.py +303 -305
  17. django_ledger/models/unit.py +42 -22
  18. django_ledger/templates/django_ledger/account/tags/accounts_table.html +1 -1
  19. django_ledger/templates/django_ledger/bills/bill_detail.html +1 -1
  20. django_ledger/templates/django_ledger/invoice/invoice_detail.html +1 -1
  21. django_ledger/templates/django_ledger/journal_entry/je_create.html +2 -3
  22. django_ledger/templates/django_ledger/journal_entry/je_delete.html +2 -3
  23. django_ledger/templates/django_ledger/journal_entry/je_detail.html +1 -1
  24. django_ledger/templates/django_ledger/journal_entry/je_detail_txs.html +8 -8
  25. django_ledger/templates/django_ledger/journal_entry/je_list.html +16 -13
  26. django_ledger/templates/django_ledger/journal_entry/je_update.html +2 -3
  27. django_ledger/templates/django_ledger/journal_entry/tags/je_table.html +24 -24
  28. django_ledger/templates/django_ledger/journal_entry/tags/je_txs_table.html +17 -14
  29. django_ledger/templates/django_ledger/ledger/tags/ledgers_table.html +38 -37
  30. django_ledger/templates/django_ledger/transactions/tags/txs_table.html +69 -0
  31. django_ledger/templatetags/django_ledger.py +24 -45
  32. django_ledger/urls/account.py +4 -4
  33. django_ledger/views/account.py +7 -7
  34. django_ledger/views/journal_entry.py +84 -101
  35. django_ledger/views/ledger.py +16 -21
  36. django_ledger/views/mixins.py +11 -10
  37. {django_ledger-0.7.3.dist-info → django_ledger-0.7.4.dist-info}/METADATA +8 -3
  38. {django_ledger-0.7.3.dist-info → django_ledger-0.7.4.dist-info}/RECORD +42 -40
  39. {django_ledger-0.7.3.dist-info → django_ledger-0.7.4.dist-info}/AUTHORS.md +0 -0
  40. {django_ledger-0.7.3.dist-info → django_ledger-0.7.4.dist-info}/LICENSE +0 -0
  41. {django_ledger-0.7.3.dist-info → django_ledger-0.7.4.dist-info}/WHEEL +0 -0
  42. {django_ledger-0.7.3.dist-info → django_ledger-0.7.4.dist-info}/top_level.txt +0 -0
@@ -34,7 +34,7 @@ from uuid import uuid4, UUID
34
34
 
35
35
  from django.core.exceptions import FieldError, ObjectDoesNotExist, ValidationError
36
36
  from django.db import models, transaction, IntegrityError
37
- from django.db.models import Q, Sum, QuerySet, F, Manager
37
+ from django.db.models import Q, Sum, QuerySet, F, Manager, Count
38
38
  from django.db.models.functions import Coalesce
39
39
  from django.db.models.signals import pre_save
40
40
  from django.urls import reverse
@@ -46,10 +46,12 @@ from django_ledger.io.roles import (
46
46
  ASSET_CA_CASH, GROUP_CFS_FIN_DIVIDENDS, GROUP_CFS_FIN_ISSUING_EQUITY,
47
47
  GROUP_CFS_FIN_LT_DEBT_PAYMENTS, GROUP_CFS_FIN_ST_DEBT_PAYMENTS,
48
48
  GROUP_CFS_INVESTING_AND_FINANCING, GROUP_CFS_INVESTING_PPE,
49
- GROUP_CFS_INVESTING_SECURITIES, validate_roles
49
+ GROUP_CFS_INVESTING_SECURITIES,
50
+ validate_roles
50
51
  )
51
52
  from django_ledger.models.accounts import CREDIT, DEBIT
52
53
  from django_ledger.models.entity import EntityStateModel, EntityModel
54
+ from django_ledger.models.ledger import LedgerModel
53
55
  from django_ledger.models.mixins import CreateUpdateMixIn
54
56
  from django_ledger.models.signals import (
55
57
  journal_entry_unlocked,
@@ -58,12 +60,12 @@ from django_ledger.models.signals import (
58
60
  journal_entry_unposted
59
61
  )
60
62
  from django_ledger.models.transactions import TransactionModelQuerySet, TransactionModel
61
- from django_ledger.models.utils import lazy_loader
62
63
  from django_ledger.settings import (
63
64
  DJANGO_LEDGER_JE_NUMBER_PREFIX,
64
65
  DJANGO_LEDGER_DOCUMENT_NUMBER_PADDING,
65
66
  DJANGO_LEDGER_JE_NUMBER_NO_UNIT_PREFIX
66
67
  )
68
+ from django_ledger.io import roles
67
69
 
68
70
 
69
71
  class JournalEntryValidationError(ValidationError):
@@ -72,167 +74,224 @@ class JournalEntryValidationError(ValidationError):
72
74
 
73
75
  class JournalEntryModelQuerySet(QuerySet):
74
76
  """
75
- Custom defined JournalEntryQuerySet.
77
+ A custom QuerySet for working with Journal Entry models, providing additional
78
+ convenience methods and validations for specific use cases.
79
+
80
+ This class enhances Django's default QuerySet by adding tailored methods
81
+ to manage and filter Journal Entries, such as handling posted, unposted,
82
+ locked entries, and querying entries associated with specific ledgers.
76
83
  """
77
84
 
78
85
  def create(self, verify_on_save: bool = False, force_create: bool = False, **kwargs):
79
86
  """
80
- Overrides the standard Django QuerySet create() method to avoid the creation of POSTED Journal Entries without
81
- proper business logic validation. New JEs using the create() method don't have any transactions to validate.
82
- therefore, it is not necessary to query DB to balance TXS
87
+ Creates a new Journal Entry while enforcing business logic validations.
88
+
89
+ This method overrides Django's default `create()` to ensure that Journal Entries
90
+ cannot be created in a "posted" state unless explicitly overridden.
91
+ Additionally, it offers optional pre-save verification.
83
92
 
84
93
  Parameters
85
94
  ----------
86
-
87
- verify_on_save: bool
88
- Executes a Journal Entry verification hook before saving. Avoids additional queries to
89
- validate the Journal Entry
90
-
91
- force_create: bool
92
- If True, will create return a new JournalEntryModel even if Posted at time of creation.
93
- Use only if you know what you are doing.
95
+ verify_on_save : bool
96
+ If True, performs a verification step before saving. This avoids
97
+ additional database queries for validation when creating new entries.
98
+ Should be used when the Journal Entry needs no transactional validation.
99
+ force_create : bool
100
+ If True, allows the creation of a Journal Entry even in a "posted"
101
+ state. Use with caution and only if you are certain of the consequences.
102
+ **kwargs : dict
103
+ Additional keyword arguments passed to instantiate the Journal Entry model.
94
104
 
95
105
  Returns
96
106
  -------
97
107
  JournalEntryModel
98
- The newly created Journal Entry Model.
108
+ The newly created Journal Entry.
109
+
110
+ Raises
111
+ ------
112
+ FieldError
113
+ Raised if attempting to create a "posted" Journal Entry without
114
+ setting `force_create=True`.
99
115
  """
100
116
  is_posted = kwargs.get('posted')
101
-
102
117
  if is_posted and not force_create:
103
- raise FieldError('Cannot create Journal Entries as posted')
118
+ raise FieldError("Cannot create Journal Entries in a posted state without 'force_create=True'.")
104
119
 
105
120
  obj = self.model(**kwargs)
106
121
  self._for_write = True
107
122
 
108
- # verify_on_save option avoids additional queries to validate the journal entry.
109
- # New JEs using the create() method don't have any transactions to validate.
110
- # therefore, it is not necessary to query DB to balance TXS.
123
+ # Save the object with optional pre-save verification.
111
124
  obj.save(force_insert=True, using=self.db, verify=verify_on_save)
112
125
  return obj
113
126
 
114
127
  def posted(self):
115
128
  """
116
- Filters the QuerySet to only posted Journal Entries.
129
+ Filters the QuerySet to include only "posted" Journal Entries.
117
130
 
118
131
  Returns
119
132
  -------
120
133
  JournalEntryModelQuerySet
121
- A QuerySet with applied filters.
134
+ A filtered QuerySet containing only posted Journal Entries.
122
135
  """
123
136
  return self.filter(posted=True)
124
137
 
125
138
  def unposted(self):
139
+ """
140
+ Filters the QuerySet to include only "unposted" Journal Entries.
141
+
142
+ Returns
143
+ -------
144
+ JournalEntryModelQuerySet
145
+ A filtered QuerySet containing only unposted Journal Entries.
146
+ """
126
147
  return self.filter(posted=False)
127
148
 
128
149
  def locked(self):
129
150
  """
130
- Filters the QuerySet to only locked Journal Entries.
151
+ Filters the QuerySet to include only "locked" Journal Entries.
131
152
 
132
153
  Returns
133
154
  -------
134
155
  JournalEntryModelQuerySet
135
- A QuerySet with applied filters.
156
+ A filtered QuerySet containing only locked Journal Entries.
136
157
  """
137
-
138
158
  return self.filter(locked=True)
139
159
 
140
160
  def unlocked(self):
161
+ """
162
+ Filters the QuerySet to include only "unlocked" Journal Entries.
163
+
164
+ Returns
165
+ -------
166
+ JournalEntryModelQuerySet
167
+ A filtered QuerySet containing only unlocked Journal Entries.
168
+ """
141
169
  return self.filter(locked=False)
142
170
 
171
+ def for_ledger(self, ledger_pk: Union[str, UUID, LedgerModel]):
172
+ """
173
+ Filters the QuerySet to include Journal Entries associated with a specific Ledger.
174
+
175
+ Parameters
176
+ ----------
177
+ ledger_pk : str, UUID, or LedgerModel
178
+ The LedgerModel instance, its UUID, or a string representation of the UUID
179
+ to identify the Ledger.
180
+
181
+ Returns
182
+ -------
183
+ JournalEntryModelQuerySet
184
+ A filtered QuerySet of Journal Entries associated with the specified Ledger.
185
+ """
186
+ if isinstance(ledger_pk, LedgerModel):
187
+ return self.filter(ledger=ledger_pk)
188
+ return self.filter(ledger__uuid__exact=ledger_pk)
189
+
143
190
 
144
191
  class JournalEntryModelManager(Manager):
145
192
  """
146
- A custom defined Journal Entry Model Manager that supports additional complex initial Queries based on the
147
- EntityModel and authenticated UserModel.
193
+ A custom manager for the JournalEntryModel that extends Django's default
194
+ Manager with additional query features. It allows complex query handling
195
+ based on relationships to the `EntityModel` and the authenticated `UserModel`.
196
+
197
+ This manager provides utility methods for generating filtered querysets
198
+ (e.g., entries associated with specific users or entities), as well as
199
+ annotations for convenience in query results.
148
200
  """
149
201
 
150
- def for_user(self, user_model):
151
- qs = self.get_queryset()
152
- if user_model.is_superuser:
153
- return qs
154
- return qs.filter(
155
- Q(ledger__entity__admin=user_model) |
156
- Q(ledger__entity__managers__in=[user_model])
202
+ def get_queryset(self) -> JournalEntryModelQuerySet:
203
+ """
204
+ Returns the default queryset for JournalEntryModel with additional
205
+ annotations applied.
206
+
207
+ Annotations:
208
+ - `_entity_slug`: The slug of the related `EntityModel`.
209
+ - `txs_count`: The count of transactions (related `TransactionModel` instances)
210
+ for each journal entry.
211
+
212
+ Returns
213
+ -------
214
+ JournalEntryModelQuerySet
215
+ A custom queryset enhanced with annotations.
216
+ """
217
+ qs = JournalEntryModelQuerySet(self.model, using=self._db)
218
+ return qs.annotate(
219
+ _entity_uuid=F('ledger__entity_id'),
220
+ _entity_slug=F('ledger__entity__slug'), # Annotates the entity slug
221
+ _entity_last_closing_date=F('ledger__entity__last_closing_date'),
222
+ _ledger_is_locked=F('ledger__locked'),
223
+ txs_count=Count('transactionmodel') # Annotates the count of transactions
157
224
  )
158
225
 
159
- def for_entity(self, entity_slug, user_model):
226
+ def for_user(self, user_model) -> JournalEntryModelQuerySet:
160
227
  """
161
- Fetches a QuerySet of JournalEntryModels associated with a specific EntityModel & UserModel.
162
- May pass an instance of EntityModel or a String representing the EntityModel slug.
228
+ Filters the JournalEntryModel queryset for the given user.
229
+
230
+ - Superusers will have access to all journal entries.
231
+ - Other authenticated users will only see entries for entities where
232
+ they are admins or managers.
163
233
 
164
234
  Parameters
165
235
  ----------
166
- entity_slug: str or EntityModel
167
- The entity slug or EntityModel used for filtering the QuerySet.
168
- user_model
169
- Logged in and authenticated django UserModel instance.
170
-
171
- Examples
172
- --------
173
- >>> request_user = request.user
174
- >>> slug = kwargs['entity_slug'] # may come from request kwargs
175
- >>> journal_entry_qs = JournalEntryModel.objects.for_entity(user_model=request_user, entity_slug=slug)
236
+ user_model : UserModel
237
+ An authenticated Django user object.
176
238
 
177
239
  Returns
178
- _______
240
+ -------
179
241
  JournalEntryModelQuerySet
180
- Returns a JournalEntryModelQuerySet with applied filters.
242
+ A filtered queryset restricted by the user's entity relationships.
181
243
  """
182
- qs = self.for_user(user_model)
183
- if isinstance(entity_slug, lazy_loader.get_entity_model()):
184
- return qs.filter(
185
- Q(ledger__entity=entity_slug)
186
- )
187
- return self.get_queryset().filter(
188
- Q(ledger__entity__slug__iexact=entity_slug)
244
+ qs = self.get_queryset()
245
+ if user_model.is_superuser:
246
+ return qs
247
+
248
+ return qs.filter(
249
+ Q(ledger__entity__admin=user_model) | # Entries for entities where the user is admin
250
+ Q(ledger__entity__managers__in=[user_model]) # Entries for entities where the user is a manager
189
251
  )
190
252
 
191
- def for_ledger(self, ledger_pk: Union[str, UUID], entity_slug, user_model):
253
+ def for_entity(self, entity_slug: Union[str, EntityModel], user_model) -> JournalEntryModelQuerySet:
192
254
  """
193
- Fetches a QuerySet of JournalEntryModels associated with a specific EntityModel & UserModel & LedgerModel.
194
- May pass an instance of EntityModel or a String representing the EntityModel slug.
255
+ Filters the JournalEntryModel queryset for a specific entity and user.
256
+
257
+ This method provides a way to fetch journal entries related to a specific
258
+ `EntityModel`, identified by its slug or model instance, with additional
259
+ filtering scoped to the user.
195
260
 
196
261
  Parameters
197
- __________
198
- entity_slug: str or EntityModel
199
- The entity slug or EntityModel used for filtering the QuerySet.
200
- user_model
201
- Logged in and authenticated django UserModel instance.
202
- ledger_pk: str or UUID
203
- The LedgerModel uuid as a string or UUID.
204
-
205
- Examples
206
- ________
207
- >>> request_user = request.user
208
- >>> slug = kwargs['entity_slug'] # may come from request kwargs
209
- >>> ledger_pk = kwargs['ledger_pk'] # may come from request kwargs
210
- >>> journal_entry_qs = JournalEntryModel.objects.for_ledger(ledger_pk=ledger_pk, user_model=request_user, entity_slug=slug)
262
+ ----------
263
+ entity_slug : str or EntityModel
264
+ The slug of the entity (or an instance of `EntityModel`) used for filtering.
265
+ user_model : UserModel
266
+ An authenticated Django user object.
211
267
 
212
268
  Returns
213
- _______
269
+ -------
214
270
  JournalEntryModelQuerySet
215
- Returns a JournalEntryModelQuerySet with applied filters.
271
+ A customized queryset containing journal entries associated with the
272
+ given entity and restricted by the user's access permissions.
216
273
  """
217
- qs = self.for_entity(entity_slug=entity_slug, user_model=user_model)
218
- return qs.filter(ledger__uuid__exact=ledger_pk)
274
+ qs = self.for_user(user_model)
275
+
276
+ # Handle the `entity_slug` as either a string or an EntityModel instance
277
+ if isinstance(entity_slug, EntityModel):
278
+ return qs.filter(ledger__entity=entity_slug)
279
+
280
+ return qs.filter(ledger__entity__slug__iexact=entity_slug) # Case-insensitive slug match
219
281
 
220
282
 
221
283
  class ActivityEnum(Enum):
222
284
  """
223
- The database string representation of each accounting activity prefix in the database.
285
+ Represents the database prefixes used for different types of accounting activities.
224
286
 
225
287
  Attributes
226
- __________
227
-
228
- OPERATING: str
229
- The database representation prefix of a Journal Entry that is an Operating Activity.
230
-
231
- INVESTING: str
232
- The database representation prefix of a Journal Entry that is an Investing Activity.
233
-
234
- FINANCING: str
235
- The database representation prefix of a Journal Entry that is an Financing Activity.
288
+ ----------
289
+ OPERATING : str
290
+ Prefix for a Journal Entry categorized as an Operating Activity.
291
+ INVESTING : str
292
+ Prefix for a Journal Entry categorized as an Investing Activity.
293
+ FINANCING : str
294
+ Prefix for a Journal Entry categorized as a Financing Activity.
236
295
  """
237
296
  OPERATING = 'op'
238
297
  INVESTING = 'inv'
@@ -241,51 +300,50 @@ class ActivityEnum(Enum):
241
300
 
242
301
  class JournalEntryModelAbstract(CreateUpdateMixIn):
243
302
  """
244
- The base implementation of the JournalEntryModel.
303
+ Abstract base model for handling journal entries in the bookkeeping system.
245
304
 
246
305
  Attributes
247
306
  ----------
248
- uuid: UUID
249
- This is a unique primary key generated for the table. The default value of this field is uuid4().
250
- je_number: str
251
- A unique, sequential, human-readable alphanumeric Journal Entry Number (a.k.a Voucher or Document Number in
252
- other commercial bookkeeping software). Contains the fiscal year under which the JE takes place within the
253
- EntityModel as a prefix.
254
- timestamp: datetime
255
- The date of the JournalEntryModel. This date is applied to all TransactionModels contained within the JE, and
256
- drives the financial statements of the EntityModel.
257
- description: str
258
- A user defined description for the JournalEntryModel.
259
- entity_unit: EntityUnitModel
260
- A logical, self-contained, user defined class or structure defined withing the EntityModel.
261
- See EntityUnitModel documentation for more details.
262
- activity: str
263
- Programmatically determined based on the JE transactions and must be a value from ACTIVITIES. Gives
264
- additional insight of the nature of the JournalEntryModel in order to produce the Statement of Cash Flows for the
265
- EntityModel.
266
- origin: str
267
- A string giving additional information behind the origin or trigger of the JournalEntryModel.
268
- For example: reconciliations, migrations, auto-generated, etc. Any string value is valid. Max 30 characters.
269
- posted: bool
270
- Determines if the JournalLedgerModel is posted, which means is affecting the books. Defaults to False.
271
- locked: bool
272
- Determines if the JournalEntryModel is locked, which the creation or updates of new transactions are not
273
- allowed.
274
- ledger: LedgerModel
275
- The LedgerModel associated with this JournalEntryModel. Cannot be null.
307
+ uuid : UUID
308
+ A unique identifier (primary key) for the journal entry, generated using uuid4().
309
+ je_number : str
310
+ A human-readable, unique, alphanumeric identifier for the journal entry (e.g., Voucher or Document Number).
311
+ Includes the fiscal year as a prefix for organizational purposes.
312
+ timestamp : datetime
313
+ The date of the journal entry, used for financial statements. This timestamp applies to associated transactions.
314
+ description : str
315
+ An optional user-defined description for the journal entry.
316
+ entity_unit : EntityUnitModel
317
+ A reference to a logical and self-contained structure within the `EntityModel`.
318
+ Provides context for the journal entry. See `EntityUnitModel` documentation for details.
319
+ activity : str
320
+ Indicates the nature of the activity associated with the journal entry.
321
+ Must be one of the predefined `ACTIVITIES` (e.g., Operating, Financing, Investing) and is programmatically determined.
322
+ origin : str
323
+ Describes the origin or trigger for the journal entry (e.g., reconciliations, migrations, auto-generated).
324
+ Max length: 30 characters.
325
+ posted : bool
326
+ Determines whether the journal entry has been posted (affecting the books). Defaults to `False`.
327
+ locked : bool
328
+ Indicates whether the journal entry is locked, preventing further modifications. Defaults to `False`.
329
+ ledger : LedgerModel
330
+ A reference to the LedgerModel associated with this journal entry. This field is mandatory.
331
+ is_closing_entry : bool
332
+ Indicates if the journal entry is a closing entry. Defaults to `False`.
276
333
  """
334
+
335
+ # Constants for activity types
277
336
  OPERATING_ACTIVITY = ActivityEnum.OPERATING.value
278
337
  FINANCING_OTHER = ActivityEnum.FINANCING.value
279
338
  INVESTING_OTHER = ActivityEnum.INVESTING.value
280
-
281
339
  INVESTING_SECURITIES = f'{ActivityEnum.INVESTING.value}_securities'
282
340
  INVESTING_PPE = f'{ActivityEnum.INVESTING.value}_ppe'
283
-
284
341
  FINANCING_STD = f'{ActivityEnum.FINANCING.value}_std'
285
342
  FINANCING_LTD = f'{ActivityEnum.FINANCING.value}_ltd'
286
343
  FINANCING_EQUITY = f'{ActivityEnum.FINANCING.value}_equity'
287
344
  FINANCING_DIVIDENDS = f'{ActivityEnum.FINANCING.value}_dividends'
288
345
 
346
+ # Activity categories for dropdown
289
347
  ACTIVITIES = [
290
348
  (_('Operating'), (
291
349
  (OPERATING_ACTIVITY, _('Operating')),
@@ -304,36 +362,43 @@ class JournalEntryModelAbstract(CreateUpdateMixIn):
304
362
  )),
305
363
  ]
306
364
 
365
+ # Utility mappings for activity validation
307
366
  VALID_ACTIVITIES = list(chain.from_iterable([[a[0] for a in cat[1]] for cat in ACTIVITIES]))
308
367
  MAP_ACTIVITIES = dict(chain.from_iterable([[(a[0], cat[0]) for a in cat[1]] for cat in ACTIVITIES]))
309
368
  NON_OPERATIONAL_ACTIVITIES = [a for a in VALID_ACTIVITIES if ActivityEnum.OPERATING.value not in a]
310
369
 
370
+ # Field definitions
311
371
  uuid = models.UUIDField(default=uuid4, editable=False, primary_key=True)
312
372
  je_number = models.SlugField(max_length=25, editable=False, verbose_name=_('Journal Entry Number'))
313
373
  timestamp = models.DateTimeField(verbose_name=_('Timestamp'), default=localtime)
314
374
  description = models.CharField(max_length=70, blank=True, null=True, verbose_name=_('Description'))
315
- entity_unit = models.ForeignKey('django_ledger.EntityUnitModel',
316
- on_delete=models.RESTRICT,
317
- blank=True,
318
- null=True,
319
- verbose_name=_('Associated Entity Unit'))
320
- activity = models.CharField(choices=ACTIVITIES,
321
- max_length=20,
322
- null=True,
323
- blank=True,
324
- editable=False,
325
- verbose_name=_('Activity'))
375
+ entity_unit = models.ForeignKey(
376
+ 'django_ledger.EntityUnitModel',
377
+ on_delete=models.RESTRICT,
378
+ blank=True,
379
+ null=True,
380
+ verbose_name=_('Associated Entity Unit')
381
+ )
382
+ activity = models.CharField(
383
+ choices=ACTIVITIES,
384
+ max_length=20,
385
+ null=True,
386
+ blank=True,
387
+ editable=False,
388
+ verbose_name=_('Activity')
389
+ )
326
390
  origin = models.CharField(max_length=30, blank=True, null=True, verbose_name=_('Origin'))
327
391
  posted = models.BooleanField(default=False, verbose_name=_('Posted'))
328
392
  locked = models.BooleanField(default=False, verbose_name=_('Locked'))
329
393
  is_closing_entry = models.BooleanField(default=False)
330
-
331
- # todo: rename to ledger_model?
332
- ledger = models.ForeignKey('django_ledger.LedgerModel',
333
- verbose_name=_('Ledger'),
334
- related_name='journal_entries',
335
- on_delete=models.CASCADE)
336
-
394
+ ledger = models.ForeignKey(
395
+ 'django_ledger.LedgerModel',
396
+ verbose_name=_('Ledger'),
397
+ related_name='journal_entries',
398
+ on_delete=models.CASCADE
399
+ )
400
+
401
+ # Custom manager
337
402
  objects = JournalEntryModelManager.from_queryset(queryset_class=JournalEntryModelQuerySet)()
338
403
 
339
404
  class Meta:
@@ -352,157 +417,225 @@ class JournalEntryModelAbstract(CreateUpdateMixIn):
352
417
  models.Index(fields=['is_closing_entry']),
353
418
  ]
354
419
 
355
- def __str__(self):
356
- if self.je_number:
357
- return 'JE: {x1} (posted={p}, locked={l}) - Desc: {x2}'.format(
358
- x1=self.je_number,
359
- x2=self.description,
360
- p=self.posted,
361
- l=self.locked
362
- )
363
- return 'JE ID: {x1} (posted={p}, locked={l}) - Desc: {x2}'.format(
364
- x1=self.pk,
365
- x2=self.description,
366
- p=self.posted,
367
- l=self.locked
368
- )
369
-
370
420
  def __init__(self, *args, **kwargs):
371
421
  super().__init__(*args, **kwargs)
372
422
  self._verified = False
373
- self._last_closing_date: Optional[date] = None
374
423
 
375
- def can_post(self, ignore_verify: bool = True) -> bool:
424
+ def __str__(self):
425
+ if self.je_number:
426
+ return f"JE: {self.je_number} (posted={self.posted}, locked={self.locked}) - Desc: {self.description or ''}"
427
+ return f"JE ID: {self.pk} (posted={self.posted}, locked={self.locked}) - Desc: {self.description or ''}"
428
+
429
+ @property
430
+ def entity_uuid(self):
431
+ try:
432
+ return getattr(self, '_entity_uuid')
433
+ except AttributeError:
434
+ pass
435
+ return self.ledger.entity_id
436
+
437
+ @property
438
+ def entity_slug(self):
376
439
  """
377
- Determines if a JournalEntryModel can be posted.
440
+ Retrieves the unique slug associated with the entity.
441
+
442
+ The property first attempts to return the value stored in the `_entity_slug`
443
+ attribute if it exists. If `_entity_slug` is not set, it falls back to the
444
+ `ledger.entity.slug` attribute.
445
+
446
+ Returns:
447
+ str: The slug value from `_entity_slug` if available, or `ledger.entity.slug` otherwise.
448
+ """
449
+ try:
450
+ return getattr(self, '_entity_slug')
451
+ except AttributeError:
452
+ pass
453
+ return self.ledger.entity.slug
454
+
455
+ @property
456
+ def entity_last_closing_date(self) -> Optional[date]:
457
+ """
458
+ Retrieves the last closing date for an entity, if available.
459
+
460
+ This property returns the date of the most recent closing event
461
+ associated with the entity. If no closing date exists, the
462
+ result will be None.
463
+
464
+ Returns
465
+ -------
466
+ Optional[date]
467
+ The date of the last closing event, or None if no closing
468
+ date is available.
469
+ """
470
+ return self.get_entity_last_closing_date()
471
+
472
+ def validate_for_entity(self, entity_model: Union[EntityModel, str, UUID], raise_exception: bool = True) -> bool:
473
+ """
474
+ Validates whether the given entity_model owns thr Journal Entry instance.
475
+
476
+ This method checks if the provided entity_model owns the Journal Entry model instance.
477
+ The entity_model can be of type `EntityModel`, `str`, or
478
+ `UUID`. The method performs type-specific checks to ensure proper validation
479
+ and returns the validation result.
378
480
 
379
481
  Parameters
380
482
  ----------
381
- ignore_verify: bool
382
- Skips JournalEntryModel verification if True. Defaults to False.
483
+ entity_model : Union[EntityModel, str, UUID]
484
+ The entity to validate against. It can either be an instance of the
485
+ `EntityModel`, a string representation of a UUID, or a UUID object.
383
486
 
384
487
  Returns
385
488
  -------
386
489
  bool
387
- True if JournalEntryModel can be posted, otherwise False.
490
+ A boolean value. True if the given entity_model corresponds to the current
491
+ entity's UUID, otherwise False.
388
492
  """
493
+ if isinstance(entity_model, str):
494
+ is_valid = str(self.entity_uuid) == entity_model
495
+ elif isinstance(entity_model, UUID):
496
+ is_valid = self.entity_uuid == entity_model
497
+ else:
498
+ is_valid = self.entity_uuid == entity_model.uuid
389
499
 
390
- return all([
391
- self.is_locked(),
392
- not self.is_posted(),
393
- self.is_verified() if not ignore_verify else True,
394
- not self.ledger.is_locked(),
395
- not self.is_in_locked_period()
396
- ])
500
+ if not is_valid and raise_exception:
501
+ raise JournalEntryValidationError(
502
+ message='The Journal Entry does not belong to the provided entity.'
503
+ )
504
+ return is_valid
397
505
 
398
- def can_unpost(self) -> bool:
506
+ def ledger_is_locked(self):
399
507
  """
400
- Determines if a JournalEntryModel can be un-posted.
508
+ Determines whether the ledger is locked.
509
+
510
+ This method checks the current state of the ledger to determine if it is
511
+ locked and unavailable for further operations. It looks for an annotated
512
+ attribute `_ledger_is_locked` and returns its value if found. If the
513
+ attribute is not set, it delegates the check to the actual `is_locked`
514
+ method of the `ledger` object.
401
515
 
402
516
  Returns
403
517
  -------
404
518
  bool
405
- True if JournalEntryModel can be un-posted, otherwise False.
519
+ A boolean value indicating whether the ledger is locked.
406
520
  """
521
+ try:
522
+ return getattr(self, '_ledger_is_locked')
523
+ except AttributeError:
524
+ pass
525
+ return self.ledger.is_locked()
526
+
527
+ def can_post(self, ignore_verify: bool = True) -> bool:
528
+ """Determines if the journal entry can be posted."""
529
+ return all([
530
+ self.is_locked(),
531
+ not self.is_posted(),
532
+ self.is_verified() if not ignore_verify else True, # avoids db queries, will be verified before saving
533
+ not self.ledger_is_locked(),
534
+ not self.is_in_locked_period()
535
+ ])
536
+
537
+ def can_unpost(self) -> bool:
538
+ """Checks if the journal entry can be un-posted."""
407
539
  return all([
408
540
  self.is_posted(),
409
- not self.ledger.is_locked(),
541
+ not self.ledger_is_locked(),
410
542
  not self.is_in_locked_period()
411
543
  ])
412
544
 
413
545
  def can_lock(self) -> bool:
414
- """
415
- Determines if a JournalEntryModel can be locked.
416
- Locked JournalEntryModels cannot be modified.
417
-
418
- Returns
419
- -------
420
- bool
421
- True if JournalEntryModel can be locked, otherwise False.
422
- """
546
+ """Determines if the journal entry can be locked."""
423
547
  return all([
424
548
  not self.is_locked(),
425
- not self.ledger.is_locked()
549
+ not self.ledger_is_locked()
426
550
  ])
427
551
 
428
552
  def can_unlock(self) -> bool:
429
- """
430
- Determines if a JournalEntryModel can be un-locked.
431
- Locked transactions cannot be modified.
432
-
433
- Returns
434
- -------
435
- bool
436
- True if JournalEntryModel can be un-locked, otherwise False.
437
- """
553
+ """Checks if the journal entry can be unlocked."""
438
554
  return all([
439
555
  self.is_locked(),
440
556
  not self.is_posted(),
441
557
  not self.is_in_locked_period(),
442
- not self.ledger.is_locked()
558
+ not self.ledger_is_locked()
443
559
  ])
444
560
 
445
561
  def can_delete(self) -> bool:
562
+ """Checks if the journal entry can be deleted."""
446
563
  return all([
447
564
  not self.is_locked(),
448
565
  not self.is_posted(),
449
566
  ])
450
567
 
451
568
  def can_edit(self) -> bool:
452
- return not self.is_locked()
569
+ """Checks if the journal entry is editable."""
570
+ return all([
571
+ not self.is_locked(),
572
+
573
+ ])
453
574
 
454
- def is_posted(self):
575
+ def is_posted(self) -> bool:
576
+ """Returns whether the journal entry has been posted."""
455
577
  return self.posted is True
456
578
 
457
579
  def is_in_locked_period(self, new_timestamp: Optional[Union[date, datetime]] = None) -> bool:
458
- last_closing_date = self.get_entity_last_closing_date()
580
+ """
581
+ Checks if the current Journal Entry falls within a locked period.
582
+
583
+ Parameters
584
+ ----------
585
+ new_timestamp: Optional[Union[date, datetime]])
586
+ An optional date or timestamp to be checked instead of the current timestamp.
587
+
588
+ Returns
589
+ -------
590
+ bool: True if the Journal Entry is in a locked period, otherwise False.
591
+ """
592
+ last_closing_date = self.entity_last_closing_date
459
593
  if last_closing_date is not None:
460
594
  if not new_timestamp:
461
595
  return last_closing_date >= self.timestamp.date()
462
596
  elif isinstance(new_timestamp, datetime):
463
597
  return last_closing_date >= new_timestamp.date()
464
- else:
465
- return last_closing_date >= new_timestamp
598
+ return last_closing_date >= new_timestamp
466
599
  return False
467
600
 
468
- def is_locked(self):
469
- if self.is_posted():
470
- return True
471
- return any([
472
- self.locked is True,
473
- any([
474
- self.is_in_locked_period(),
475
- self.ledger.is_locked()
476
- ])
601
+ def is_locked(self) -> bool:
602
+ """
603
+ Determines if the Journal Entry is locked.
604
+
605
+ A Journal Entry is considered locked if it is posted, explicitly marked
606
+ as locked, falls within a locked period, or the associated ledger is locked.
607
+
608
+ Returns:
609
+ bool: True if the Journal Entry is locked, otherwise False.
610
+ """
611
+ return self.is_posted() or any([
612
+ self.locked,
613
+ self.is_in_locked_period(),
614
+ self.ledger_is_locked()
477
615
  ])
478
616
 
479
617
  def is_verified(self) -> bool:
480
618
  """
481
- Determines if the JournalEntryModel is verified.
619
+ Checks if the Journal Entry is verified.
482
620
 
483
- Returns
484
- -------
485
- bool
486
- True if is verified, otherwise False.
621
+ Returns:
622
+ bool: True if the Journal Entry is verified, otherwise False.
487
623
  """
488
624
  return self._verified
489
625
 
490
- # Transaction QuerySet
491
626
  def is_balance_valid(self, txs_qs: TransactionModelQuerySet, raise_exception: bool = True) -> bool:
492
627
  """
493
- Checks if CREDITs and DEBITs are equal.
628
+ Validates whether the DEBITs and CREDITs of the transactions balance correctly.
494
629
 
495
- Parameters
496
- ----------
497
- txs_qs: TransactionModelQuerySet
630
+ Parameters:
631
+ txs_qs (TransactionModelQuerySet): A QuerySet containing transactions to validate.
632
+ raise_exception (bool): Whether to raise a JournalEntryValidationError if the validation fails.
498
633
 
499
- raise_exception: bool
500
- Raises JournalEntryValidationError if TransactionModelQuerySet is not valid.
501
-
502
- Returns
503
- -------
504
- bool
505
- True if JE balances are valid (i.e. are equal).
634
+ Returns:
635
+ bool: True if the transactions are balanced, otherwise False.
636
+
637
+ Raises:
638
+ JournalEntryValidationError: If the transactions are not balanced and raise_exception is True.
506
639
  """
507
640
  if len(txs_qs) > 0:
508
641
  balances = self.get_txs_balances(txs_qs=txs_qs, as_dict=True)
@@ -519,65 +652,78 @@ class JournalEntryModelAbstract(CreateUpdateMixIn):
519
652
  return is_valid
520
653
  return True
521
654
 
522
- def is_txs_qs_coa_valid(self, txs_qs: TransactionModelQuerySet) -> bool:
655
+ def is_txs_qs_coa_valid(self, txs_qs: TransactionModelQuerySet, raise_exception: bool = True) -> bool:
523
656
  """
524
- Validates that the Chart of Accounts (COA) is valid for the transactions.
525
- Journal Entry transactions can only be associated with one Chart of Accounts (COA).
526
-
527
-
528
- Parameters
529
- ----------
530
- txs_qs: TransactionModelQuerySet
657
+ Validates that all transactions in the QuerySet are associated with the same Chart of Accounts (COA).
531
658
 
532
- Returns
533
- -------
534
- True if Transaction CoAs are valid, otherwise False.
535
- """
659
+ Parameters:
660
+ txs_qs (TransactionModelQuerySet): A QuerySet containing transactions to validate.
536
661
 
662
+ Returns:
663
+ bool: True if all transactions have the same Chart of Accounts, otherwise False.
664
+ """
537
665
  if len(txs_qs) > 0:
538
666
  coa_count = len(set(tx.coa_id for tx in txs_qs))
539
- return coa_count == 1
667
+ is_valid = coa_count == 1
668
+ if not is_valid and raise_exception:
669
+ raise JournalEntryValidationError(
670
+ message='All transactions in the QuerySet must be associated with the same Chart of Accounts.'
671
+ )
672
+ return is_valid
540
673
  return True
541
674
 
542
675
  def is_txs_qs_valid(self, txs_qs: TransactionModelQuerySet, raise_exception: bool = True) -> bool:
543
676
  """
544
- Validates a given TransactionModelQuerySet against the JournalEntryModel instance.
677
+ Validates whether the given Transaction QuerySet belongs to the current Journal Entry.
545
678
 
546
- Parameters
547
- ----------
548
- txs_qs: TransactionModelQuerySet
549
- The queryset to validate.
679
+ Parameters:
680
+ txs_qs (TransactionModelQuerySet): A QuerySet containing transactions to validate.
681
+ raise_exception (bool): Whether to raise a JournalEntryValidationError if the validation fails.
550
682
 
551
- raise_exception: bool
552
- Raises JournalEntryValidationError if TransactionModelQuerySet is not valid.
553
-
554
- Raises
555
- ------
556
- JournalEntryValidationError if JE model is invalid and raise_exception is True.
683
+ Returns:
684
+ bool: True if all transactions belong to the Journal Entry, otherwise False.
557
685
 
558
- Returns
559
- -------
560
- bool
561
- True if valid, otherwise False.
686
+ Raises:
687
+ JournalEntryValidationError: If validation fails and raise_exception is True.
562
688
  """
563
689
  if not isinstance(txs_qs, TransactionModelQuerySet):
564
690
  raise JournalEntryValidationError('Must pass an instance of TransactionModelQuerySet')
565
691
 
566
692
  is_valid = all(tx.journal_entry_id == self.uuid for tx in txs_qs)
567
693
  if not is_valid and raise_exception:
568
- raise JournalEntryValidationError('Invalid TransactionModelQuerySet provided. All Transactions must be ',
569
- f'associated with LedgerModel {self.uuid}')
694
+ raise JournalEntryValidationError(
695
+ f'Invalid TransactionModelQuerySet. All transactions must be associated with Journal Entry {self.uuid}.'
696
+ )
570
697
  return is_valid
571
698
 
572
- def is_cash_involved(self, txs_qs=None):
573
- return ASSET_CA_CASH in self.get_txs_roles(txs_qs=None)
699
+ def is_cash_involved(self, txs_qs: Optional[TransactionModelQuerySet] = None) -> bool:
700
+ """
701
+ Checks if the transactions involve cash assets.
574
702
 
575
- def is_operating(self):
576
- return self.activity in [
577
- self.OPERATING_ACTIVITY
578
- ]
703
+ Parameters:
704
+ txs_qs (Optional[TransactionModelQuerySet]): Transactions to evaluate. If None, defaults to class behavior.
579
705
 
580
- def is_financing(self):
706
+ Returns:
707
+ bool: True if cash assets are involved, otherwise False.
708
+ """
709
+ return roles.ASSET_CA_CASH in self.get_txs_roles(txs_qs)
710
+
711
+ def is_operating(self) -> bool:
712
+ """
713
+ Checks if the Journal Entry is categorized as an operating activity.
714
+
715
+ Returns:
716
+ bool: True if the activity is operating, otherwise False.
717
+ """
718
+ return self.activity in [self.OPERATING_ACTIVITY]
719
+
720
+ def is_financing(self) -> bool:
721
+ """
722
+ Checks if the Journal Entry is categorized as a financing activity.
723
+
724
+ Returns:
725
+ bool: True if the activity is financing, otherwise False.
726
+ """
581
727
  return self.activity in [
582
728
  self.FINANCING_EQUITY,
583
729
  self.FINANCING_LTD,
@@ -586,19 +732,44 @@ class JournalEntryModelAbstract(CreateUpdateMixIn):
586
732
  self.FINANCING_OTHER
587
733
  ]
588
734
 
589
- def is_investing(self):
735
+ def is_investing(self) -> bool:
736
+ """
737
+ Checks if the Journal Entry is categorized as an investing activity.
738
+
739
+ Returns:
740
+ bool: True if the activity is investing, otherwise False.
741
+ """
590
742
  return self.activity in [
591
743
  self.INVESTING_SECURITIES,
592
744
  self.INVESTING_PPE,
593
745
  self.INVESTING_OTHER
594
746
  ]
595
747
 
596
- def get_entity_unit_name(self, no_unit_name: str = ''):
748
+ def get_entity_unit_name(self, no_unit_name: str = "") -> str:
749
+ """
750
+ Retrieves the name of the entity unit associated with the Journal Entry.
751
+
752
+ Parameters:
753
+ no_unit_name (str): The fallback name to return if no unit is associated.
754
+
755
+ Returns:
756
+ str: The name of the entity unit, or the fallback provided.
757
+ """
597
758
  if self.entity_unit_id:
598
759
  return self.entity_unit.name
599
760
  return no_unit_name
600
761
 
601
762
  def get_entity_last_closing_date(self) -> Optional[date]:
763
+ """
764
+ Retrieves the last closing date for the entity associated with the Journal Entry.
765
+
766
+ Returns:
767
+ Optional[date]: The last closing date if one exists, otherwise None.
768
+ """
769
+ try:
770
+ return getattr(self, '_entity_last_closing_date')
771
+ except AttributeError:
772
+ pass
602
773
  return self.ledger.entity.last_closing_date
603
774
 
604
775
  def mark_as_posted(self,
@@ -609,21 +780,16 @@ class JournalEntryModelAbstract(CreateUpdateMixIn):
609
780
  **kwargs):
610
781
  """
611
782
  Posted transactions show on the EntityModel ledger and financial statements.
612
-
613
783
  Parameters
614
784
  ----------
615
785
  commit: bool
616
786
  Commits changes into the Database, Defaults to False.
617
-
618
787
  verify: bool
619
788
  Verifies JournalEntryModel before marking as posted. Defaults to False.
620
-
621
789
  force_lock: bool
622
790
  Forces to lock the JournalEntry before is posted.
623
-
624
791
  raise_exception: bool
625
792
  Raises JournalEntryValidationError if cannot post. Defaults to False.
626
-
627
793
  kwargs: dict
628
794
  Additional keyword arguments.
629
795
  """
@@ -635,7 +801,6 @@ class JournalEntryModelAbstract(CreateUpdateMixIn):
635
801
  message=_('Cannot post an empty Journal Entry.')
636
802
  )
637
803
  return
638
-
639
804
  if force_lock and not self.is_locked():
640
805
  try:
641
806
  self.mark_as_locked(commit=False, raise_exception=True)
@@ -643,13 +808,11 @@ class JournalEntryModelAbstract(CreateUpdateMixIn):
643
808
  if raise_exception:
644
809
  raise e
645
810
  return
646
-
647
811
  if not self.can_post(ignore_verify=False):
648
812
  if raise_exception:
649
813
  raise JournalEntryValidationError(f'Journal Entry {self.uuid} cannot post.'
650
814
  f' Is verified: {self.is_verified()}')
651
815
  return
652
-
653
816
  if not self.is_posted():
654
817
  self.posted = True
655
818
  if self.is_posted():
@@ -680,10 +843,8 @@ class JournalEntryModelAbstract(CreateUpdateMixIn):
680
843
  ----------
681
844
  commit: bool
682
845
  Commits changes into the Database, Defaults to False.
683
-
684
846
  raise_exception: bool
685
847
  Raises JournalEntryValidationError if cannot post. Defaults to False.
686
-
687
848
  kwargs: dict
688
849
  Additional keyword arguments.
689
850
  """
@@ -723,10 +884,8 @@ class JournalEntryModelAbstract(CreateUpdateMixIn):
723
884
  ----------
724
885
  commit: bool
725
886
  Commits changes into the Database, Defaults to False.
726
-
727
887
  raise_exception: bool
728
888
  Raises JournalEntryValidationError if cannot lock. Defaults to False.
729
-
730
889
  kwargs: dict
731
890
  Additional keyword arguments.
732
891
  """
@@ -735,7 +894,6 @@ class JournalEntryModelAbstract(CreateUpdateMixIn):
735
894
  if raise_exception:
736
895
  raise JournalEntryValidationError(f'Journal Entry {self.uuid} is already locked.')
737
896
  return
738
-
739
897
  if not self.is_locked():
740
898
  self.generate_activity(force_update=True)
741
899
  self.locked = True
@@ -770,9 +928,9 @@ class JournalEntryModelAbstract(CreateUpdateMixIn):
770
928
  if raise_exception:
771
929
  raise JournalEntryValidationError(f'Journal Entry {self.uuid} is already unlocked.')
772
930
  return
773
-
774
931
  if self.is_locked():
775
932
  self.locked = False
933
+ self.activity = None
776
934
  if not self.is_locked():
777
935
  if commit:
778
936
  self.save(verify=False)
@@ -789,138 +947,129 @@ class JournalEntryModelAbstract(CreateUpdateMixIn):
789
947
 
790
948
  def get_transaction_queryset(self, select_accounts: bool = True) -> TransactionModelQuerySet:
791
949
  """
792
- Fetches the TransactionModelQuerySet associated with the JournalEntryModel instance.
950
+ Retrieves the `TransactionModelQuerySet` associated with this `JournalEntryModel` instance.
793
951
 
794
952
  Parameters
795
953
  ----------
796
- select_accounts: bool
797
- Fetches the associated AccountModel of each transaction. Defaults to True.
954
+ select_accounts : bool, optional
955
+ If True, prefetches the related `AccountModel` for each transaction. Defaults to True.
798
956
 
799
957
  Returns
800
958
  -------
801
959
  TransactionModelQuerySet
802
- The TransactionModelQuerySet associated with the current JournalEntryModel instance.
960
+ A queryset containing transactions related to this journal entry. If `select_accounts` is
961
+ True, the accounts are included in the query as well.
803
962
  """
804
963
  if select_accounts:
805
964
  return self.transactionmodel_set.all().select_related('account')
806
965
  return self.transactionmodel_set.all()
807
966
 
808
- def get_txs_balances(self,
809
- txs_qs: Optional[TransactionModelQuerySet] = None,
810
- as_dict: bool = False) -> Union[TransactionModelQuerySet, Dict]:
967
+ def get_txs_balances(
968
+ self,
969
+ txs_qs: Optional[TransactionModelQuerySet] = None,
970
+ as_dict: bool = False
971
+ ) -> Union[TransactionModelQuerySet, Dict[str, Decimal]]:
811
972
  """
812
- Fetches the sum total of CREDITs and DEBITs associated with the JournalEntryModel instance. This method
813
- performs a reduction/aggregation at the database level and fetches exactly two records. Optionally,
814
- may pass an existing TransactionModelQuerySet if previously fetched. Additional validation occurs to ensure
815
- that all TransactionModels in QuerySet are of the JE instance. Due to JournalEntryModel pre-save validation
816
- and basic rules of accounting, CREDITs and DEBITS will always match.
973
+ Calculates the total CREDIT and DEBIT balances for the journal entry.
974
+
975
+ This method performs an aggregate database query to compute the sum of CREDITs and
976
+ DEBITs across the transactions related to this journal entry. Optionally, a pre-fetched
977
+ `TransactionModelQuerySet` can be supplied for efficiency. Validation is performed to
978
+ ensure that all transactions belong to this journal entry.
817
979
 
818
980
  Parameters
819
981
  ----------
820
- txs_qs: TransactionModelQuerySet
821
- The JE TransactionModelQuerySet to use if previously fetched. Will be validated to make sure all
822
- TransactionModel in QuerySet belong to the JournalEntryModel instance.
823
-
824
- as_dict: bool
825
- If True, returns the result as a dictionary, with exactly two keys: 'credit' and 'debit'.
826
- The values will be the total CREDIT or DEBIT amount as Decimal.
827
-
828
- Examples
829
- --------
830
- >>> je_model: JournalEntryModel = je_qs.first() # any existing JournalEntryModel QuerySet...
831
- >>> balances = je_model.get_txs_balances()
832
- >>> balances
833
- Returns exactly two records:
834
- <TransactionModelQuerySet [{'tx_type': 'credit', 'amount__sum': Decimal('2301.5')},
835
- {'tx_type': 'debit', 'amount__sum': Decimal('2301.5')}]>
836
-
837
- Examples
838
- --------
839
- >>> balances = je_model.get_txs_balances(as_dict=True)
840
- >>> balances
841
- Returns a dictionary:
842
- {'credit': Decimal('2301.5'), 'debit': Decimal('2301.5')}
982
+ txs_qs : TransactionModelQuerySet, optional
983
+ A pre-fetched queryset of transactions. If None, the queryset is fetched automatically.
984
+ as_dict : bool, optional
985
+ If True, returns the results as a dictionary with keys "credit" and "debit". Defaults to False.
986
+
987
+ Returns
988
+ -------
989
+ Union[TransactionModelQuerySet, Dict[str, Decimal]]
990
+ If `as_dict` is False, returns a queryset of aggregated balances. If `as_dict` is True,
991
+ returns a dictionary containing the CREDIT and DEBIT totals.
843
992
 
844
993
  Raises
845
994
  ------
846
995
  JournalEntryValidationError
847
- If JE is not valid or TransactionModelQuerySet provided does not belong to JE instance.
848
-
849
- Returns
850
- -------
851
- TransactionModelQuerySet or dict
852
- An aggregated queryset containing exactly two records. The total CREDIT or DEBIT amount as Decimal.
996
+ If the provided queryset is invalid or does not belong to this journal entry.
853
997
  """
854
998
  if not txs_qs:
855
999
  txs_qs = self.get_transaction_queryset(select_accounts=False)
856
- else:
857
- if not isinstance(txs_qs, TransactionModelQuerySet):
858
- raise JournalEntryValidationError(
859
- message=f'Must pass a TransactionModelQuerySet. Got {txs_qs.__class__.__name__}'
860
- )
861
-
862
- # todo: add maximum transactions per JE model as a setting...
863
- is_valid = self.is_txs_qs_valid(txs_qs)
864
- if not is_valid:
865
- raise JournalEntryValidationError(
866
- message='Invalid Transaction QuerySet used. Must be from same Journal Entry'
867
- )
1000
+ elif not isinstance(txs_qs, TransactionModelQuerySet):
1001
+ raise JournalEntryValidationError(
1002
+ f"Expected a TransactionModelQuerySet, got {type(txs_qs).__name__}"
1003
+ )
1004
+ elif not self.is_txs_qs_valid(txs_qs):
1005
+ raise JournalEntryValidationError(
1006
+ "Invalid TransactionModelQuerySet. All transactions must belong to the same journal entry."
1007
+ )
868
1008
 
869
1009
  balances = txs_qs.values('tx_type').annotate(
870
- amount__sum=Coalesce(Sum('amount'),
871
- Decimal('0.00'),
872
- output_field=models.DecimalField()))
1010
+ amount__sum=Coalesce(Sum('amount'), Decimal('0.00'), output_field=models.DecimalField())
1011
+ )
873
1012
 
874
1013
  if as_dict:
875
- return {
876
- tx['tx_type']: tx['amount__sum'] for tx in balances
877
- }
1014
+ return {tx['tx_type']: tx['amount__sum'] for tx in balances}
878
1015
  return balances
879
1016
 
880
- def get_txs_roles(self,
881
- txs_qs: Optional[TransactionModelQuerySet] = None,
882
- exclude_cash_role: bool = False) -> Set[str]:
1017
+ def get_txs_roles(
1018
+ self,
1019
+ txs_qs: Optional[TransactionModelQuerySet] = None,
1020
+ exclude_cash_role: bool = False
1021
+ ) -> Set[str]:
883
1022
  """
884
- Determines the list of account roles involved in the JournalEntryModel instance.
885
- It reaches into the AccountModel associated with each TransactionModel of the JE to determine a Set of
886
- all roles involved in transactions. This method is important in determining the nature of the
1023
+ Retrieves the set of account roles involved in the journal entry's transactions.
1024
+
1025
+ This method extracts the roles associated with the accounts linked to each transaction.
1026
+ Optionally, the CASH role can be excluded from the results.
887
1027
 
888
1028
  Parameters
889
1029
  ----------
890
- txs_qs: TransactionModelQuerySet
891
- Prefetched TransactionModelQuerySet. Will be validated if provided.
892
- Avoids additional DB query if provided.
893
-
894
- exclude_cash_role: bool
895
- Removes CASH role from the Set if present.
896
- Useful in some cases where cash role must be excluded for additional validation.
1030
+ txs_qs : TransactionModelQuerySet, optional
1031
+ A pre-fetched queryset of transactions. If None, the queryset is fetched automatically.
1032
+ exclude_cash_role : bool, optional
1033
+ If True, excludes the CASH role from the result. Defaults to False.
897
1034
 
898
1035
  Returns
899
1036
  -------
900
- set
901
- The set of account roles as strings associated with the JournalEntryModel instance.
1037
+ Set[str]
1038
+ A set of account roles associated with this journal entry's transactions.
902
1039
  """
903
1040
  if not txs_qs:
904
1041
  txs_qs = self.get_transaction_queryset(select_accounts=True)
905
1042
  else:
906
1043
  self.is_txs_qs_valid(txs_qs)
907
1044
 
908
- # todo: implement distinct for non SQLite Backends...
1045
+ roles = {tx.account.role for tx in txs_qs}
1046
+
909
1047
  if exclude_cash_role:
910
- return set([i.account.role for i in txs_qs if i.account.role != ASSET_CA_CASH])
911
- return set([i.account.role for i in txs_qs])
1048
+ roles.discard(ASSET_CA_CASH)
1049
+
1050
+ return roles
912
1051
 
913
1052
  def has_activity(self) -> bool:
1053
+ """
1054
+ Checks if the journal entry has an associated activity.
1055
+
1056
+ Returns
1057
+ -------
1058
+ bool
1059
+ True if an activity is defined for the journal entry, otherwise False.
1060
+ """
914
1061
  return self.activity is not None
915
1062
 
916
1063
  def get_activity_name(self) -> Optional[str]:
917
1064
  """
918
- Returns a human-readable, GAAP string representing the JournalEntryModel activity.
1065
+ Gets the name of the activity associated with this journal entry.
1066
+
1067
+ The activity indicates its categorization based on GAAP (e.g., operating, investing, financing).
919
1068
 
920
1069
  Returns
921
1070
  -------
922
- str or None
923
- Representing the JournalEntryModel activity in the statement of cash flows.
1071
+ Optional[str]
1072
+ The activity name if defined, otherwise None.
924
1073
  """
925
1074
  if self.activity:
926
1075
  if self.is_operating():
@@ -929,13 +1078,38 @@ class JournalEntryModelAbstract(CreateUpdateMixIn):
929
1078
  return ActivityEnum.INVESTING.value
930
1079
  elif self.is_financing():
931
1080
  return ActivityEnum.FINANCING.value
1081
+ return None
932
1082
 
933
1083
  @classmethod
934
- def get_activity_from_roles(cls,
935
- role_set: Union[List[str], Set[str]],
936
- validate: bool = False,
937
- raise_exception: bool = True) -> Optional[str]:
1084
+ def get_activity_from_roles(
1085
+ cls,
1086
+ role_set: Union[List[str], Set[str]],
1087
+ validate: bool = False,
1088
+ raise_exception: bool = True
1089
+ ) -> Optional[str]:
1090
+ """
1091
+ Determines the financial activity type (e.g., operating, investing, financing)
1092
+ based on a set of account roles.
1093
+
1094
+ Parameters
1095
+ ----------
1096
+ role_set : Union[List[str], Set[str]]
1097
+ The set of roles to analyze.
1098
+ validate : bool, optional
1099
+ If True, validates the roles before analysis. Defaults to False.
1100
+ raise_exception : bool, optional
1101
+ If True, raises an exception if multiple activities are detected. Defaults to True.
1102
+
1103
+ Returns
1104
+ -------
1105
+ Optional[str]
1106
+ The detected activity name, or None if no activity type is matched.
938
1107
 
1108
+ Raises
1109
+ ------
1110
+ JournalEntryValidationError
1111
+ If multiple activities are detected and `raise_exception` is True.
1112
+ """
939
1113
  if validate:
940
1114
  role_set = validate_roles(roles=role_set)
941
1115
  else:
@@ -1014,39 +1188,68 @@ class JournalEntryModelAbstract(CreateUpdateMixIn):
1014
1188
  txs_qs: Optional[TransactionModelQuerySet] = None,
1015
1189
  raise_exception: bool = True,
1016
1190
  force_update: bool = False) -> Optional[str]:
1191
+ """
1192
+ Generates the activity for the Journal Entry model based on its transactions.
1017
1193
 
1018
- if raise_exception and self.is_closing_entry:
1019
- raise_exception = False
1020
-
1021
- if any([
1022
- not self.has_activity(),
1023
- not self.is_locked(),
1024
- force_update
1025
- ]):
1026
-
1027
- txs_is_valid = True
1028
- if not txs_qs:
1029
- txs_qs = self.get_transaction_queryset(select_accounts=False)
1030
- else:
1031
- try:
1032
- txs_is_valid = self.is_txs_qs_valid(txs_qs=txs_qs, raise_exception=raise_exception)
1033
- except JournalEntryValidationError as e:
1034
- if raise_exception:
1035
- raise e
1194
+ Parameters
1195
+ ----------
1196
+ txs_qs : Optional[TransactionModelQuerySet], default None
1197
+ Queryset of TransactionModel instances for validation. If None, transactions are queried.
1198
+ raise_exception : bool, default True
1199
+ Determines whether exceptions are raised during processing.
1200
+ force_update : bool, default False
1201
+ Forces the regeneration of activity even if it exists.
1036
1202
 
1037
- if txs_is_valid:
1038
- cash_is_involved = self.is_cash_involved(txs_qs=txs_qs)
1039
- if not cash_is_involved:
1040
- self.activity = None
1203
+ Returns
1204
+ -------
1205
+ Optional[str]
1206
+ Generated activity or None if not applicable.
1207
+ """
1208
+ if not self._state.adding:
1209
+ if raise_exception and self.is_closing_entry:
1210
+ raise_exception = False
1211
+
1212
+ if any([
1213
+ not self.has_activity(),
1214
+ not self.is_locked(),
1215
+ force_update
1216
+ ]):
1217
+
1218
+ txs_is_valid = True
1219
+ if not txs_qs:
1220
+ txs_qs = self.get_transaction_queryset(select_accounts=False)
1041
1221
  else:
1042
- role_list = self.get_txs_roles(txs_qs, exclude_cash_role=True)
1043
- self.activity = self.get_activity_from_roles(role_set=role_list)
1222
+ try:
1223
+ txs_is_valid = self.is_txs_qs_valid(txs_qs=txs_qs, raise_exception=raise_exception)
1224
+ except JournalEntryValidationError as e:
1225
+ if raise_exception:
1226
+ raise e
1227
+
1228
+ if txs_is_valid:
1229
+ cash_is_involved = self.is_cash_involved(txs_qs=txs_qs)
1230
+ if not cash_is_involved:
1231
+ self.activity = None
1232
+ else:
1233
+ role_list = self.get_txs_roles(txs_qs, exclude_cash_role=True)
1234
+ self.activity = self.get_activity_from_roles(role_set=role_list)
1044
1235
  return self.activity
1045
1236
 
1046
1237
  # todo: add entity_model as parameter on all functions...
1047
1238
  # todo: outsource this function to EntityStateModel...?...
1048
1239
  def _get_next_state_model(self, raise_exception: bool = True) -> EntityStateModel:
1240
+ """
1241
+ Retrieves or creates the next state model for the Journal Entry.
1242
+
1243
+ Parameters
1244
+ ----------
1245
+ raise_exception : bool, default True
1246
+ Determines if exceptions should be raised when the entity state is not found.
1049
1247
 
1248
+ Returns
1249
+ -------
1250
+ EntityStateModel
1251
+ The state model with an incremented sequence.
1252
+ """
1050
1253
  entity_model = EntityModel.objects.get(uuid__exact=self.ledger.entity_id)
1051
1254
  fy_key = entity_model.get_fy_for_date(dt=self.timestamp)
1052
1255
 
@@ -1082,15 +1285,12 @@ class JournalEntryModelAbstract(CreateUpdateMixIn):
1082
1285
 
1083
1286
  def can_generate_je_number(self) -> bool:
1084
1287
  """
1085
- Checks if the JournalEntryModel instance can generate its own JE number.
1086
- Conditions are:
1087
- * The JournalEntryModel must have a LedgerModel instance assigned.
1088
- * The JournalEntryModel instance must not have a pre-existing JE number.
1288
+ Checks if a Journal Entry Number can be generated.
1089
1289
 
1090
1290
  Returns
1091
1291
  -------
1092
1292
  bool
1093
- True if JournalEntryModel needs a JE number, otherwise False.
1293
+ True if the Journal Entry can generate a JE number, otherwise False.
1094
1294
  """
1095
1295
  return all([
1096
1296
  self.ledger_id,
@@ -1099,19 +1299,17 @@ class JournalEntryModelAbstract(CreateUpdateMixIn):
1099
1299
 
1100
1300
  def generate_je_number(self, commit: bool = False) -> str:
1101
1301
  """
1102
- Atomic Transaction. Generates the next Journal Entry document number available. The operation
1103
- will result in two additional queries if the Journal Entry LedgerModel & EntityUnitModel are not cached in
1104
- QuerySet via select_related('ledger', 'entity_unit').
1302
+ Generates the Journal Entry number in an atomic transaction.
1105
1303
 
1106
1304
  Parameters
1107
1305
  ----------
1108
- commit: bool
1109
- Commits transaction into JournalEntryModel when function is called.
1306
+ commit : bool, default False
1307
+ Saves the generated JE number in the database.
1110
1308
 
1111
1309
  Returns
1112
1310
  -------
1113
1311
  str
1114
- A String, representing the new or current JournalEntryModel instance Document Number.
1312
+ The generated or existing JE number.
1115
1313
  """
1116
1314
  if self.can_generate_je_number():
1117
1315
 
@@ -1141,30 +1339,24 @@ class JournalEntryModelAbstract(CreateUpdateMixIn):
1141
1339
  **kwargs) -> Tuple[TransactionModelQuerySet, bool]:
1142
1340
 
1143
1341
  """
1144
- Verifies the JournalEntryModel. The JE Model is verified when:
1145
- * All TransactionModels associated with the JE instance are in balance (i.e. the sum of CREDITs and DEBITs are equal).
1146
- * If the JournalEntryModel is using cash, a cash flow activity is assigned.
1342
+ Verifies the validity of the Journal Entry model instance.
1147
1343
 
1148
1344
  Parameters
1149
1345
  ----------
1150
- txs_qs: TransactionModelQuerySet
1151
- Prefetched TransactionModelQuerySet. If provided avoids additional DB query. Will be verified against
1152
- JournalEntryModel instance.
1153
- force_verify: bool
1154
- If True, forces new verification of JournalEntryModel if previously verified. Defaults to False.
1155
- raise_exception: bool
1156
- If True, will raise JournalEntryValidationError if verification fails.
1157
- kwargs: dict
1158
- Additional function key-word args.
1159
-
1160
- Raises
1161
- ------
1162
- JournalEntryValidationError if JE instance could not be verified.
1346
+ txs_qs : Optional[TransactionModelQuerySet], default None
1347
+ Queryset of TransactionModel instances to validate. If None, transactions are queried.
1348
+ force_verify : bool, default False
1349
+ Forces re-verification even if already verified.
1350
+ raise_exception : bool, default True
1351
+ Determines if exceptions are raised on validation failure.
1352
+ kwargs : dict
1353
+ Additional options.
1163
1354
 
1164
1355
  Returns
1165
1356
  -------
1166
- tuple: TransactionModelQuerySet, bool
1167
- The TransactionModelQuerySet of the JournalEntryModel instance, verification result as True/False.
1357
+ Tuple[TransactionModelQuerySet, bool]
1358
+ - The TransactionModelQuerySet associated with the JournalEntryModel.
1359
+ - A boolean indicating whether verification was successful.
1168
1360
  """
1169
1361
 
1170
1362
  if not self.is_verified() or force_verify:
@@ -1176,6 +1368,7 @@ class JournalEntryModelAbstract(CreateUpdateMixIn):
1176
1368
  is_txs_qs_valid = True
1177
1369
  else:
1178
1370
  try:
1371
+ # if provided, it is verified...
1179
1372
  is_txs_qs_valid = self.is_txs_qs_valid(raise_exception=raise_exception, txs_qs=txs_qs)
1180
1373
  except JournalEntryValidationError as e:
1181
1374
  raise e
@@ -1188,7 +1381,7 @@ class JournalEntryModelAbstract(CreateUpdateMixIn):
1188
1381
  except JournalEntryValidationError as e:
1189
1382
  raise e
1190
1383
 
1191
- # Transaction CoA if valid
1384
+ # Transaction CoA if valid...
1192
1385
 
1193
1386
  try:
1194
1387
  is_coa_valid = self.is_txs_qs_coa_valid(txs_qs=txs_qs)
@@ -1205,34 +1398,45 @@ class JournalEntryModelAbstract(CreateUpdateMixIn):
1205
1398
  # if raise_exception:
1206
1399
  # raise JournalEntryValidationError('At least two transactions required.')
1207
1400
 
1208
- if all([is_balance_valid, is_txs_qs_valid, is_coa_valid]):
1401
+ if all([
1402
+ is_balance_valid,
1403
+ is_txs_qs_valid,
1404
+ is_coa_valid
1405
+ ]):
1209
1406
  # activity flag...
1210
1407
  self.generate_activity(txs_qs=txs_qs, raise_exception=raise_exception)
1211
1408
  self._verified = True
1212
1409
  return txs_qs, self.is_verified()
1213
- return TransactionModel.objects.none(), self.is_verified()
1410
+ return self.get_transaction_queryset(), self.is_verified()
1214
1411
 
1215
1412
  def clean(self,
1216
1413
  verify: bool = False,
1217
1414
  raise_exception: bool = True,
1218
1415
  txs_qs: Optional[TransactionModelQuerySet] = None) -> Tuple[TransactionModelQuerySet, bool]:
1219
1416
  """
1220
- Customized JournalEntryModel clean method. Generates a JE number if needed. Optional verification hook on clean.
1417
+ Cleans the JournalEntryModel instance, optionally verifying it and generating a Journal Entry (JE) number if required.
1221
1418
 
1222
1419
  Parameters
1223
1420
  ----------
1224
- raise_exception: bool
1225
- Raises exception if JE could not be verified. Defaults to True.
1226
- verify: bool
1227
- Attempts to verify the JournalEntryModel during cleaning.
1228
- txs_qs: TransactionModelQuerySet
1229
- Prefetched TransactionModelQuerySet. If provided avoids additional DB query. Will be verified against
1230
- JournalEntryModel instance.
1421
+ verify : bool, optional
1422
+ If True, attempts to verify the JournalEntryModel during the cleaning process. Default is False.
1423
+ raise_exception : bool, optional
1424
+ If True, raises an exception when the instance fails verification. Default is True.
1425
+ txs_qs : TransactionModelQuerySet, optional
1426
+ A pre-fetched TransactionModelQuerySet. If provided, avoids additional database queries. The provided queryset is
1427
+ validated against the JournalEntryModel instance.
1231
1428
 
1232
1429
  Returns
1233
1430
  -------
1234
- tuple: TransactionModelQuerySet, bool
1235
- The TransactionModelQuerySet of the JournalEntryModel instance, verification result as True/False.
1431
+ Tuple[TransactionModelQuerySet, bool]
1432
+ A tuple containing:
1433
+ - The validated TransactionModelQuerySet for the JournalEntryModel instance.
1434
+ - A boolean indicating whether the instance passed verification.
1435
+
1436
+ Raises
1437
+ ------
1438
+ JournalEntryValidationError
1439
+ If the instance has a timestamp in the future and is posted, or if verification fails and `raise_exception` is True.
1236
1440
  """
1237
1441
 
1238
1442
  if txs_qs:
@@ -1251,137 +1455,293 @@ class JournalEntryModelAbstract(CreateUpdateMixIn):
1251
1455
  if verify:
1252
1456
  txs_qs, verified = self.verify()
1253
1457
  return txs_qs, self.is_verified()
1254
- return TransactionModel.objects.none(), self.is_verified()
1458
+ return self.get_transaction_queryset(), self.is_verified()
1255
1459
 
1256
1460
  def get_delete_message(self) -> str:
1461
+ """
1462
+ Generates a confirmation message for deleting the JournalEntryModel instance.
1463
+
1464
+ Returns
1465
+ -------
1466
+ str
1467
+ A confirmation message including the Journal Entry number and Ledger name.
1468
+ """
1257
1469
  return _(f'Are you sure you want to delete JournalEntry Model {self.je_number} on Ledger {self.ledger.name}?')
1258
1470
 
1259
1471
  def delete(self, **kwargs):
1472
+ """
1473
+ Deletes the JournalEntryModel instance, ensuring it is allowed to be deleted.
1474
+
1475
+ Parameters
1476
+ ----------
1477
+ **kwargs : dict
1478
+ Additional arguments passed to the parent delete method.
1479
+
1480
+ Raises
1481
+ ------
1482
+ JournalEntryValidationError
1483
+ If the instance is not eligible for deletion.
1484
+ """
1260
1485
  if not self.can_delete():
1261
1486
  raise JournalEntryValidationError(
1262
1487
  message=_(f'JournalEntryModel {self.uuid} cannot be deleted...')
1263
1488
  )
1264
1489
  return super().delete(**kwargs)
1265
1490
 
1266
- def save(self,
1267
- verify: bool = True,
1268
- post_on_verify: bool = False,
1269
- *args, **kwargs):
1270
- # todo this does not show up on docs...
1491
+ def save(
1492
+ self,
1493
+ verify: bool = True,
1494
+ post_on_verify: bool = False,
1495
+ *args,
1496
+ **kwargs
1497
+ ):
1271
1498
  """
1272
- Custom JournalEntryModel instance save method. Additional options are added to attempt to verify JournalEntryModel
1273
- before saving into database.
1499
+ Saves the JournalEntryModel instance, with optional verification and posting prior to saving.
1274
1500
 
1275
1501
  Parameters
1276
1502
  ----------
1277
- verify: bool
1278
- If True, verifies JournalEntryModel transactions before saving. Defaults to True.
1279
- post_on_verify: bool
1280
- Posts JournalEntryModel if verification is successful and can_post() is True.
1503
+ verify : bool, optional
1504
+ If True, verifies the transactions of the JournalEntryModel before saving. Default is True.
1505
+ post_on_verify : bool, optional
1506
+ If True, posts the JournalEntryModel if verification is successful and `can_post()` is True. Default is False.
1281
1507
 
1282
1508
  Returns
1283
1509
  -------
1284
1510
  JournalEntryModel
1285
- The saved instance.
1511
+ The saved JournalEntryModel instance.
1512
+
1513
+ Raises
1514
+ ------
1515
+ JournalEntryValidationError
1516
+ If the instance fails verification or encounters an issue during save.
1286
1517
  """
1287
1518
  try:
1519
+ # Generate the Journal Entry number prior to verification and saving
1288
1520
  self.generate_je_number(commit=False)
1521
+
1289
1522
  if verify:
1290
1523
  txs_qs, is_verified = self.clean(verify=True)
1291
1524
  if self.is_verified() and post_on_verify:
1292
- # commit is False since the super call takes place at the end of save()
1293
- # self.mark_as_locked(commit=False, raise_exception=True)
1525
+ # Mark as posted if verification succeeds and posting is requested
1294
1526
  self.mark_as_posted(commit=False, verify=False, force_lock=True, raise_exception=True)
1527
+
1295
1528
  except ValidationError as e:
1296
1529
  if self.can_unpost():
1297
1530
  self.mark_as_unposted(raise_exception=True)
1298
1531
  raise JournalEntryValidationError(
1299
- f'Something went wrong validating journal entry ID: {self.uuid}: {e.message}')
1532
+ f'Error validating Journal Entry ID: {self.uuid}: {e.message}'
1533
+ )
1300
1534
  except Exception as e:
1301
- # safety net, for any unexpected error...
1302
- # no JE can be posted if not fully validated...
1535
+ # Safety net for unexpected errors during save
1303
1536
  self.posted = False
1304
1537
  self._verified = False
1305
1538
  self.save(update_fields=['posted', 'updated'], verify=False)
1306
1539
  raise JournalEntryValidationError(e)
1307
1540
 
1541
+ # Prevent saving an unverified Journal Entry
1308
1542
  if not self.is_verified() and verify:
1309
1543
  raise JournalEntryValidationError(message='Cannot save an unverified Journal Entry.')
1310
1544
 
1311
1545
  return super(JournalEntryModelAbstract, self).save(*args, **kwargs)
1312
1546
 
1313
1547
  # URLS Generation...
1548
+
1314
1549
  def get_absolute_url(self) -> str:
1550
+ """
1551
+ Generates the URL to view the details of the journal entry.
1315
1552
 
1316
- return reverse('django_ledger:je-detail',
1317
- kwargs={
1318
- 'je_pk': self.uuid,
1319
- 'ledger_pk': self.ledger_id,
1320
- 'entity_slug': self.ledger.entity.slug
1321
- })
1553
+ Returns
1554
+ -------
1555
+ str
1556
+ The absolute URL for the journal entry details.
1557
+ """
1558
+ return reverse('django_ledger:je-detail', kwargs={
1559
+ 'je_pk': self.uuid,
1560
+ 'ledger_pk': self.ledger_id,
1561
+ 'entity_slug': self.entity_slug
1562
+ })
1322
1563
 
1323
1564
  def get_detail_url(self) -> str:
1324
1565
  """
1325
- Determines the update URL of the LedgerModel instance.
1326
- Results in additional Database query if entity field is not selected in QuerySet.
1566
+ Generates the detail URL for the journal entry.
1327
1567
 
1328
1568
  Returns
1329
1569
  -------
1330
1570
  str
1331
- URL as a string.
1571
+ The URL for updating or viewing journal entry details.
1332
1572
  """
1333
- return reverse('django_ledger:je-detail',
1334
- kwargs={
1335
- 'entity_slug': self.ledger.entity.slug,
1336
- 'ledger_pk': self.ledger_id,
1337
- 'je_pk': self.uuid
1338
- })
1573
+ return reverse('django_ledger:je-detail', kwargs={
1574
+ 'entity_slug': self.entity_slug,
1575
+ 'ledger_pk': self.ledger_id,
1576
+ 'je_pk': self.uuid
1577
+ })
1578
+
1579
+ def get_journal_entry_list_url(self) -> str:
1580
+ """
1581
+ Constructs the URL to access the list of journal entries
1582
+ associated with a specific ledger and entity.
1583
+
1584
+ Returns
1585
+ -------
1586
+ str
1587
+ The URL for the journal entry list.
1588
+ """
1589
+ return reverse('django_ledger:je-list', kwargs={
1590
+ 'entity_slug': self.entity_slug,
1591
+ 'ledger_pk': self.ledger_id
1592
+ })
1593
+
1594
+ def get_journal_entry_create_url(self) -> str:
1595
+ """
1596
+ Constructs the URL to create a new journal entry
1597
+ associated with a specific ledger and entity.
1598
+
1599
+ Returns
1600
+ -------
1601
+ str
1602
+ The URL to create a journal entry.
1603
+ """
1604
+ return reverse('django_ledger:je-create', kwargs={
1605
+ 'entity_slug': self.entity_slug,
1606
+ 'ledger_pk': self.ledger_id
1607
+ })
1339
1608
 
1340
1609
  def get_detail_txs_url(self) -> str:
1341
1610
  """
1342
- Determines the update URL of the LedgerModel instance.
1343
- Results in additional Database query if entity field is not selected in QuerySet.
1611
+ Generates the URL to view transaction details of the journal entry.
1344
1612
 
1345
1613
  Returns
1346
1614
  -------
1347
1615
  str
1348
- URL as a string.
1616
+ The URL for transaction details of the journal entry.
1617
+ """
1618
+ return reverse('django_ledger:je-detail-txs', kwargs={
1619
+ 'entity_slug': self.entity_slug,
1620
+ 'ledger_pk': self.ledger_id,
1621
+ 'je_pk': self.uuid
1622
+ })
1623
+
1624
+ def get_unlock_url(self) -> str:
1625
+ """
1626
+ Generates the URL to mark the journal entry as unlocked.
1627
+
1628
+ Returns
1629
+ -------
1630
+ str
1631
+ The URL for unlocking the journal entry.
1632
+ """
1633
+ return reverse('django_ledger:je-mark-as-unlocked', kwargs={
1634
+ 'entity_slug': self.entity_slug,
1635
+ 'ledger_pk': self.ledger_id,
1636
+ 'je_pk': self.uuid
1637
+ })
1638
+
1639
+ def get_lock_url(self) -> str:
1640
+ """
1641
+ Generates the URL to mark the journal entry as locked.
1642
+
1643
+ Returns
1644
+ -------
1645
+ str
1646
+ The URL for locking the journal entry.
1647
+ """
1648
+ return reverse('django_ledger:je-mark-as-locked', kwargs={
1649
+ 'entity_slug': self.entity_slug,
1650
+ 'ledger_pk': self.ledger_id,
1651
+ 'je_pk': self.uuid
1652
+ })
1653
+
1654
+ def get_post_url(self) -> str:
1349
1655
  """
1350
- return reverse('django_ledger:je-detail-txs',
1656
+ Generates the URL to mark the journal entry as posted.
1657
+
1658
+ Returns
1659
+ -------
1660
+ str
1661
+ The URL for posting the journal entry.
1662
+ """
1663
+ return reverse('django_ledger:je-mark-as-posted', kwargs={
1664
+ 'entity_slug': self.entity_slug,
1665
+ 'ledger_pk': self.ledger_id,
1666
+ 'je_pk': self.uuid
1667
+ })
1668
+
1669
+ def get_unpost_url(self) -> str:
1670
+ """
1671
+ Generates the URL to mark the journal entry as unposted.
1672
+
1673
+ Returns
1674
+ -------
1675
+ str
1676
+ The URL for unposting the journal entry.
1677
+ """
1678
+ return reverse('django_ledger:je-mark-as-unposted', kwargs={
1679
+ 'entity_slug': self.entity_slug,
1680
+ 'ledger_pk': self.ledger_id,
1681
+ 'je_pk': self.uuid
1682
+ })
1683
+
1684
+ # Action URLS....
1685
+ def get_action_post_url(self) -> str:
1686
+ """
1687
+ Generates the URL used to mark the journal entry as posted.
1688
+
1689
+ Returns
1690
+ -------
1691
+ str
1692
+ The generated URL for marking the journal entry as posted.
1693
+ """
1694
+ return reverse('django_ledger:je-mark-as-posted',
1351
1695
  kwargs={
1352
- 'entity_slug': self.ledger.entity.slug,
1696
+ 'entity_slug': self.entity_slug,
1353
1697
  'ledger_pk': self.ledger_id,
1354
1698
  'je_pk': self.uuid
1355
1699
  })
1356
1700
 
1357
- def get_unlock_url(self):
1358
- return reverse('django_ledger:je-mark-as-unlocked',
1701
+ def get_action_unpost_url(self) -> str:
1702
+ """
1703
+ Generates the URL used to mark the journal entry as unposted.
1704
+
1705
+ Returns
1706
+ -------
1707
+ str
1708
+ The generated URL for marking the journal entry as unposted.
1709
+ """
1710
+ return reverse('django_ledger:je-mark-as-unposted',
1359
1711
  kwargs={
1360
- 'entity_slug': self.ledger.entity.slug,
1712
+ 'entity_slug': self.entity_slug,
1361
1713
  'ledger_pk': self.ledger_id,
1362
1714
  'je_pk': self.uuid
1363
1715
  })
1364
1716
 
1365
- def get_lock_url(self):
1717
+ def get_action_lock_url(self) -> str:
1718
+ """
1719
+ Generates the URL used to mark the journal entry as locked.
1720
+
1721
+ Returns
1722
+ -------
1723
+ str
1724
+ The generated URL for marking the journal entry as locked.
1725
+ """
1366
1726
  return reverse('django_ledger:je-mark-as-locked',
1367
1727
  kwargs={
1368
- 'entity_slug': self.ledger.entity.slug,
1728
+ 'entity_slug': self.entity_slug,
1369
1729
  'ledger_pk': self.ledger_id,
1370
1730
  'je_pk': self.uuid
1371
1731
  })
1372
1732
 
1373
- def get_post_url(self):
1374
- return reverse('django_ledger:je-mark-as-posted',
1375
- kwargs={
1376
- 'entity_slug': self.ledger.entity.slug,
1377
- 'ledger_pk': self.ledger_id,
1378
- 'je_pk': self.uuid
1379
- })
1733
+ def get_action_unlock_url(self) -> str:
1734
+ """
1735
+ Generates the URL used to mark the journal entry as unlocked.
1380
1736
 
1381
- def get_unpost_url(self):
1382
- return reverse('django_ledger:je-mark-as-unposted',
1737
+ Returns
1738
+ -------
1739
+ str
1740
+ The generated URL for marking the journal entry as unlocked.
1741
+ """
1742
+ return reverse('django_ledger:je-mark-as-unlocked',
1383
1743
  kwargs={
1384
- 'entity_slug': self.ledger.entity.slug,
1744
+ 'entity_slug': self.entity_slug,
1385
1745
  'ledger_pk': self.ledger_id,
1386
1746
  'je_pk': self.uuid
1387
1747
  })
@@ -1398,10 +1758,12 @@ class JournalEntryModel(JournalEntryModelAbstract):
1398
1758
 
1399
1759
 
1400
1760
  def journalentrymodel_presave(instance: JournalEntryModel, **kwargs):
1401
- if instance._state.adding and not instance.ledger.can_edit_journal_entries():
1402
- raise JournalEntryValidationError(
1403
- message=_(f'Cannot add Journal Entries to locked LedgerModel {instance.ledger_id}')
1404
- )
1761
+ if instance._state.adding:
1762
+ # cannot add journal entries to a locked ledger...
1763
+ if instance.ledger_is_locked():
1764
+ raise JournalEntryValidationError(
1765
+ message=_(f'Cannot add Journal Entries to locked LedgerModel {instance.ledger_id}')
1766
+ )
1405
1767
 
1406
1768
 
1407
1769
  pre_save.connect(journalentrymodel_presave, sender=JournalEntryModel)