django-ledger 0.8.0__py3-none-any.whl → 0.8.1__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 (51) hide show
  1. django_ledger/__init__.py +1 -1
  2. django_ledger/forms/account.py +45 -46
  3. django_ledger/forms/data_import.py +182 -63
  4. django_ledger/io/io_core.py +507 -374
  5. django_ledger/migrations/0026_stagedtransactionmodel_customer_model_and_more.py +56 -0
  6. django_ledger/models/__init__.py +2 -1
  7. django_ledger/models/bill.py +337 -300
  8. django_ledger/models/customer.py +47 -34
  9. django_ledger/models/data_import.py +770 -289
  10. django_ledger/models/entity.py +882 -637
  11. django_ledger/models/mixins.py +421 -280
  12. django_ledger/models/receipt.py +1083 -0
  13. django_ledger/models/transactions.py +105 -41
  14. django_ledger/models/unit.py +42 -30
  15. django_ledger/models/utils.py +12 -2
  16. django_ledger/models/vendor.py +85 -66
  17. django_ledger/settings.py +1 -0
  18. django_ledger/templates/django_ledger/components/period_navigator.html +5 -3
  19. django_ledger/templates/django_ledger/customer/customer_detail.html +87 -0
  20. django_ledger/templates/django_ledger/customer/customer_list.html +0 -1
  21. django_ledger/templates/django_ledger/customer/tags/customer_table.html +3 -1
  22. django_ledger/templates/django_ledger/data_import/tags/data_import_job_txs_imported.html +24 -3
  23. django_ledger/templates/django_ledger/data_import/tags/data_import_job_txs_table.html +26 -10
  24. django_ledger/templates/django_ledger/entity/entity_dashboard.html +2 -2
  25. django_ledger/templates/django_ledger/layouts/base.html +1 -1
  26. django_ledger/templates/django_ledger/layouts/content_layout_1.html +1 -1
  27. django_ledger/templates/django_ledger/receipt/customer_receipt_report.html +115 -0
  28. django_ledger/templates/django_ledger/receipt/receipt_delete.html +30 -0
  29. django_ledger/templates/django_ledger/receipt/receipt_detail.html +89 -0
  30. django_ledger/templates/django_ledger/receipt/receipt_list.html +134 -0
  31. django_ledger/templates/django_ledger/receipt/vendor_receipt_report.html +115 -0
  32. django_ledger/templates/django_ledger/vendor/tags/vendor_table.html +3 -2
  33. django_ledger/templates/django_ledger/vendor/vendor_detail.html +86 -0
  34. django_ledger/templatetags/django_ledger.py +338 -191
  35. django_ledger/urls/__init__.py +1 -0
  36. django_ledger/urls/customer.py +3 -0
  37. django_ledger/urls/data_import.py +3 -0
  38. django_ledger/urls/receipt.py +102 -0
  39. django_ledger/urls/vendor.py +1 -0
  40. django_ledger/views/__init__.py +1 -0
  41. django_ledger/views/customer.py +56 -14
  42. django_ledger/views/data_import.py +119 -66
  43. django_ledger/views/mixins.py +112 -86
  44. django_ledger/views/receipt.py +294 -0
  45. django_ledger/views/vendor.py +53 -14
  46. {django_ledger-0.8.0.dist-info → django_ledger-0.8.1.dist-info}/METADATA +1 -1
  47. {django_ledger-0.8.0.dist-info → django_ledger-0.8.1.dist-info}/RECORD +51 -40
  48. {django_ledger-0.8.0.dist-info → django_ledger-0.8.1.dist-info}/WHEEL +0 -0
  49. {django_ledger-0.8.0.dist-info → django_ledger-0.8.1.dist-info}/licenses/AUTHORS.md +0 -0
  50. {django_ledger-0.8.0.dist-info → django_ledger-0.8.1.dist-info}/licenses/LICENSE +0 -0
  51. {django_ledger-0.8.0.dist-info → django_ledger-0.8.1.dist-info}/top_level.txt +0 -0
@@ -8,33 +8,60 @@ application. It introduces two primary models to facilitate the import and proce
8
8
  1. `ImportJobModel` - Represents jobs that handle financial data import tasks.
9
9
  2. `StagedTransactionModel` - Represents individual transactions, including those that are staged for review, mapping,
10
10
  or further processing.
11
-
12
11
  """
12
+
13
13
  import warnings
14
+ from datetime import date, datetime
14
15
  from decimal import Decimal
15
- from typing import Optional, Set, Dict, List, Union
16
- from uuid import uuid4, UUID
17
-
18
- from django.core.exceptions import ValidationError
19
- from django.db import models
20
- from django.db.models import Q, Count, Sum, Case, When, F, Value, DecimalField, BooleanField, Manager, QuerySet
16
+ from typing import Dict, List, Optional, Set, Union
17
+ from uuid import UUID, uuid4
18
+
19
+ from django.core.exceptions import ObjectDoesNotExist, ValidationError
20
+ from django.db import models, transaction
21
+ from django.db.models import (
22
+ BooleanField,
23
+ Case,
24
+ Count,
25
+ DecimalField,
26
+ F,
27
+ Manager,
28
+ Q,
29
+ QuerySet,
30
+ Sum,
31
+ Value,
32
+ When,
33
+ )
21
34
  from django.db.models.functions import Coalesce
22
35
  from django.db.models.signals import pre_save
23
36
  from django.utils.translation import gettext_lazy as _
24
37
 
25
38
  from django_ledger.io import ASSET_CA_CASH, CREDIT, DEBIT
26
- from django_ledger.models import JournalEntryModel, deprecated_entity_slug_behavior
39
+ from django_ledger.models import AccountModel
40
+ from django_ledger.models.deprecations import deprecated_entity_slug_behavior
27
41
  from django_ledger.models.entity import EntityModel
42
+ from django_ledger.models.journal_entry import JournalEntryModel
28
43
  from django_ledger.models.mixins import CreateUpdateMixIn
29
- from django_ledger.models.utils import lazy_loader
44
+ from django_ledger.models.receipt import ReceiptModel
30
45
  from django_ledger.settings import DJANGO_LEDGER_USE_DEPRECATED_BEHAVIOR
31
46
 
32
47
 
33
48
  class ImportJobModelValidationError(ValidationError):
49
+ """
50
+ Represents an error that occurs during the validation of an import job model.
51
+
52
+ This class is a specific type of `ValidationError` raised when validation
53
+ of an import job model fails due to incorrect or invalid data. It serves
54
+ as a means to categorize and identify errors related to the import job
55
+ model validation process. This class does not redefine or add functionality
56
+ but exists to provide semantic clarity when handling this specific type
57
+ of validation failure.
58
+ """
59
+
34
60
  pass
35
61
 
36
62
 
37
63
  class ImportJobModelQuerySet(QuerySet):
64
+ """ """
38
65
 
39
66
  def for_user(self, user_model) -> 'ImportJobModelQuerySet':
40
67
  """
@@ -59,22 +86,21 @@ class ImportJobModelQuerySet(QuerySet):
59
86
  if user_model.is_superuser:
60
87
  return self
61
88
  return self.filter(
62
- Q(bank_account_model__entity_model__admin=user_model) |
63
- Q(bank_account_model__entity_model__managers__in=[user_model])
64
-
89
+ Q(bank_account_model__entity_model__admin=user_model)
90
+ | Q(bank_account_model__entity_model__managers__in=[user_model])
65
91
  )
66
92
 
67
93
 
68
94
  class ImportJobModelManager(Manager):
