django-ledger 0.7.11__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 (139) hide show
  1. django_ledger/__init__.py +1 -1
  2. django_ledger/context.py +12 -0
  3. django_ledger/forms/account.py +45 -46
  4. django_ledger/forms/bill.py +0 -4
  5. django_ledger/forms/closing_entry.py +13 -1
  6. django_ledger/forms/data_import.py +182 -63
  7. django_ledger/forms/estimate.py +3 -6
  8. django_ledger/forms/invoice.py +3 -7
  9. django_ledger/forms/item.py +10 -18
  10. django_ledger/forms/purchase_order.py +2 -4
  11. django_ledger/io/io_core.py +515 -400
  12. django_ledger/io/io_generator.py +7 -6
  13. django_ledger/io/io_library.py +1 -2
  14. django_ledger/migrations/0025_alter_billmodel_cash_account_and_more.py +70 -0
  15. django_ledger/migrations/0026_stagedtransactionmodel_customer_model_and_more.py +56 -0
  16. django_ledger/models/__init__.py +2 -1
  17. django_ledger/models/accounts.py +109 -69
  18. django_ledger/models/bank_account.py +40 -23
  19. django_ledger/models/bill.py +386 -333
  20. django_ledger/models/chart_of_accounts.py +173 -105
  21. django_ledger/models/closing_entry.py +99 -48
  22. django_ledger/models/customer.py +100 -66
  23. django_ledger/models/data_import.py +818 -323
  24. django_ledger/models/deprecations.py +61 -0
  25. django_ledger/models/entity.py +891 -644
  26. django_ledger/models/estimate.py +57 -28
  27. django_ledger/models/invoice.py +46 -26
  28. django_ledger/models/items.py +503 -142
  29. django_ledger/models/journal_entry.py +61 -47
  30. django_ledger/models/ledger.py +106 -42
  31. django_ledger/models/mixins.py +424 -281
  32. django_ledger/models/purchase_order.py +39 -17
  33. django_ledger/models/receipt.py +1083 -0
  34. django_ledger/models/transactions.py +242 -139
  35. django_ledger/models/unit.py +93 -54
  36. django_ledger/models/utils.py +12 -2
  37. django_ledger/models/vendor.py +121 -70
  38. django_ledger/report/core.py +2 -14
  39. django_ledger/settings.py +57 -71
  40. django_ledger/static/django_ledger/bundle/djetler.bundle.js +1 -1
  41. django_ledger/static/django_ledger/bundle/djetler.bundle.js.LICENSE.txt +25 -0
  42. django_ledger/static/django_ledger/bundle/styles.bundle.js +1 -1
  43. django_ledger/static/django_ledger/css/djl_styles.css +273 -0
  44. django_ledger/templates/django_ledger/bills/includes/card_bill.html +2 -2
  45. django_ledger/templates/django_ledger/components/menu.html +41 -26
  46. django_ledger/templates/django_ledger/components/period_navigator.html +5 -3
  47. django_ledger/templates/django_ledger/customer/customer_detail.html +87 -0
  48. django_ledger/templates/django_ledger/customer/customer_list.html +0 -1
  49. django_ledger/templates/django_ledger/customer/tags/customer_table.html +8 -6
  50. django_ledger/templates/django_ledger/data_import/tags/data_import_job_txs_imported.html +24 -3
  51. django_ledger/templates/django_ledger/data_import/tags/data_import_job_txs_table.html +26 -10
  52. django_ledger/templates/django_ledger/entity/entity_dashboard.html +2 -2
  53. django_ledger/templates/django_ledger/entity/includes/card_entity.html +12 -6
  54. django_ledger/templates/django_ledger/financial_statements/balance_sheet.html +1 -1
  55. django_ledger/templates/django_ledger/financial_statements/cash_flow.html +4 -1
  56. django_ledger/templates/django_ledger/financial_statements/income_statement.html +4 -1
  57. django_ledger/templates/django_ledger/financial_statements/tags/balance_sheet_statement.html +27 -3
  58. django_ledger/templates/django_ledger/financial_statements/tags/cash_flow_statement.html +16 -4
  59. django_ledger/templates/django_ledger/financial_statements/tags/income_statement.html +73 -18
  60. django_ledger/templates/django_ledger/includes/widget_ratios.html +18 -24
  61. django_ledger/templates/django_ledger/invoice/includes/card_invoice.html +3 -3
  62. django_ledger/templates/django_ledger/layouts/base.html +7 -2
  63. django_ledger/templates/django_ledger/layouts/content_layout_1.html +1 -1
  64. django_ledger/templates/django_ledger/receipt/customer_receipt_report.html +115 -0
  65. django_ledger/templates/django_ledger/receipt/receipt_delete.html +30 -0
  66. django_ledger/templates/django_ledger/receipt/receipt_detail.html +89 -0
  67. django_ledger/templates/django_ledger/receipt/receipt_list.html +134 -0
  68. django_ledger/templates/django_ledger/receipt/vendor_receipt_report.html +115 -0
  69. django_ledger/templates/django_ledger/vendor/tags/vendor_table.html +12 -7
  70. django_ledger/templates/django_ledger/vendor/vendor_detail.html +86 -0
  71. django_ledger/templatetags/django_ledger.py +338 -191
  72. django_ledger/tests/test_accounts.py +1 -2
  73. django_ledger/tests/test_io.py +17 -0
  74. django_ledger/tests/test_purchase_order.py +3 -3
  75. django_ledger/tests/test_transactions.py +1 -2
  76. django_ledger/urls/__init__.py +1 -4
  77. django_ledger/urls/customer.py +3 -0
  78. django_ledger/urls/data_import.py +3 -0
  79. django_ledger/urls/receipt.py +102 -0
  80. django_ledger/urls/vendor.py +1 -0
  81. django_ledger/views/__init__.py +1 -0
  82. django_ledger/views/bill.py +8 -11
  83. django_ledger/views/chart_of_accounts.py +6 -4
  84. django_ledger/views/closing_entry.py +11 -7
  85. django_ledger/views/customer.py +68 -30
  86. django_ledger/views/data_import.py +120 -66
  87. django_ledger/views/djl_api.py +3 -5
  88. django_ledger/views/entity.py +2 -4
  89. django_ledger/views/estimate.py +3 -7
  90. django_ledger/views/inventory.py +3 -5
  91. django_ledger/views/invoice.py +4 -6
  92. django_ledger/views/item.py +7 -11
  93. django_ledger/views/journal_entry.py +1 -2
  94. django_ledger/views/mixins.py +125 -93
  95. django_ledger/views/purchase_order.py +24 -35
  96. django_ledger/views/receipt.py +294 -0
  97. django_ledger/views/unit.py +1 -2
  98. django_ledger/views/vendor.py +54 -16
  99. {django_ledger-0.7.11.dist-info → django_ledger-0.8.1.dist-info}/METADATA +43 -75
  100. {django_ledger-0.7.11.dist-info → django_ledger-0.8.1.dist-info}/RECORD +104 -122
  101. {django_ledger-0.7.11.dist-info → django_ledger-0.8.1.dist-info}/WHEEL +1 -1
  102. django_ledger-0.8.1.dist-info/top_level.txt +1 -0
  103. django_ledger/contrib/django_ledger_graphene/__init__.py +0 -0
  104. django_ledger/contrib/django_ledger_graphene/accounts/schema.py +0 -33
  105. django_ledger/contrib/django_ledger_graphene/api.py +0 -42
  106. django_ledger/contrib/django_ledger_graphene/apps.py +0 -6
  107. django_ledger/contrib/django_ledger_graphene/auth/mutations.py +0 -49
  108. django_ledger/contrib/django_ledger_graphene/auth/schema.py +0 -6
  109. django_ledger/contrib/django_ledger_graphene/bank_account/mutations.py +0 -61
  110. django_ledger/contrib/django_ledger_graphene/bank_account/schema.py +0 -34
  111. django_ledger/contrib/django_ledger_graphene/bill/mutations.py +0 -0
  112. django_ledger/contrib/django_ledger_graphene/bill/schema.py +0 -34
  113. django_ledger/contrib/django_ledger_graphene/coa/mutations.py +0 -0
  114. django_ledger/contrib/django_ledger_graphene/coa/schema.py +0 -30
  115. django_ledger/contrib/django_ledger_graphene/customers/__init__.py +0 -0
  116. django_ledger/contrib/django_ledger_graphene/customers/mutations.py +0 -71
  117. django_ledger/contrib/django_ledger_graphene/customers/schema.py +0 -43
  118. django_ledger/contrib/django_ledger_graphene/data_import/mutations.py +0 -0
  119. django_ledger/contrib/django_ledger_graphene/data_import/schema.py +0 -0
  120. django_ledger/contrib/django_ledger_graphene/entity/mutations.py +0 -0
  121. django_ledger/contrib/django_ledger_graphene/entity/schema.py +0 -94
  122. django_ledger/contrib/django_ledger_graphene/item/mutations.py +0 -0
  123. django_ledger/contrib/django_ledger_graphene/item/schema.py +0 -31
  124. django_ledger/contrib/django_ledger_graphene/journal_entry/mutations.py +0 -0
  125. django_ledger/contrib/django_ledger_graphene/journal_entry/schema.py +0 -35
  126. django_ledger/contrib/django_ledger_graphene/ledger/mutations.py +0 -0
  127. django_ledger/contrib/django_ledger_graphene/ledger/schema.py +0 -32
  128. django_ledger/contrib/django_ledger_graphene/purchase_order/mutations.py +0 -0
  129. django_ledger/contrib/django_ledger_graphene/purchase_order/schema.py +0 -31
  130. django_ledger/contrib/django_ledger_graphene/transaction/mutations.py +0 -0
  131. django_ledger/contrib/django_ledger_graphene/transaction/schema.py +0 -36
  132. django_ledger/contrib/django_ledger_graphene/unit/mutations.py +0 -0
  133. django_ledger/contrib/django_ledger_graphene/unit/schema.py +0 -27
  134. django_ledger/contrib/django_ledger_graphene/vendor/mutations.py +0 -0
  135. django_ledger/contrib/django_ledger_graphene/vendor/schema.py +0 -37
  136. django_ledger/contrib/django_ledger_graphene/views.py +0 -12
  137. django_ledger-0.7.11.dist-info/top_level.txt +0 -4
  138. {django_ledger-0.7.11.dist-info → django_ledger-0.8.1.dist-info/licenses}/AUTHORS.md +0 -0
  139. {django_ledger-0.7.11.dist-info → django_ledger-0.8.1.dist-info/licenses}/LICENSE +0 -0