69
95
  """
70
- Manages queryset operations related to import jobs.
71
-
72
- This manager provides custom queryset handling for import job models, including
73
- annotations for custom fields like transaction counts, user-specific filters,
74
- and entity-specific filters. It is integrated with the ImportJobModel, designed
75
- to support complex query requirements with field annotations and related object
76
- optimizations for performance efficiency.
77
-
96
+ Manager class for handling ImportJobModel queries.
97
+
98
+ This class provides custom query methods for the ImportJobModel, allowing
99
+ efficient querying and annotation of related fields. It is tailored to
100
+ facilitate operations involving entities, accounts, and transactions with
101
+ various computed properties including counts, pending transactions, and
102
+ completion status. It also supports entity-specific filtering and deprecated
103
+ behavior for backward compatibility.
78
104
  """
79
105
 
80
106
  def get_queryset(self) -> ImportJobModelQuerySet:
@@ -102,41 +128,47 @@ class ImportJobModelManager(Manager):
102
128
  (no pending transactions or total count is zero).
103
129
  """
104
130
  qs = ImportJobModelQuerySet(self.model, using=self._db)
105
- return qs.annotate(
106
- _entity_uuid=F('ledger_model__entity__uuid'),
107
- _entity_slug=F('ledger_model__entity__slug'),
108
- txs_count=Count('stagedtransactionmodel',
109
- filter=Q(stagedtransactionmodel__parent__isnull=False)),
110
- txs_mapped_count=Count(
111
- 'stagedtransactionmodel__account_model_id',
112
- filter=Q(stagedtransactionmodel__parent__isnull=False) |
113
- Q(stagedtransactionmodel__parent__parent__isnull=False)
114
-
115
- ),
116
- ).annotate(
117
- txs_pending=F('txs_count') - F('txs_mapped_count')
118
- ).annotate(
119
- is_complete=Case(
120
- When(txs_count__exact=0, then=False),
121
- When(txs_pending__exact=0, then=True),
122
- default=False,
123
- output_field=BooleanField()
124
- ),
125
- ).select_related(
126
- 'bank_account_model',
127
- 'bank_account_model__account_model',
128
- 'ledger_model'
131
+ return (
132
+ qs.annotate(
133
+ _entity_uuid=F('ledger_model__entity__uuid'),
134
+ _entity_slug=F('ledger_model__entity__slug'),
135
+ txs_count=Count(
136
+ 'stagedtransactionmodel',
137
+ filter=Q(stagedtransactionmodel__parent__isnull=False),
138
+ ),
139
+ txs_mapped_count=Count(
140
+ 'stagedtransactionmodel__account_model_id',
141
+ filter=Q(stagedtransactionmodel__parent__isnull=False)
142
+ | Q(stagedtransactionmodel__parent__parent__isnull=False),
143
+ ),
144
+ )
145
+ .annotate(txs_pending=F('txs_count') - F('txs_mapped_count'))
146
+ .annotate(
147
+ is_complete=Case(
148
+ When(txs_count__exact=0, then=False),
149
+ When(txs_pending__exact=0, then=True),
150
+ default=False,
151
+ output_field=BooleanField(),
152
+ ),
153
+ )
154
+ .select_related(
155
+ 'bank_account_model',
156
+ 'bank_account_model__account_model',
157
+ 'ledger_model',
158
+ )
129
159
  )
130
160
 
131
161
  @deprecated_entity_slug_behavior
132
- def for_entity(self, entity_model: Union[EntityModel, str, UUID] = None, **kwargs) -> ImportJobModelQuerySet:
162
+ def for_entity(
163
+ self, entity_model: Union[EntityModel, str, UUID] = None, **kwargs
164
+ ) -> ImportJobModelQuerySet:
133
165
  qs = self.get_queryset()
134
166
  if 'user_model' in kwargs:
135
167
  warnings.warn(
136
168
  'user_model parameter is deprecated and will be removed in a future release. '
137
169
  'Use for_user(user_model).for_entity(entity_model) instead to keep current behavior.',
138
170
  DeprecationWarning,
139
- stacklevel=2
171
+ stacklevel=2,
140
172
  )
141
173
  if DJANGO_LEDGER_USE_DEPRECATED_BEHAVIOR:
142
174
  qs = qs.for_user(kwargs['user_model'])
@@ -156,41 +188,55 @@ class ImportJobModelManager(Manager):
156
188
 
157
189
  class ImportJobModelAbstract(CreateUpdateMixIn):
158
190
  """
159
- Abstract model for managing import jobs within a financial system.
191
+ Represents an abstract model for managing import jobs.
160
192
 
161
- This abstract model serves as a foundational base for managing import jobs involving
162
- bank accounts and ledger models. It provides functionalities such as linking to an
163
- associated bank account and ledger model, determining completion status of the
164
- import job, and properties for UUID and slug identifiers. Additionally, helper
165
- methods are provided for configuration and deletion confirmation.
193
+ This class provides attributes and methods to facilitate the creation,
194
+ configuration, and management of import jobs. It is designed to work
195
+ with ledger and bank account models, enabling tight integration with
196
+ ledger-based systems. The model is marked as abstract and is intended
197
+ to be extended by other concrete models.
166
198
 
167
199
  Attributes
168
200
  ----------
169
201
  uuid : UUID
170
- Unique identifier for the import job instance.
202
+ The universally unique identifier for the import job.
171
203
  description : str
172
- Descriptive label or description for the import job.
173
- bank_account_model : BankAccountModel
174
- Foreign key linking the import job to a bank account model.
175
- ledger_model : LedgerModel or None
176
- One-to-one field linking the import job to a ledger model. Can be null or blank.
204
+ A brief description of the import job.
205
+ bank_account_model : django_ledger.BankAccountModel
206
+ The foreign key relating the import job to a specific bank account model.
207
+ ledger_model : django_ledger.LedgerModel
208
+ A one-to-one relation to the ledger model associated with the import job.
209
+ This field may be null or blank.
177
210
  completed : bool
178
211
  Indicates whether the import job has been completed.
179
212
  objects : ImportJobModelManager
180
- Manager for handling query operations and model lifecycle.
213
+ The default manager for the model.
214
+
215
+ Meta
216
+ ----
217
+ This class is abstract and serves as a base for other models.
218
+ It includes additional metadata such as field verbose names
219
+ and database indexing.
181
220
  """
221
+
182
222
  uuid = models.UUIDField(default=uuid4, editable=False, primary_key=True)
183
223
  description = models.CharField(max_length=200, verbose_name=_('Description'))
184
- bank_account_model = models.ForeignKey('django_ledger.BankAccountModel',
185
- on_delete=models.CASCADE,
186
- verbose_name=_('Associated Bank Account Model'))
187
- ledger_model = models.OneToOneField('django_ledger.LedgerModel',
188
- editable=False,
189
- on_delete=models.CASCADE,
190
- verbose_name=_('Ledger Model'),
191
- null=True,
192
- blank=True)
193
- completed = models.BooleanField(default=False, verbose_name=_('Import Job Completed'))
224
+ bank_account_model = models.ForeignKey(
225
+ 'django_ledger.BankAccountModel',
226
+ on_delete=models.CASCADE,
227
+ verbose_name=_('Associated Bank Account Model'),
228
+ )
229
+ ledger_model = models.OneToOneField(
230
+ 'django_ledger.LedgerModel',
231
+ editable=False,
232
+ on_delete=models.CASCADE,
233
+ verbose_name=_('Ledger Model'),
234
+ null=True,
235
+ blank=True,
236
+ )
237
+ completed = models.BooleanField(
238
+ default=False, verbose_name=_('Import Job Completed')
239
+ )
194
240
  objects = ImportJobModelManager()
195
241
 
196
242
  class Meta:
@@ -258,10 +304,9 @@ class ImportJobModelAbstract(CreateUpdateMixIn):
258
304
  True if both `ledger_model_id` and `bank_account_model_id` attributes
259
305
  are set (not None), otherwise False.
260
306
  """