@@ -8,48 +8,102 @@ 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
  """
13
12
 
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
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
45
+ from django_ledger.settings import DJANGO_LEDGER_USE_DEPRECATED_BEHAVIOR
30
46
 
31
47
 
32
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
+
33
60
  pass
34
61
 
35
62
 
36
63
  class ImportJobModelQuerySet(QuerySet):
37
- pass
64
+ """ """
38
65
 
66
+ def for_user(self, user_model) -> 'ImportJobModelQuerySet':
67
+ """
68
+ Filters the queryset based on the user's permissions for accessing the data
69
+ related to bank accounts and entities they manage or administer.
39
70
 
40
- class ImportJobModelManager(Manager):
41
- """
42
- Manages queryset operations related to import jobs.
71
+ This method first retrieves the default queryset. If the user is a superuser,
72
+ the query will return the full queryset without any filters. Otherwise, the
73
+ query will be limited to the entities that the user either administers or is
74
+ listed as a manager for.
75
+
76
+ Parameters
77
+ ----------
78
+ user_model : User
79
+ The user model instance whose permissions determine the filtering of the queryset.
80
+
81
+ Returns
82
+ -------
83
+ QuerySet
84
+ A filtered queryset based on the user's role and associated permissions.
85
+ """
86
+ if user_model.is_superuser:
87
+ return self
88
+ return self.filter(
89
+ Q(bank_account_model__entity_model__admin=user_model)
90
+ | Q(bank_account_model__entity_model__managers__in=[user_model])
91
+ )
43
92
 
44
- This manager provides custom queryset handling for import job models, including
45
- annotations for custom fields like transaction counts, user-specific filters,
46
- and entity-specific filters. It is integrated with the ImportJobModel, designed
47
- to support complex query requirements with field annotations and related object
48
- optimizations for performance efficiency.
49
93
 
94
+ class ImportJobModelManager(Manager):
95
+ """
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.
50
104
  """
51
105
 
52
- def get_queryset(self):
106
+ def get_queryset(self) -> ImportJobModelQuerySet:
53
107
  """
54
108
  Generates a QuerySet with annotated data for ImportJobModel.
55
109
 
@@ -74,109 +128,115 @@ class ImportJobModelManager(Manager):
74
128
  (no pending transactions or total count is zero).
75
129
  """
76
130
  qs = ImportJobModelQuerySet(self.model, using=self._db)
77
- return qs.annotate(
78
- _entity_uuid=F('ledger_model__entity__uuid'),
79
- _entity_slug=F('ledger_model__entity__slug'),
80
- txs_count=Count('stagedtransactionmodel',
81
- filter=Q(stagedtransactionmodel__parent__isnull=False)),
82
- txs_mapped_count=Count(
83
- 'stagedtransactionmodel__account_model_id',
84
- filter=Q(stagedtransactionmodel__parent__isnull=False) |
85
- Q(stagedtransactionmodel__parent__parent__isnull=False)
86
-
87
- ),
88
- ).annotate(
89
- txs_pending=F('txs_count') - F('txs_mapped_count')
90
- ).annotate(
91
- is_complete=Case(
92
- When(txs_count__exact=0, then=False),
93
- When(txs_pending__exact=0, then=True),
94
- default=False,
95
- output_field=BooleanField()
96
- ),
97
- ).select_related(
98
- 'bank_account_model',
99
- 'bank_account_model__account_model',
100
- '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
+ )
101
159
  )
102
160
 
103
- def for_user(self, user_model):
104
- """
105
- Filters the queryset based on the user's permissions for accessing the data
106
- related to bank accounts and entities they manage or administer.
107
-
108
- This method first retrieves the default queryset. If the user is a superuser,
109
- the query will return the full queryset without any filters. Otherwise, the
110
- query will be limited to the entities that the user either administers or is
111
- listed as a manager for.
112
-
113
- Parameters
114
- ----------
115
- user_model : User
116
- The user model instance whose permissions determine the filtering of the queryset.
117
-
118
- Returns
119
- -------
120
- QuerySet
121
- A filtered queryset based on the user's role and associated permissions.
122
- """
161
+ @deprecated_entity_slug_behavior
162
+ def for_entity(
163
+ self, entity_model: Union[EntityModel, str, UUID] = None, **kwargs
164
+ ) -> ImportJobModelQuerySet:
123
165
  qs = self.get_queryset()