261
- return all([
262
- self.ledger_model_id is not None,
263
- self.bank_account_model_id is not None
264
- ])
307
+ return all(
308
+ [self.ledger_model_id is not None, self.bank_account_model_id is not None]
309
+ )
265
310
 
266
311
  def configure(self, commit: bool = True):
267
312
  """
@@ -282,15 +327,25 @@ class ImportJobModelAbstract(CreateUpdateMixIn):
282
327
  name=self.description
283
328
  )
284
329
  if commit:
285
- self.save(
286
- update_fields=[
287
- 'ledger_model'
288
- ])
330
+ self.save(update_fields=['ledger_model'])
289
331
 
290
332
  def get_delete_message(self) -> str:
291
333
  return _(f'Are you sure you want to delete Import Job {self.description}?')
292
334
 
293
335
 
336
+ class StagedTransactionModelValidationError(ValidationError):
337
+ """
338
+ A custom exception class that represents errors during staged model validation.
339
+
340
+ This exception is a specialized type of ValidationError that can be raised
341
+ during the validation process of staged models. It is intended to provide
342
+ an explicit representation of validation failures specifically designed for
343
+ use cases involving staged models in the application.
344
+ """
345
+
346
+ pass
347
+
348
+
294
349
  class StagedTransactionModelQuerySet(QuerySet):
295
350
  """
296
351
  Represents a custom QuerySet for handling staged transaction models.
@@ -367,6 +422,21 @@ class StagedTransactionModelQuerySet(QuerySet):
367
422
 
368
423
 
369
424
  class StagedTransactionModelManager(Manager):
425
+ """
426
+ Manager for staged transaction models to provide custom querysets.
427
+
428
+ This manager is customized to enhance query access for staged transaction models.
429
+ The main functionality includes fetching related fields, adding annotations to
430
+ facilitate business logic computations, and sorting the resulting queryset. It
431
+ incorporates annotations to compute field values like entity slug, child transaction
432
+ mappings, grouping IDs, readiness for import, and eligibility for splitting into
433
+ journal entries. The manager simplifies accessing such precomputed fields.
434
+
435
+ Methods
436
+ -------
437
+ get_queryset():
438
+ Fetch and annotate the queryset with related fields and calculated annotations.
439
+ """
370
440
 
371
441
  def get_queryset(self):
372
442
  """
@@ -386,165 +456,280 @@ class StagedTransactionModelManager(Manager):
386
456
  for staged transaction models.
387
457
  """
388
458
  qs = StagedTransactionModelQuerySet(self.model, using=self._db)