124
- if user_model.is_superuser:
125
- return qs
126
- return qs.filter(
127
- Q(bank_account_model__entity_model__admin=user_model) |
128
- Q(bank_account_model__entity_model__managers__in=[user_model])
129
-
130
- )
131
-
132
- def for_entity(self, entity_slug: Union[EntityModel, str], user_model):
133
- qs = self.for_user(user_model)
134
- if isinstance(entity_slug, EntityModel):
135
- return qs.filter(
136
- Q(bank_account_model__entity_model=entity_slug)
137
- )
138
- return qs.filter(
139
- Q(bank_account_model__entity_model__slug__exact=entity_slug)
140
- )
166
+ if 'user_model' in kwargs:
167
+ warnings.warn(
168
+ 'user_model parameter is deprecated and will be removed in a future release. '
169
+ 'Use for_user(user_model).for_entity(entity_model) instead to keep current behavior.',
170
+ DeprecationWarning,
171
+ stacklevel=2,
172
+ )
173
+ if DJANGO_LEDGER_USE_DEPRECATED_BEHAVIOR:
174
+ qs = qs.for_user(kwargs['user_model'])
175
+
176
+ if isinstance(entity_model, EntityModel):
177
+ qs = qs.filter(bank_account_model__entity_model=entity_model)
178
+ elif isinstance(entity_model, UUID):
179
+ qs = qs.filter(bank_account_model__entity_model_id=entity_model)
180
+ elif isinstance(entity_model, str):
181
+ qs = qs.filter(bank_account_model__slug__exact=entity_model)
182
+ else:
183
+ raise ImportJobModelValidationError(
184
+ message=_('Must pass EntityModel, slug or UUID'),
185
+ )
186
+ return qs
141
187
 
142
188
 
143
189
  class ImportJobModelAbstract(CreateUpdateMixIn):
144
190
  """
145
- Abstract model for managing import jobs within a financial system.
191
+ Represents an abstract model for managing import jobs.
146
192
 
147
- This abstract model serves as a foundational base for managing import jobs involving
148
- bank accounts and ledger models. It provides functionalities such as linking to an
149
- associated bank account and ledger model, determining completion status of the
150
- import job, and properties for UUID and slug identifiers. Additionally, helper
151
- 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.
152
198
 
153
199
  Attributes
154
200
  ----------
155
201
  uuid : UUID
156
- Unique identifier for the import job instance.
202
+ The universally unique identifier for the import job.
157
203
  description : str
158
- Descriptive label or description for the import job.
159
- bank_account_model : BankAccountModel
160
- Foreign key linking the import job to a bank account model.
161
- ledger_model : LedgerModel or None
162
- 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.
163
210
  completed : bool
164
211
  Indicates whether the import job has been completed.
165
212
  objects : ImportJobModelManager
166
- 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.
167
220
  """
221
+
168
222
  uuid = models.UUIDField(default=uuid4, editable=False, primary_key=True)
169
223
  description = models.CharField(max_length=200, verbose_name=_('Description'))
170
- bank_account_model = models.ForeignKey('django_ledger.BankAccountModel',
171
- on_delete=models.CASCADE,
172
- verbose_name=_('Associated Bank Account Model'))
173
- ledger_model = models.OneToOneField('django_ledger.LedgerModel',
174
- editable=False,
175
- on_delete=models.CASCADE,
176
- verbose_name=_('Ledger Model'),
177
- null=True,
178
- blank=True)
179
- 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
+ )
180
240
  objects = ImportJobModelManager()
181
241
 
182
242
  class Meta:
@@ -244,10 +304,9 @@ class ImportJobModelAbstract(CreateUpdateMixIn):
244
304
  True if both `ledger_model_id` and `bank_account_model_id` attributes
245
305
  are set (not None), otherwise False.
246
306
  """
247
- return all([
248
- self.ledger_model_id is not None,
249
- self.bank_account_model_id is not None
250
- ])
307
+ return all(
308
+ [self.ledger_model_id is not None, self.bank_account_model_id is not None]
309
+ )
251
310
 
252
311
  def configure(self, commit: bool = True):
253
312
  """
@@ -268,15 +327,25 @@ class ImportJobModelAbstract(CreateUpdateMixIn):
268
327
  name=self.description
269
328
  )
270
329
  if commit:
271
- self.save(
272
- update_fields=[
273
- 'ledger_model'
274
- ])
330
+ self.save(update_fields=['ledger_model'])
275
331
 
276
332
  def get_delete_message(self) -> str:
277
333
  return _(f'Are you sure you want to delete Import Job {self.description}?')
278
334
 
279
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
+
280
349
  class StagedTransactionModelQuerySet(QuerySet):
281
350
  """
282
351
  Represents a custom QuerySet for handling staged transaction models.
@@ -353,6 +422,21 @@ class StagedTransactionModelQuerySet(QuerySet):
353
422
 
354
423
 
355
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
+ """
356
440
 
357
441
  def get_queryset(self):
358
442
  """
@@ -372,165 +456,280 @@ class StagedTransactionModelManager(Manager):
372
456
  for staged transaction models.
373
457
  """
374
458
  qs = StagedTransactionModelQuerySet(self.model, using=self._db)
375
- return qs.select_related(
376
- 'account_model',
377
- 'unit_model',
378
- 'transaction_model',
379
- 'transaction_model__journal_entry',
380
- 'transaction_model__account',
381
-
382
- 'import_job',
383
- 'import_job__bank_account_model__account_model',
384
-
385
- # selecting parent data....
386
- 'parent',
387
- 'parent__account_model',
388
- 'parent__unit_model',
389
- ).annotate(
390
- entity_slug=F('import_job__bank_account_model__entity_model__slug'),
391
- entity_unit=F('transaction_model__journal_entry__entity_unit__name'),
392
- children_count=Count('split_transaction_set'),
393
- children_mapped_count=Count('split_transaction_set__account_model_id'),
394
- total_amount_split=Coalesce(
395
- Sum('split_transaction_set__amount_split'),
396
- Value(value=0.00, output_field=DecimalField())
397
- ),
398
- group_uuid=Case(
399
- When(parent_id__isnull=True, then=F('uuid')),
400
- When(parent_id__isnull=False, then=F('parent_id'))
401
- ),
402
- ).annotate(
403
- ready_to_import=Case(
404
- # is mapped singleton...
405
- When(
406
- condition=(
407
- Q(children_count__exact=0) &
408
- Q(account_model__isnull=False) &
409
- Q(parent__isnull=True) &
410
- Q(transaction_model__isnull=True)
411
- ),
412
- 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')),
490
+ ),
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(),
413
501
  ),
414
- # is children, mapped and all parent amount is split...
415
- When(
416
- condition=(
417
- Q(children_count__gt=0) &
418
- Q(children_count=F('children_mapped_count')) &
419
- Q(total_amount_split__exact=F('amount')) &
420
- Q(parent__isnull=True) &
421
- Q(transaction_model__isnull=True)
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,
422
534
  ),
423
- then=True
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,
569
+ ),
570
+ default=False,
571
+ output_field=BooleanField(),
424
572
  ),
425
- default=False,
426
- output_field=BooleanField()
427
- ),
428
- can_split_into_je=Case(
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)
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,
436
583
  ),
437
- then=True
584
+ default=False,
585
+ output_field=BooleanField(),
438
586
  ),
439
- default=False,
440
- output_field=BooleanField()
441
587
  )
442
- ).order_by(
443
- 'date_posted',
444
- 'group_uuid',
445
- '-children_count'
588
+ .order_by('date_posted', 'group_uuid', '-children_count')
446
589
  )
447
590
 
448
591
 
449
592
  class StagedTransactionModelAbstract(CreateUpdateMixIn):
450
593
  """
451
- Represents an abstract model for staged transactions in a financial application.
594
+ Abstract model representing a staged transaction within the application.
452
595
 
453
- This abstract class is designed to handle and manage staged transactions that may be
454
- split into multiple child transactions for financial processing purposes. It includes
455
- various attributes and methods to validate, process, and structure financial data for
456
- import and transaction management. The model supports hierarchical relationships,
457
- role mapping, unit handling, and other important functionalities required for staged
458
- 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.
459
600
 
460
601
  Attributes
461
602
  ----------
462
603
  uuid : UUIDField
463
- The unique identifier for the transaction.
604
+ The unique identifier for the staged transaction.
464
605
  parent : ForeignKey
465
- 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.
466
607
  import_job : ForeignKey
467
- Reference to the related import job that this staged transaction is part of.
608
+ Reference to the import job this transaction belongs to.
468
609
  fit_id : CharField
469
- Identifier related to the financial institution transaction (FIT ID).
610
+ A unique identifier for the financial institution's transaction ID.
470
611
  date_posted : DateField
471
612
  The date on which the transaction was posted.
472
613
  bundle_split : BooleanField
473
- Indicates whether the transaction's split children are bundled into one record.
474
- activity : CharField
475
- Proposed activity for the staged transaction (e.g., spending, income categorization).
476
- amount : DecimalField
477
- The transaction amount, representing the value of the main transaction.
478
- amount_split : DecimalField
479
- The split amount for children when the transaction is split.
480
- name : CharField
481
- The name or title for the transaction description.
482
- memo : CharField
483
- Additional information or notes attached to the transaction.
484
- account_model : ForeignKey
485
- The related account model this transaction is associated with.
486
- unit_model : ForeignKey
487
- The unit model or entity associated with this transaction for accounting purposes.
488
- transaction_model : OneToOneField
489
- The actual transaction model associated with this staged transaction post-import.
490
- objects : Manager
491
- 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.
492
655
  """