389
- return qs.select_related(
390
- 'account_model',
391
- 'unit_model',
392
- 'transaction_model',
393
- 'transaction_model__journal_entry',
394
- 'transaction_model__account',
395
-
396
- 'import_job',
397
- 'import_job__bank_account_model__account_model',
398
-
399
- # selecting parent data....
400
- 'parent',
401
- 'parent__account_model',
402
- 'parent__unit_model',
403
- ).annotate(
404
- entity_slug=F('import_job__bank_account_model__entity_model__slug'),
405
- entity_unit=F('transaction_model__journal_entry__entity_unit__name'),
406
- children_count=Count('split_transaction_set'),
407
- children_mapped_count=Count('split_transaction_set__account_model_id'),
408
- total_amount_split=Coalesce(
409
- Sum('split_transaction_set__amount_split'),
410
- Value(value=0.00, output_field=DecimalField())
411
- ),
412
- group_uuid=Case(
413
- When(parent_id__isnull=True, then=F('uuid')),
414
- When(parent_id__isnull=False, then=F('parent_id'))
415
- ),
416
- ).annotate(
417
- ready_to_import=Case(
418
- # is mapped singleton...
419
- When(
420
- condition=(
421
- Q(children_count__exact=0) &
422
- Q(account_model__isnull=False) &
423
- Q(parent__isnull=True) &
424
- Q(transaction_model__isnull=True)
425
- ),
426
- then=True
459
+ return (
460
+ qs.select_related(
461
+ 'account_model',
462
+ 'unit_model',
463
+ 'vendor_model',
464
+ 'customer_model',
465
+ 'transaction_model',
466
+ 'transaction_model__journal_entry',
467
+ 'transaction_model__account',
468
+ 'import_job',
469
+ 'import_job__bank_account_model__account_model',
470
+ # selecting parent data....
471
+ 'parent',
472
+ 'parent__account_model',
473
+ 'parent__unit_model',
474
+ )
475
+ .annotate(
476
+ entity_slug=F('import_job__bank_account_model__entity_model__slug'),
477
+ entity_unit=F('transaction_model__journal_entry__entity_unit__name'),
478
+ _receipt_uuid=F('receiptmodel__uuid'),
479
+ children_count=Count('split_transaction_set'),
480
+ children_mapped_count=Count(
481
+ 'split_transaction_set__account_model__uuid'
482
+ ),
483
+ total_amount_split=Coalesce(
484
+ Sum('split_transaction_set__amount_split'),
485
+ Value(value=0.00, output_field=DecimalField()),
486
+ ),
487
+ group_uuid=Case(
488
+ When(parent_id__isnull=True, then=F('uuid')),
489
+ When(parent_id__isnull=False, then=F('parent_id')),
427
490
  ),
428
- # is children, mapped and all parent amount is split...
429
- When(
430
- condition=(
431
- Q(children_count__gt=0) &
432
- Q(children_count=F('children_mapped_count')) &
433
- Q(total_amount_split__exact=F('amount')) &
434
- Q(parent__isnull=True) &
435
- Q(transaction_model__isnull=True)
491
+ )
492
+ .annotate(
493
+ children_mapping_pending_count=F('children_count')
494
+ - F('children_mapped_count'),
495
+ )
496
+ .annotate(
497
+ children_mapping_done=Case(
498
+ When(children_mapping_pending_count=0, then=True),
499
+ default=False,
500
+ output_field=BooleanField(),
501
+ ),
502
+ ready_to_import=Case(
503
+ # single transaction...
504
+ When(
505
+ condition=(
506
+ Q(children_count__exact=0)
507
+ & Q(account_model__isnull=False)
508
+ & Q(parent__isnull=True)
509
+ & Q(transaction_model__isnull=True)
510
+ & (
511
+ (
512
+ # transactions with no receipt...
513
+ Q(receipt_type__isnull=True)
514
+ & Q(vendor_model__isnull=True)
515
+ & Q(customer_model__isnull=True)
516
+ )
517
+ | (
518
+ # transaction with receipt...
519
+ Q(receipt_type__isnull=False)
520
+ & (
521
+ (
522
+ Q(vendor_model__isnull=False)
523
+ & Q(customer_model__isnull=True)
524
+ )
525
+ | (
526
+ Q(vendor_model__isnull=True)
527
+ & Q(customer_model__isnull=False)
528
+ )
529
+ )
530
+ )
531
+ )
532
+ ),
533
+ then=True,
534
+ ),
535
+ # is children, mapped and all parent amount is split...
536
+ When(
537
+ condition=(
538
+ # no receipt type selected...
539
+ # will import the transaction as is...
540
+ (
541
+ Q(children_count__gt=0)
542
+ & Q(receipt_type__isnull=True)
543
+ & Q(children_count=F('children_mapped_count'))
544
+ & Q(total_amount_split__exact=F('amount'))
545
+ & Q(parent__isnull=True)
546
+ & Q(transaction_model__isnull=True)
547
+ )
548
+ # receipt type is assigned... at least a customer or vendor is selected...
549
+ | (
550
+ Q(children_count__gt=0)
551
+ & Q(receipt_type__isnull=False)
552
+ & (
553
+ (
554
+ Q(vendor_model__isnull=False)
555
+ & Q(customer_model__isnull=True)
556
+ )
557
+ | (
558
+ Q(vendor_model__isnull=True)
559
+ & Q(customer_model__isnull=False)
560
+ )
561
+ )
562
+ & Q(children_count=F('children_mapped_count'))
563
+ & Q(total_amount_split__exact=F('amount'))
564
+ & Q(parent__isnull=True)
565
+ & Q(transaction_model__isnull=True)
566
+ )
567
+ ),
568
+ then=True,
436
569
  ),
437
- then=True
570
+ default=False,
571
+ output_field=BooleanField(),
438
572
  ),
439
- default=False,
440
- output_field=BooleanField()
441
- ),
442
- can_split_into_je=Case(
443
- When(
444
- condition=(
445
- Q(children_count__gt=0) &
446
- Q(children_count=F('children_mapped_count')) &
447
- Q(total_amount_split__exact=F('amount')) &
448
- Q(parent__isnull=True) &
449
- Q(transaction_model__isnull=True)
573
+ can_split_into_je=Case(
574
+ When(
575
+ condition=(
576
+ Q(children_count__gt=0)
577
+ & Q(children_count=F('children_mapped_count'))
578
+ & Q(total_amount_split__exact=F('amount'))
579
+ & Q(parent__isnull=True)
580
+ & Q(transaction_model__isnull=True)
581
+ ),
582
+ then=True,
450
583
  ),
451
- then=True
584
+ default=False,
585
+ output_field=BooleanField(),
452
586
  ),
453
- default=False,
454
- output_field=BooleanField()
455
587
  )
456
- ).order_by(
457
- 'date_posted',
458
- 'group_uuid',
459
- '-children_count'
588
+ .order_by('date_posted', 'group_uuid', '-children_count')
460
589
  )
461
590
 
462
591
 
463
592
  class StagedTransactionModelAbstract(CreateUpdateMixIn):
464
593
  """
465
- Represents an abstract model for staged transactions in a financial application.
594
+ Abstract model representing a staged transaction within the application.
466
595
 
467
- This abstract class is designed to handle and manage staged transactions that may be
468
- split into multiple child transactions for financial processing purposes. It includes
469
- various attributes and methods to validate, process, and structure financial data for
470
- import and transaction management. The model supports hierarchical relationships,
471
- role mapping, unit handling, and other important functionalities required for staged
472
- transactions.
596
+ This class defines the structure, behavior, and relationships for staged transactions.
597
+ It helps manage various aspects of financial transactions such as splitting, associating
598
+ with accounts, vendors, or customers, and bundling transactions. The model is abstract
599
+ and serves as a basis for actual concrete models in the application.
473
600
 
474
601
  Attributes
475
602
  ----------
476
603
  uuid : UUIDField
477
- The unique identifier for the transaction.
604
+ The unique identifier for the staged transaction.
478
605
  parent : ForeignKey
479
- Reference to the parent transaction if this is a child split transaction.
606
+ The parent transaction associated with this transaction in case of split transactions.
480
607
  import_job : ForeignKey
481
- Reference to the related import job that this staged transaction is part of.
608
+ Reference to the import job this transaction belongs to.
482
609
  fit_id : CharField
483
- Identifier related to the financial institution transaction (FIT ID).
610
+ A unique identifier for the financial institution's transaction ID.
484
611
  date_posted : DateField
485
612
  The date on which the transaction was posted.
486
613
  bundle_split : BooleanField
487
- Indicates whether the transaction's split children are bundled into one record.
488
- activity : CharField
489
- Proposed activity for the staged transaction (e.g., spending, income categorization).
490
- amount : DecimalField
491
- The transaction amount, representing the value of the main transaction.
492
- amount_split : DecimalField
493
- The split amount for children when the transaction is split.
494
- name : CharField
495
- The name or title for the transaction description.
496
- memo : CharField
497
- Additional information or notes attached to the transaction.
498
- account_model : ForeignKey
499
- The related account model this transaction is associated with.
500
- unit_model : ForeignKey
501
- The unit model or entity associated with this transaction for accounting purposes.
502
- transaction_model : OneToOneField
503
- The actual transaction model associated with this staged transaction post-import.
504
- objects : Manager
505
- Custom manager for handling queries related to `StagedTransactionModel`.
614
+ Indicates whether related split transactions should be bundled.
615
+ activity : CharField, optional
616
+ The proposed activity type for the transaction.
617
+ amount : DecimalField, optional
618
+ The primary transaction amount (non-editable).
619
+ amount_split : DecimalField, optional
620
+ The amount for split transactions.
621
+ name : CharField, optional
622
+ The name or short description of the transaction.
623
+ memo : CharField, optional
624
+ A memo or additional note related to the transaction.
625
+ account_model : ForeignKey, optional
626
+ The associated account model for the transaction.
627
+ unit_model : ForeignKey, optional
628
+ The entity unit model associated with the transaction.
629
+ transaction_model : OneToOneField, optional
630
+ Reference to a specific transaction model.
631
+ receipt_type : CharField, optional
632
+ Type of receipt associated with the transaction.
633
+ vendor_model : ForeignKey, optional
634
+ The vendor associated with the transaction.
635
+ customer_model : ForeignKey, optional
636
+ The customer associated with the transaction.
637
+
638
+ Meta
639
+ ----
640
+ abstract : bool
641
+ Indicates this is an abstract model.
642
+ verbose_name : str
643
+ The human-readable name for this model.
644
+ indexes : list
645
+ Indexes for optimizing database queries on certain fields.
646
+
647
+ Methods
648
+ -------
649
+ from_commit_dict(split_amount: Optional[Decimal]) -> List[Dict]
650
+ Converts a commit dictionary to a list of dictionaries containing transactional data.
651
+ to_commit_dict() -> List[Dict]
652
+ Converts the current transaction or its children into a list of commit dictionaries.
653
+ commit_dict(split_txs: bool) -> list
654
+ Generates a list of commit dictionaries or splits commit dictionaries based on staged amounts.
506
655
  """
656
+
507
657
  uuid = models.UUIDField(default=uuid4, editable=False, primary_key=True)
508
- parent = models.ForeignKey('self',
509
- null=True,
510
- blank=True,
511
- editable=False,
512
- on_delete=models.CASCADE,
513
- related_name='split_transaction_set',
514
- verbose_name=_('Parent Transaction'))
515
- import_job = models.ForeignKey('django_ledger.ImportJobModel', on_delete=models.CASCADE)
658
+ parent = models.ForeignKey(
659
+ 'self',
660
+ null=True,
661
+ blank=True,
662
+ editable=False,
663
+ on_delete=models.CASCADE,
664
+ related_name='split_transaction_set',
665
+ verbose_name=_('Parent Transaction'),
666
+ )
667
+ import_job = models.ForeignKey(
668
+ 'django_ledger.ImportJobModel', on_delete=models.CASCADE
669
+ )
516
670
  fit_id = models.CharField(max_length=100)
517
671
  date_posted = models.DateField(verbose_name=_('Date Posted'))
518
- bundle_split = models.BooleanField(default=True, verbose_name=_('Bundle Split Transactions'))
519
- activity = models.CharField(choices=JournalEntryModel.ACTIVITIES,
520
- max_length=20,
521
- null=True,
522
- blank=True,
523
- verbose_name=_('Proposed Activity'))
524
- amount = models.DecimalField(decimal_places=2,
525
- max_digits=15,
526
- editable=False,
527
- null=True,
528
- blank=True)
529
- amount_split = models.DecimalField(decimal_places=2, max_digits=15, null=True, blank=True)
672
+ bundle_split = models.BooleanField(
673
+ default=True, verbose_name=_('Bundle Split Transactions')
674
+ )
675
+ activity = models.CharField(
676
+ choices=JournalEntryModel.ACTIVITIES,
677
+ max_length=20,
678
+ null=True,
679
+ blank=True,
680
+ verbose_name=_('Proposed Activity'),
681
+ )
682
+ amount = models.DecimalField(
683
+ decimal_places=2, max_digits=15, editable=False, null=True, blank=True
684
+ )
685
+ amount_split = models.DecimalField(
686
+ decimal_places=2, max_digits=15, null=True, blank=True
687
+ )
530
688
  name = models.CharField(max_length=200, blank=True, null=True)
531
689
  memo = models.CharField(max_length=200, blank=True, null=True)
532
690
 
533
- account_model = models.ForeignKey('django_ledger.AccountModel',
534
- on_delete=models.RESTRICT,
535
- null=True,
536
- blank=True)
537
-
538
- unit_model = models.ForeignKey('django_ledger.EntityUnitModel',
539
- on_delete=models.RESTRICT,
540
- null=True,
541
- blank=True,
542
- verbose_name=_('Entity Unit Model'))
543
-
544
- transaction_model = models.OneToOneField('django_ledger.TransactionModel',
545
- on_delete=models.SET_NULL,
546
- null=True,
547
- blank=True)
691
+ account_model = models.ForeignKey(
692
+ 'django_ledger.AccountModel', on_delete=models.RESTRICT, null=True, blank=True
693
+ )
694
+
695
+ unit_model = models.ForeignKey(
696
+ 'django_ledger.EntityUnitModel',
697
+ on_delete=models.RESTRICT,
698
+ null=True,
699
+ blank=True,
700
+ verbose_name=_('Entity Unit Model'),
701
+ )
702
+
703
+ transaction_model = models.OneToOneField(
704
+ 'django_ledger.TransactionModel',
705
+ on_delete=models.SET_NULL,
706
+ null=True,
707
+ blank=True,
708
+ )
709
+ receipt_type = models.CharField(
710
+ choices=ReceiptModel.RECEIPT_TYPES,
711
+ max_length=20,
712
+ null=True,
713
+ blank=True,
714
+ verbose_name=_('Receipt Type'),
715
+ help_text=_('The receipt type of the transaction.'),
716
+ )
717
+ vendor_model = models.ForeignKey(
718
+ 'django_ledger.VendorModel',
719
+ on_delete=models.RESTRICT,
720
+ null=True,
721
+ blank=True,
722
+ verbose_name=_('Associated Vendor Model'),
723
+ help_text=_('The Vendor associated with the transaction.'),
724
+ )
725
+ customer_model = models.ForeignKey(
726
+ 'django_ledger.CustomerModel',
727
+ on_delete=models.RESTRICT,
728
+ null=True,
729
+ blank=True,
730
+ verbose_name=_('Associated Customer Model'),
731
+ help_text=_('The Customer associated with the transaction.'),
732
+ )
548
733
 
549
734
  objects = StagedTransactionModelManager()
550
735
 
@@ -565,6 +750,13 @@ class StagedTransactionModelAbstract(CreateUpdateMixIn):
565
750
  def __str__(self):
566
751
  return f'{self.__class__.__name__}: {self.get_amount()}'
567
752
 
753
+ def get_entity_slug(self) -> str:
754
+ try:
755
+ return getattr(self, 'entity_slug')
756
+ except AttributeError:
757
+ pass
758
+ return self.account_model.coa_model.entity.slug
759
+
568
760
  def from_commit_dict(self, split_amount: Optional[Decimal] = None) -> List[Dict]:
569
761
  """
570
762
  Converts a commit dictionary to a list of dictionaries containing
@@ -593,13 +785,15 @@ class StagedTransactionModelAbstract(CreateUpdateMixIn):
593
785
  the staged transaction model.
594
786
  """
595
787
  amt = split_amount if split_amount else self.amount
596
- return [{
597
- 'account': self.import_job.bank_account_model.account_model,
598
- 'amount': abs(amt),
599
- 'tx_type': DEBIT if not amt < 0.00 else CREDIT,
600
- 'description': self.name,
601
- 'staged_tx_model': self
602
- }]
788
+ return [
789
+ {
790
+ 'account': self.import_job.bank_account_model.account_model,
791
+ 'amount': abs(amt),
792
+ 'tx_type': DEBIT if not amt < 0.00 else CREDIT,
793
+ 'description': self.name,
794
+ 'staged_tx_model': self,
795
+ }
796
+ ]
603
797
 
604
798
  def to_commit_dict(self) -> List[Dict]:
605
799
  """
@@ -621,26 +815,33 @@ class StagedTransactionModelAbstract(CreateUpdateMixIn):
621
815
  children_qs = self.split_transaction_set.all().prefetch_related(
622
816
  'split_transaction_set',
623
817
  'split_transaction_set__account_model',
624
- 'split_transaction_set__unit_model'
818
+ 'split_transaction_set__unit_model',
625
819
  )
626
- return [{
627
- 'account': child_txs_model.account_model,
628
- 'amount': abs(child_txs_model.amount_split),
629
- 'amount_staged': child_txs_model.amount_split,
630
- 'unit_model': child_txs_model.unit_model,
631
- 'tx_type': CREDIT if not child_txs_model.amount_split < 0.00 else DEBIT,
632
- 'description': child_txs_model.name,
633
- 'staged_tx_model': child_txs_model
634
- } for child_txs_model in children_qs]
635
- return [{
636
- 'account': self.account_model,
637
- 'amount': abs(self.amount),
638
- 'amount_staged': self.amount,
639
- 'unit_model': self.unit_model,
640
- 'tx_type': CREDIT if not self.amount < 0.00 else DEBIT,
641
- 'description': self.name,
642
- 'staged_tx_model': self
643
- }]
820
+ return [
821
+ {
822
+ 'account': child_txs_model.account_model,
823
+ 'amount': abs(child_txs_model.amount_split),
824
+ 'amount_staged': child_txs_model.amount_split,
825
+ 'unit_model': child_txs_model.unit_model,
826
+ 'tx_type': CREDIT
827
+ if not child_txs_model.amount_split < 0.00
828
+ else DEBIT,
829
+ 'description': child_txs_model.name,
830
+ 'staged_tx_model': child_txs_model,
831
+ }
832
+ for child_txs_model in children_qs
833
+ ]
834
+ return [
835
+ {
836
+ 'account': self.account_model,
837
+ 'amount': abs(self.amount),
838
+ 'amount_staged': self.amount,
839
+ 'unit_model': self.unit_model,
840
+ 'tx_type': CREDIT if not self.amount < 0.00 else DEBIT,
841
+ 'description': self.name,
842
+ 'staged_tx_model': self,
843
+ }
844
+ ]
644
845
 
645
846
  def commit_dict(self, split_txs: bool = False):
646
847
  """
@@ -665,9 +866,10 @@ class StagedTransactionModelAbstract(CreateUpdateMixIn):
665
866
  to_commit = self.to_commit_dict()
666
867
  return [
667
868
  [
668
- self.from_commit_dict(split_amount=to_split['amount_staged'])[0], to_split
669
- ] for to_split in
670
- to_commit
869
+ self.from_commit_dict(split_amount=to_split['amount_staged'])[0],
870
+ to_split,
871
+ ]
872
+ for to_split in to_commit
671
873
  ]
672
874
  return [self.from_commit_dict() + self.to_commit_dict()]
673
875
 
@@ -689,6 +891,26 @@ class StagedTransactionModelAbstract(CreateUpdateMixIn):
689
891
  return self.amount_split
690
892
  return self.amount
691
893
 
894
+ def is_sales(self) -> bool:
895
+ if self.is_children():
896
+ return self.parent.is_sales()
897
+ return any(
898
+ [
899
+ self.receipt_type == ReceiptModel.SALES_RECEIPT,
900
+ self.receipt_type == ReceiptModel.SALES_REFUND,
901
+ ]
902
+ )
903
+
904
+ def is_expense(self) -> bool:
905
+ if self.is_children():
906
+ return self.parent.is_expense()
907
+ return any(
908
+ [
909
+ self.receipt_type == ReceiptModel.EXPENSE_RECEIPT,
910
+ self.receipt_type == ReceiptModel.EXPENSE_REFUND,
911
+ ]
912
+ )
913
+
692
914
  def is_imported(self) -> bool:
693
915
  """
694
916
  Determines if the necessary models have been imported for the system to function
@@ -701,10 +923,12 @@ class StagedTransactionModelAbstract(CreateUpdateMixIn):
701
923
  True if both `account_model_id` and `transaction_model_id` are not None,
702
924
  indicating that the models have been successfully imported. False otherwise.
703
925
  """
704
- return all([
705
- self.account_model_id is not None,
706
- self.transaction_model_id is not None,
707
- ])
926
+ return all(
927
+ [
928
+ self.account_model_id is not None,
929
+ self.transaction_model_id is not None,
930
+ ]
931
+ )
708
932
 
709
933
  def is_pending(self) -> bool:
710
934
  """
@@ -752,10 +976,7 @@ class StagedTransactionModelAbstract(CreateUpdateMixIn):
752
976
  bool
753
977
  True if the entry is a single, standalone entry; False otherwise.
754
978
  """
755
- return all([
756
- not self.is_children(),
757
- not self.has_children()
758
- ])
979
+ return all([not self.is_children(), not self.has_children()])
759
980
 
760
981
  def is_children(self) -> bool:
761
982
  """
@@ -770,9 +991,11 @@ class StagedTransactionModelAbstract(CreateUpdateMixIn):
770
991
  True if the object has a valid `parent_id`, indicating it is a child entity;
771
992
  False otherwise.
772
993
  """
773
- return all([
774
- self.parent_id is not None,
775
- ])
994
+ return all(
995
+ [
996
+ self.parent_id is not None,
997
+ ]
998
+ )
776
999
 
777
1000
  def has_activity(self) -> bool:
778
1001
  """
@@ -809,6 +1032,51 @@ class StagedTransactionModelAbstract(CreateUpdateMixIn):
809
1032
  return False
810
1033
  return getattr(self, 'children_count') > 0
811
1034
 
1035
+ @property
1036
+ def receipt_uuid(self):
1037
+ try:
1038
+ return getattr(self, '_receipt_uuid')
1039
+ except AttributeError:
1040
+ pass
1041
+ return self.receiptmodel.uuid
1042
+
1043
+ def can_migrate_receipt(self) -> bool:
1044
+ if self.is_children():
1045
+ return self.parent.receipt_type is not None
1046
+ return self.receipt_type is not None
1047
+
1048
+ def has_receipt(self) -> bool:
1049
+ if self.is_children():
1050
+ return all(
1051
+ [self.parent.receipt_type is not None, self.receipt_uuid is not None]
1052
+ )
1053
+ return all([self.receipt_type is not None, self.receipt_uuid is not None])
1054
+
1055
+ def has_mapped_receipt(self) -> bool:
1056
+ if all(
1057
+ [
1058
+ self.receipt_type is not None,
1059
+ any(
1060
+ [
1061
+ all(
1062
+ [
1063
+ self.vendor_model_id is not None,
1064
+ self.customer_model_id is None,
1065
+ ]
1066
+ ),
1067
+ all(
1068
+ [
1069
+ self.vendor_model_id is None,
1070
+ self.customer_model_id is not None,
1071
+ ]
1072
+ ),
1073
+ ]
1074
+ ),
1075
+ ]
1076
+ ):
1077
+ return True
1078
+ return False
1079
+
812
1080
  def can_split(self) -> bool:
813
1081
  """
814
1082
  Determines if the current object can be split based on its child status.
@@ -822,7 +1090,7 @@ class StagedTransactionModelAbstract(CreateUpdateMixIn):
822
1090
  `True` if the object has no children and can be split, otherwise
823
1091
  `False`.
824
1092
  """
825
- return not self.is_children()
1093
+ return all([not self.has_children(), not self.has_receipt()])
826
1094
 
827
1095
  def can_have_unit(self) -> bool:
828
1096
  """
@@ -846,18 +1114,23 @@ class StagedTransactionModelAbstract(CreateUpdateMixIn):
846
1114
  if self.is_single():
847
1115
  return True
848
1116
 
849
- if all([
850
- self.has_children(),
851
- self.has_activity(),
852
- self.are_all_children_mapped(),
853
- self.bundle_split is True
854
- ]):
1117
+ # parent transaction...
1118
+ if all(
1119
+ [
1120
+ self.has_children(),
1121
+ # self.has_activity(),
1122
+ # self.are_all_children_mapped(),
1123
+ self.bundle_split is True,
1124
+ ]
1125
+ ):
855
1126
  return True
856
1127
 
857
- if all([
858
- self.is_children(),
859
- self.parent.bundle_split is False if self.parent_id else False
860
- ]):
1128
+ if all(
1129
+ [
1130
+ self.is_children(),
1131
+ self.parent.bundle_split is False if self.parent_id else False,
1132
+ ]
1133
+ ):
861
1134
  return True
862
1135
 
863
1136
  return False
@@ -877,7 +1150,10 @@ class StagedTransactionModelAbstract(CreateUpdateMixIn):
877
1150
  """
878
1151
  return not self.has_children()
879
1152
 
880
- def can_import(self, as_split: bool = False) -> bool:
1153
+ def can_have_activity(self) -> bool:
1154
+ return self.account_model_id is not None
1155
+
1156
+ def can_migrate(self, as_split: bool = False) -> bool:
881
1157
  """
882
1158
  Determines whether the object is ready for importing data and can optionally
883
1159
  be split into "je" (journal entries) for import if applicable.
@@ -909,9 +1185,10 @@ class StagedTransactionModelAbstract(CreateUpdateMixIn):
909
1185
  can_split_into_je = getattr(self, 'can_split_into_je')
910
1186
  if can_split_into_je and as_split:
911
1187
  return True
912
- return all([
913
- self.is_role_mapping_valid(raise_exception=False)
914
- ])
1188
+ return all([self.is_role_mapping_valid(raise_exception=False)])
1189
+
1190
+ def can_import(self) -> bool:
1191
+ return self.can_migrate()
915
1192
 
916
1193
  def add_split(self, raise_exception: bool = True, commit: bool = True, n: int = 1):
917
1194
  """
@@ -957,8 +1234,9 @@ class StagedTransactionModelAbstract(CreateUpdateMixIn):
957
1234
  date_posted=self.date_posted,
958
1235
  amount=None,
959
1236
  amount_split=Decimal('0.00'),
960
- name=f'SPLIT: {self.name}'
961
- ) for _ in range(n)
1237
+ name=f'SPLIT: {self.name}',
1238
+ )
1239
+ for _ in range(n)
962
1240
  ]
963
1241
 
964
1242
  for txs in new_txs:
@@ -1019,10 +1297,18 @@ class StagedTransactionModelAbstract(CreateUpdateMixIn):
1019
1297
  if self.has_children():
1020
1298
  split_txs_qs = self.split_transaction_set.all()
1021
1299
  if all([txs.is_mapped() for txs in split_txs_qs]):
1022
- return set([txs.account_model.role for txs in split_txs_qs if txs.account_model.role != ASSET_CA_CASH])
1300
+ return set(
1301
+ [
1302
+ txs.account_model.role
1303
+ for txs in split_txs_qs
1304
+ if txs.account_model.role != ASSET_CA_CASH
1305
+ ]
1306
+ )
1023
1307
  return set()
1024
1308
 
1025
- def get_prospect_je_activity_try(self, raise_exception: bool = True, force_update: bool = False) -> Optional[str]:
1309
+ def get_prospect_je_activity_try(
1310
+ self, raise_exception: bool = True, force_update: bool = False
1311
+ ) -> Optional[str]:
1026
1312
  """
1027
1313
  Retrieve or attempt to fetch the journal entry activity for the current prospect object.
1028
1314
 
@@ -1049,11 +1335,12 @@ class StagedTransactionModelAbstract(CreateUpdateMixIn):
1049
1335
  """
1050
1336
  ready_to_import = getattr(self, 'ready_to_import')
1051
1337
  if (not self.has_activity() and ready_to_import) or force_update:
1052
- JournalEntryModel = lazy_loader.get_journal_entry_model()
1053
1338
  role_set = self.get_import_role_set()
1054
1339
  if role_set is not None:
1055
1340
  try:
1056
- self.activity = JournalEntryModel.get_activity_from_roles(role_set=role_set)
1341
+ self.activity = JournalEntryModel.get_activity_from_roles(
1342
+ role_set=role_set
1343
+ )
1057
1344
  self.save(update_fields=['activity'])
1058
1345
  return self.activity
1059
1346
  except ValidationError as e:
@@ -1090,9 +1377,7 @@ class StagedTransactionModelAbstract(CreateUpdateMixIn):
1090
1377
  otherwise None.
1091
1378
  """
1092
1379
  activity = self.get_prospect_je_activity_try(raise_exception=False)
1093
- if activity is not None:
1094
- JournalEntryModel = lazy_loader.get_journal_entry_model()
1095
- return JournalEntryModel.MAP_ACTIVITIES[activity]
1380
+ return JournalEntryModel.MAP_ACTIVITIES[activity] if activity else None
1096
1381
 
1097
1382
  def is_role_mapping_valid(self, raise_exception: bool = False) -> bool:
1098
1383
  """
@@ -1120,7 +1405,9 @@ class StagedTransactionModelAbstract(CreateUpdateMixIn):
1120
1405
  """
1121
1406
  if not self.has_activity():
1122
1407
  try:
1123
- activity = self.get_prospect_je_activity_try(raise_exception=raise_exception)
1408
+ activity = self.get_prospect_je_activity_try(
1409
+ raise_exception=raise_exception
1410
+ )
1124
1411
  if activity is None:
1125
1412
  return False
1126
1413
  self.activity = activity
@@ -1131,7 +1418,10 @@ class StagedTransactionModelAbstract(CreateUpdateMixIn):
1131
1418
  return False
1132
1419
  return True
1133
1420
 
1134
- def migrate(self, split_txs: bool = False):
1421
+ def get_coa_account_model(self) -> AccountModel:
1422
+ return self.import_job.bank_account_model.account_model
1423
+
1424
+ def migrate_transactions(self, split_txs: bool = False):
1135
1425
  """
1136
1426
  Migrate transactional data to the ledger model by processing the provided
1137
1427
  transactions and committing them. This process involves using the provided
@@ -1152,27 +1442,128 @@ class StagedTransactionModelAbstract(CreateUpdateMixIn):
1152
1442
  The saved objects are staged with appropriate models to retain the
1153
1443
  transaction state.
1154
1444
  """
1155
- if self.can_import(as_split=split_txs):
1156
- commit_dict = self.commit_dict(split_txs=split_txs)
1157
- import_job = self.import_job
1158
- ledger_model = import_job.ledger_model
1445
+ if self.has_receipt():
1446
+ raise StagedTransactionModelValidationError(
1447
+ 'Migrate transactions can only be performed on non-receipt transactions. Use migrate_receipt() instead.'
1448
+ )
1449
+ if not self.can_migrate():
1450
+ raise StagedTransactionModelValidationError(
1451
+ f'Transaction {self.uuid} is not ready to be migrated'
1452
+ )
1453
+
1454
+ commit_dict = self.commit_dict(split_txs=split_txs)
1455
+ import_job = self.import_job
1456
+ ledger_model = import_job.ledger_model
1159
1457
 
1160
- if len(commit_dict) > 0:
1458
+ if len(commit_dict) > 0:
1459
+ with transaction.atomic():
1460
+ staged_to_save = list()
1161
1461
  for je_data in commit_dict:
1162
- unit_model = self.unit_model if not split_txs else commit_dict[0][1]['unit_model']
1462
+ unit_model = (
1463
+ self.unit_model
1464
+ if not split_txs
1465
+ else commit_dict[0][1]['unit_model']
1466
+ )
1163
1467
  _, _ = ledger_model.commit_txs(
1164
1468
  je_timestamp=self.date_posted,
1165
1469
  je_unit_model=unit_model,
1166
1470
  je_txs=je_data,
1167
1471
  je_desc=self.memo,
1168
1472
  je_posted=False,
1169
- force_je_retrieval=False
1473
+ force_je_retrieval=False,
1170
1474
  )
1171
- staged_to_save = [i['staged_tx_model'] for i in je_data]
1172
- for i in staged_to_save:
1173
- i.save(
1174
- update_fields=['transaction_model', 'updated']
1175
- )
1475
+ staged_to_save += [i['staged_tx_model'] for i in je_data]
1476
+ # staged_to_save = set(i['staged_tx_model'] for i in je_data)
1477
+ # for i in staged_to_save:
1478
+ # i.save(update_fields=['transaction_model', 'updated'])
1479
+ staged_to_save = set(staged_to_save)
1480
+ for i in staged_to_save:
1481
+ i.save(update_fields=['transaction_model', 'updated'])
1482
+
1483
+ def migrate_receipt(self, receipt_date: Optional[date | datetime] = None):
1484
+ if not self.can_migrate_receipt():
1485
+ raise StagedTransactionModelValidationError(
1486
+ 'Migrate receipts can only be performed on receipt transactions. Use migrate_transactions() instead.'
1487
+ )
1488
+ if not self.can_migrate():
1489
+ raise StagedTransactionModelValidationError(
1490
+ f'Transaction {self.uuid} is not ready to be migratedd'
1491
+ )
1492
+
1493
+ with transaction.atomic():
1494
+ receipt_model: ReceiptModel = self.generate_receipt_model(
1495
+ receipt_date=receipt_date, commit=True
1496
+ )
1497
+ receipt_model.migrate_receipt()
1498
+
1499
+ def generate_receipt_model(
1500
+ self, receipt_date: Optional[date] = None, commit: bool = False
1501
+ ) -> ReceiptModel:
1502
+ if receipt_date:
1503
+ if isinstance(receipt_date, datetime):
1504
+ receipt_date = receipt_date.date()
1505
+
1506
+ receipt_model = ReceiptModel()
1507
+
1508
+ if commit:
1509
+ receipt_model.configure(
1510
+ receipt_date=receipt_date,
1511
+ entity_model=self.entity_slug,
1512
+ amount=abs(self.amount),
1513
+ unit_model=self.unit_model,
1514
+ receipt_type=self.receipt_type,
1515
+ vendor_model=self.vendor_model if self.is_expense() else None,
1516
+ customer_model=self.customer_model if self.is_sales() else None,
1517
+ charge_account=self.get_coa_account_model(),
1518
+ receipt_account=self.account_model,
1519
+ staged_transaction_model=self,
1520
+ commit=True,
1521
+ )
1522
+
1523
+ return receipt_model
1524
+
1525
+ # UNDO
1526
+ def undo_import(self, raise_exception: bool = True):
1527
+ """
1528
+ Undo import operation for a staged transaction. This method handles the deletion
1529
+ of related receipt or transaction models, as well as their associated data,
1530
+ if applicable. If no related data is available to undo, raises a validation
1531
+ error specifying that there is nothing to undo.
1532
+
1533
+ Raises
1534
+ ------
1535
+ ValidationError
1536
+ If there is no receipt model or transaction model to undo.
1537
+
1538
+ """
1539
+ with transaction.atomic():
1540
+ # Receipt import case...
1541
+ try:
1542
+ receipt_model = self.receiptmodel
1543
+ except ObjectDoesNotExist:
1544
+ receipt_model = None
1545
+
1546
+ if receipt_model is not None:
1547
+ receipt_model.delete()
1548
+
1549
+ if self.transaction_model_id:
1550
+ self.transaction_model = None
1551
+ self.save(update_fields=['transaction_model', 'updated'])
1552
+ return
1553
+
1554
+ # Transaction Import case....
1555
+ if self.transaction_model_id:
1556
+ tx_model = self.transaction_model
1557
+ journal_entry_model = tx_model.journal_entry
1558
+ journal_entry_model.delete()
1559
+ self.transaction_model = None
1560
+ self.save(update_fields=['transaction_model', 'updated'])
1561
+ return
1562
+
1563
+ if raise_exception:
1564
+ raise StagedTransactionModelValidationError(
1565
+ message=_('Nothing to undo for this staged transaction.')
1566
+ )
1176
1567
 
1177
1568
  def clean(self, verify: bool = False):
1178
1569
  if self.has_children():
@@ -1185,13 +1576,34 @@ class StagedTransactionModelAbstract(CreateUpdateMixIn):
1185
1576
  if self.parent_id:
1186
1577
  self.unit_model = self.parent.unit_model
1187
1578
 
1579
+ if not self.can_have_activity():
1580
+ self.activity = None
1581
+
1582
+ if self.is_children():
1583
+ self.vendor_model = None
1584
+ self.customer_model = None
1585
+
1586
+ if all([self.has_children(), self.has_receipt(), not self.bundle_split]):
1587
+ raise StagedTransactionModelValidationError(
1588
+ 'Receipt transactions cannot be split into multiple receipts.'
1589
+ )
1590
+
1188
1591
  if verify:
1189
1592
  self.is_role_mapping_valid(raise_exception=True)
1190
1593
 
1191
1594
 
1192
1595
  class ImportJobModel(ImportJobModelAbstract):
1193
1596
  """
1194
- Transaction Import Job Model Base Class.
1597
+ Represents the ImportJobModel class.
1598
+
1599
+ This class inherits from ImportJobModelAbstract and is specifically designed
1600
+ to provide implementations and metadata for import job entries. It defines the
1601
+ Meta subclass, which overrides the abstract attribute indicating whether this
1602
+ model is abstract or not.
1603
+
1604
+ Attributes
1605
+ ----------
1606
+ None
1195
1607
  """
1196
1608
 
1197
1609
  class Meta(ImportJobModelAbstract.Meta):
@@ -1199,10 +1611,35 @@ class ImportJobModel(ImportJobModelAbstract):
1199
1611
 
1200
1612
 
1201
1613
  def importjobmodel_presave(instance: ImportJobModel, **kwargs):
1614
+ """
1615
+ Handles pre-save validation for ImportJobModel instances.
1616
+
1617
+ This function ensures that the provided `ImportJobModel` instance is properly
1618
+ configured and validates its integrity with respect to related entities, such as
1619
+ the Bank Account Model and Ledger Model.
1620
+
1621
+ Parameters
1622
+ ----------
1623
+ instance : ImportJobModel
1624
+ The instance of ImportJobModel being saved.
1625
+ **kwargs
1626
+ Additional arguments passed to the pre-save signal.
1627
+
1628
+ Raises
1629
+ ------
1630
+ ImportJobModelValidationError
1631
+ If the Bank Account Model associated with the instance does not match the
1632
+ entity ID of the Ledger Model.
1633
+ """
1202
1634
  if instance.is_configured():
1203
- if instance.bank_account_model.entity_model_id != instance.ledger_model.entity_id:
1635
+ if (
1636
+ instance.bank_account_model.entity_model_id
1637
+ != instance.ledger_model.entity_id
1638
+ ):
1204
1639
  raise ImportJobModelValidationError(
1205
- message=_('Invalid Bank Account for LedgerModel. No matching Entity Model found.')
1640
+ message=_(
1641
+ 'Invalid Bank Account for LedgerModel. No matching Entity Model found.'
1642
+ )
1206
1643
  )
1207
1644
 
1208
1645
 
@@ -1211,8 +1648,52 @@ pre_save.connect(importjobmodel_presave, sender=ImportJobModel)
1211
1648
 
1212
1649
  class StagedTransactionModel(StagedTransactionModelAbstract):
1213
1650
  """
1214
- Staged Transaction Model Base Class.
1651
+ Represents a concrete implementation of a staged transaction model.
1652
+
1653
+ This class is derived from `StagedTransactionModelAbstract` and provides
1654
+ a concrete implementation by overriding its meta-configuration. It is
1655
+ used to define the structure and behavior of the staged transaction
1656
+ records in the system.
1657
+
1658
+ Attributes
1659
+ ----------
1660
+ Meta : class
1661
+ A nested class that extends the meta-configuration of
1662
+ the `StagedTransactionModelAbstract.Meta` class, specifying
1663
+ that the model is not abstract.
1215
1664
  """
1216
1665
 
1217
1666
  class Meta(StagedTransactionModelAbstract.Meta):
1218
1667
  abstract = False
1668
+
1669
+
1670
+ def stagedtransactionmodel_presave(instance: StagedTransactionModel, **kwargs):
1671
+ """
1672
+ Validates the instance of StagedTransactionModel before saving.
1673
+
1674
+ This function ensures that either `customer_model_id` or `vendor_model_id`
1675
+ is set on the given instance but not both. If both attributes are present,
1676
+ an exception is raised to prevent invalid data from being saved.
1677
+
1678
+ Parameters
1679
+ ----------
1680
+ instance : StagedTransactionModel
1681
+ The instance of the model to be validated.
1682
+
1683
+ kwargs : dict
1684
+ Additional keyword arguments, which are currently not used but
1685
+ are included for potential future extensibility.
1686
+
1687
+ Raises
1688
+ ------
1689
+ StagedModelValidationError
1690
+ If both `customer_model_id` and `vendor_model_id` are set on the instance.
1691
+
1692
+ """
1693
+ if all([instance.customer_model_id, instance.vendor_model_id]):
1694
+ raise StagedTransactionModelValidationError(
1695
+ message=_('Either customer or vendor model allowed.'),
1696
+ )
1697
+
1698
+
1699
+ pre_save.connect(stagedtransactionmodel_presave, sender=StagedTransactionModel)