656
+
493
657
  uuid = models.UUIDField(default=uuid4, editable=False, primary_key=True)
494
- parent = models.ForeignKey('self',
495
- null=True,
496
- blank=True,
497
- editable=False,
498
- on_delete=models.CASCADE,
499
- related_name='split_transaction_set',
500
- verbose_name=_('Parent Transaction'))
501
- 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
+ )
502
670
  fit_id = models.CharField(max_length=100)
503
671
  date_posted = models.DateField(verbose_name=_('Date Posted'))
504
- bundle_split = models.BooleanField(default=True, verbose_name=_('Bundle Split Transactions'))
505
- activity = models.CharField(choices=JournalEntryModel.ACTIVITIES,
506
- max_length=20,
507
- null=True,
508
- blank=True,
509
- verbose_name=_('Proposed Activity'))
510
- amount = models.DecimalField(decimal_places=2,
511
- max_digits=15,
512
- editable=False,
513
- null=True,
514
- blank=True)
515
- 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
+ )
516
688
  name = models.CharField(max_length=200, blank=True, null=True)
517
689
  memo = models.CharField(max_length=200, blank=True, null=True)
518
690
 
519
- account_model = models.ForeignKey('django_ledger.AccountModel',
520
- on_delete=models.RESTRICT,
521
- null=True,
522
- blank=True)
523
-
524
- unit_model = models.ForeignKey('django_ledger.EntityUnitModel',
525
- on_delete=models.RESTRICT,
526
- null=True,
527
- blank=True,
528
- verbose_name=_('Entity Unit Model'))
529
-
530
- transaction_model = models.OneToOneField('django_ledger.TransactionModel',
531
- on_delete=models.SET_NULL,
532
- null=True,
533
- 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
+ )
534
733
 
535
734
  objects = StagedTransactionModelManager()
536
735
 
@@ -551,6 +750,13 @@ class StagedTransactionModelAbstract(CreateUpdateMixIn):
551
750
  def __str__(self):
552
751
  return f'{self.__class__.__name__}: {self.get_amount()}'
553
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
+
554
760
  def from_commit_dict(self, split_amount: Optional[Decimal] = None) -> List[Dict]:
555
761
  """
556
762
  Converts a commit dictionary to a list of dictionaries containing
@@ -579,13 +785,15 @@ class StagedTransactionModelAbstract(CreateUpdateMixIn):
579
785
  the staged transaction model.
580
786
  """
581
787
  amt = split_amount if split_amount else self.amount
582
- return [{
583
- 'account': self.import_job.bank_account_model.account_model,
584
- 'amount': abs(amt),
585
- 'tx_type': DEBIT if not amt < 0.00 else CREDIT,
586
- 'description': self.name,
587
- 'staged_tx_model': self
588
- }]
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
+ ]
589
797
 
590
798
  def to_commit_dict(self) -> List[Dict]:
591
799
  """
@@ -607,26 +815,33 @@ class StagedTransactionModelAbstract(CreateUpdateMixIn):
607
815
  children_qs = self.split_transaction_set.all().prefetch_related(
608
816
  'split_transaction_set',
609
817
  'split_transaction_set__account_model',
610
- 'split_transaction_set__unit_model'
818
+ 'split_transaction_set__unit_model',
611
819
  )
612
- return [{
613
- 'account': child_txs_model.account_model,
614
- 'amount': abs(child_txs_model.amount_split),
615
- 'amount_staged': child_txs_model.amount_split,
616
- 'unit_model': child_txs_model.unit_model,
617
- 'tx_type': CREDIT if not child_txs_model.amount_split < 0.00 else DEBIT,
618
- 'description': child_txs_model.name,
619
- 'staged_tx_model': child_txs_model
620
- } for child_txs_model in children_qs]
621
- return [{
622
- 'account': self.account_model,
623
- 'amount': abs(self.amount),
624
- 'amount_staged': self.amount,
625
- 'unit_model': self.unit_model,
626
- 'tx_type': CREDIT if not self.amount < 0.00 else DEBIT,
627
- 'description': self.name,
628
- 'staged_tx_model': self
629
- }]
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
+ ]
630
845
 
631
846
  def commit_dict(self, split_txs: bool = False):
632
847
  """
@@ -651,9 +866,10 @@ class StagedTransactionModelAbstract(CreateUpdateMixIn):
651
866
  to_commit = self.to_commit_dict()
652
867
  return [
653
868
  [
654
- self.from_commit_dict(split_amount=to_split['amount_staged'])[0], to_split
655
- ] for to_split in
656
- 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
657
873
  ]
658
874
  return [self.from_commit_dict() + self.to_commit_dict()]
659
875
 
@@ -675,6 +891,26 @@ class StagedTransactionModelAbstract(CreateUpdateMixIn):
675
891
  return self.amount_split
676
892
  return self.amount
677
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
+
678
914
  def is_imported(self) -> bool:
679
915
  """
680
916
  Determines if the necessary models have been imported for the system to function
@@ -687,10 +923,12 @@ class StagedTransactionModelAbstract(CreateUpdateMixIn):
687
923
  True if both `account_model_id` and `transaction_model_id` are not None,
688
924
  indicating that the models have been successfully imported. False otherwise.
689
925
  """
690
- return all([
691
- self.account_model_id is not None,
692
- self.transaction_model_id is not None,
693
- ])
926
+ return all(
927
+ [
928
+ self.account_model_id is not None,
929
+ self.transaction_model_id is not None,
930
+ ]
931
+ )
694
932
 
695
933
  def is_pending(self) -> bool:
696
934
  """
@@ -738,10 +976,7 @@ class StagedTransactionModelAbstract(CreateUpdateMixIn):
738
976
  bool
739
977
  True if the entry is a single, standalone entry; False otherwise.
740
978
  """
741
- return all([
742
- not self.is_children(),
743
- not self.has_children()
744
- ])
979
+ return all([not self.is_children(), not self.has_children()])
745
980
 
746
981
  def is_children(self) -> bool:
747
982
  """
@@ -756,9 +991,11 @@ class StagedTransactionModelAbstract(CreateUpdateMixIn):
756
991
  True if the object has a valid `parent_id`, indicating it is a child entity;
757
992
  False otherwise.
758
993
  """
759
- return all([
760
- self.parent_id is not None,
761
- ])
994
+ return all(
995
+ [
996
+ self.parent_id is not None,
997
+ ]
998
+ )
762
999
 
763
1000
  def has_activity(self) -> bool:
764
1001
  """
@@ -795,6 +1032,51 @@ class StagedTransactionModelAbstract(CreateUpdateMixIn):
795
1032
  return False
796
1033
  return getattr(self, 'children_count') > 0
797
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
+
798
1080
  def can_split(self) -> bool:
799
1081
  """
800
1082
  Determines if the current object can be split based on its child status.
@@ -808,7 +1090,7 @@ class StagedTransactionModelAbstract(CreateUpdateMixIn):
808
1090
  `True` if the object has no children and can be split, otherwise
809
1091
  `False`.
810
1092
  """
811
- return not self.is_children()
1093
+ return all([not self.has_children(), not self.has_receipt()])
812
1094
 
813
1095
  def can_have_unit(self) -> bool:
814
1096
  """
@@ -832,18 +1114,23 @@ class StagedTransactionModelAbstract(CreateUpdateMixIn):
832
1114
  if self.is_single():
833
1115
  return True
834
1116
 
835
- if all([
836
- self.has_children(),
837
- self.has_activity(),
838
- self.are_all_children_mapped(),
839
- self.bundle_split is True
840
- ]):
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
+ ):
841
1126
  return True
842
1127
 
843
- if all([
844
- self.is_children(),
845
- self.parent.bundle_split is False if self.parent_id else False
846
- ]):
1128
+ if all(
1129
+ [
1130
+ self.is_children(),
1131
+ self.parent.bundle_split is False if self.parent_id else False,
1132
+ ]
1133
+ ):
847
1134
  return True
848
1135
 
849
1136
  return False
@@ -863,7 +1150,10 @@ class StagedTransactionModelAbstract(CreateUpdateMixIn):
863
1150
  """
864
1151
  return not self.has_children()
865
1152
 
866
- 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:
867
1157
  """
868
1158
  Determines whether the object is ready for importing data and can optionally
869
1159
  be split into "je" (journal entries) for import if applicable.
@@ -895,9 +1185,10 @@ class StagedTransactionModelAbstract(CreateUpdateMixIn):
895
1185
  can_split_into_je = getattr(self, 'can_split_into_je')
896
1186
  if can_split_into_je and as_split:
897
1187
  return True
898
- return all([
899
- self.is_role_mapping_valid(raise_exception=False)
900
- ])
1188
+ return all([self.is_role_mapping_valid(raise_exception=False)])
1189
+
1190
+ def can_import(self) -> bool:
1191
+ return self.can_migrate()
901
1192
 
902
1193
  def add_split(self, raise_exception: bool = True, commit: bool = True, n: int = 1):
903
1194
  """
@@ -943,8 +1234,9 @@ class StagedTransactionModelAbstract(CreateUpdateMixIn):
943
1234
  date_posted=self.date_posted,
944
1235
  amount=None,
945
1236
  amount_split=Decimal('0.00'),
946
- name=f'SPLIT: {self.name}'
947
- ) for _ in range(n)
1237
+ name=f'SPLIT: {self.name}',
1238
+ )
1239
+ for _ in range(n)
948
1240
  ]
949
1241
 
950
1242
  for txs in new_txs:
@@ -1005,10 +1297,18 @@ class StagedTransactionModelAbstract(CreateUpdateMixIn):
1005
1297
  if self.has_children():
1006
1298
  split_txs_qs = self.split_transaction_set.all()
1007
1299
  if all([txs.is_mapped() for txs in split_txs_qs]):
1008
- 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
+ )
1009
1307
  return set()
1010
1308
 
1011
- 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]:
1012
1312
  """
1013
1313
  Retrieve or attempt to fetch the journal entry activity for the current prospect object.
1014
1314
 
@@ -1035,11 +1335,12 @@ class StagedTransactionModelAbstract(CreateUpdateMixIn):
1035
1335
  """
1036
1336
  ready_to_import = getattr(self, 'ready_to_import')
1037
1337
  if (not self.has_activity() and ready_to_import) or force_update:
1038
- JournalEntryModel = lazy_loader.get_journal_entry_model()
1039
1338
  role_set = self.get_import_role_set()
1040
1339
  if role_set is not None:
1041
1340
  try:
1042
- 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
+ )
1043
1344
  self.save(update_fields=['activity'])
1044
1345
  return self.activity
1045
1346
  except ValidationError as e:
@@ -1076,9 +1377,7 @@ class StagedTransactionModelAbstract(CreateUpdateMixIn):
1076
1377
  otherwise None.
1077
1378
  """
1078
1379
  activity = self.get_prospect_je_activity_try(raise_exception=False)
1079
- if activity is not None:
1080
- JournalEntryModel = lazy_loader.get_journal_entry_model()
1081
- return JournalEntryModel.MAP_ACTIVITIES[activity]
1380
+ return JournalEntryModel.MAP_ACTIVITIES[activity] if activity else None
1082
1381
 
1083
1382
  def is_role_mapping_valid(self, raise_exception: bool = False) -> bool:
1084
1383
  """
@@ -1106,7 +1405,9 @@ class StagedTransactionModelAbstract(CreateUpdateMixIn):
1106
1405
  """
1107
1406
  if not self.has_activity():
1108
1407
  try:
1109
- 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
+ )
1110
1411
  if activity is None:
1111
1412
  return False
1112
1413
  self.activity = activity
@@ -1117,7 +1418,10 @@ class StagedTransactionModelAbstract(CreateUpdateMixIn):
1117
1418
  return False
1118
1419
  return True
1119
1420
 
1120
- 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):
1121
1425
  """
1122
1426
  Migrate transactional data to the ledger model by processing the provided
1123
1427
  transactions and committing them. This process involves using the provided
@@ -1138,27 +1442,128 @@ class StagedTransactionModelAbstract(CreateUpdateMixIn):
1138
1442
  The saved objects are staged with appropriate models to retain the
1139
1443
  transaction state.
1140
1444
  """
1141
- if self.can_import(as_split=split_txs):
1142
- commit_dict = self.commit_dict(split_txs=split_txs)
1143
- import_job = self.import_job
1144
- 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
1145
1457
 
1146
- if len(commit_dict) > 0:
1458
+ if len(commit_dict) > 0:
1459
+ with transaction.atomic():
1460
+ staged_to_save = list()
1147
1461
  for je_data in commit_dict:
1148
- 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
+ )
1149
1467
  _, _ = ledger_model.commit_txs(
1150
1468
  je_timestamp=self.date_posted,
1151
1469
  je_unit_model=unit_model,
1152
1470
  je_txs=je_data,
1153
1471
  je_desc=self.memo,
1154
1472
  je_posted=False,
1155
- force_je_retrieval=False
1473
+ force_je_retrieval=False,
1156
1474
  )
1157
- staged_to_save = [i['staged_tx_model'] for i in je_data]
1158
- for i in staged_to_save:
1159
- i.save(
1160
- update_fields=['transaction_model', 'updated']
1161
- )
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
+ )
1162
1567
 
1163
1568
  def clean(self, verify: bool = False):
1164
1569
  if self.has_children():
@@ -1171,13 +1576,34 @@ class StagedTransactionModelAbstract(CreateUpdateMixIn):
1171
1576
  if self.parent_id:
1172
1577
  self.unit_model = self.parent.unit_model
1173
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
+
1174
1591
  if verify:
1175
1592
  self.is_role_mapping_valid(raise_exception=True)
1176
1593
 
1177
1594
 
1178
1595
  class ImportJobModel(ImportJobModelAbstract):
1179
1596
  """
1180
- 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
1181
1607
  """
1182
1608
 
1183
1609
  class Meta(ImportJobModelAbstract.Meta):
@@ -1185,10 +1611,35 @@ class ImportJobModel(ImportJobModelAbstract):
1185
1611
 
1186
1612
 
1187
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
+ """
1188
1634
  if instance.is_configured():
1189
- 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
+ ):
1190
1639
  raise ImportJobModelValidationError(
1191
- 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
+ )
1192
1643
  )
1193
1644
 
1194
1645
 
@@ -1197,8 +1648,52 @@ pre_save.connect(importjobmodel_presave, sender=ImportJobModel)
1197
1648
 
1198
1649
  class StagedTransactionModel(StagedTransactionModelAbstract):
1199
1650
  """
1200
- 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.
1201
1664
  """
1202
1665
 
1203
1666
  class Meta(StagedTransactionModelAbstract.Meta):
1204
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)