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.
- django_ledger/__init__.py +1 -1
- django_ledger/forms/account.py +45 -46
- django_ledger/forms/data_import.py +182 -63
- django_ledger/io/io_core.py +507 -374
- django_ledger/migrations/0026_stagedtransactionmodel_customer_model_and_more.py +56 -0
- django_ledger/models/__init__.py +2 -1
- django_ledger/models/bill.py +337 -300
- django_ledger/models/customer.py +47 -34
- django_ledger/models/data_import.py +770 -289
- django_ledger/models/entity.py +882 -637
- django_ledger/models/mixins.py +421 -280
- django_ledger/models/receipt.py +1083 -0
- django_ledger/models/transactions.py +105 -41
- django_ledger/models/unit.py +42 -30
- django_ledger/models/utils.py +12 -2
- django_ledger/models/vendor.py +85 -66
- django_ledger/settings.py +1 -0
- django_ledger/templates/django_ledger/components/period_navigator.html +5 -3
- django_ledger/templates/django_ledger/customer/customer_detail.html +87 -0
- django_ledger/templates/django_ledger/customer/customer_list.html +0 -1
- django_ledger/templates/django_ledger/customer/tags/customer_table.html +3 -1
- django_ledger/templates/django_ledger/data_import/tags/data_import_job_txs_imported.html +24 -3
- django_ledger/templates/django_ledger/data_import/tags/data_import_job_txs_table.html +26 -10
- django_ledger/templates/django_ledger/entity/entity_dashboard.html +2 -2
- django_ledger/templates/django_ledger/layouts/base.html +1 -1
- django_ledger/templates/django_ledger/layouts/content_layout_1.html +1 -1
- django_ledger/templates/django_ledger/receipt/customer_receipt_report.html +115 -0
- django_ledger/templates/django_ledger/receipt/receipt_delete.html +30 -0
- django_ledger/templates/django_ledger/receipt/receipt_detail.html +89 -0
- django_ledger/templates/django_ledger/receipt/receipt_list.html +134 -0
- django_ledger/templates/django_ledger/receipt/vendor_receipt_report.html +115 -0
- django_ledger/templates/django_ledger/vendor/tags/vendor_table.html +3 -2
- django_ledger/templates/django_ledger/vendor/vendor_detail.html +86 -0
- django_ledger/templatetags/django_ledger.py +338 -191
- django_ledger/urls/__init__.py +1 -0
- django_ledger/urls/customer.py +3 -0
- django_ledger/urls/data_import.py +3 -0
- django_ledger/urls/receipt.py +102 -0
- django_ledger/urls/vendor.py +1 -0
- django_ledger/views/__init__.py +1 -0
- django_ledger/views/customer.py +56 -14
- django_ledger/views/data_import.py +119 -66
- django_ledger/views/mixins.py +112 -86
- django_ledger/views/receipt.py +294 -0
- django_ledger/views/vendor.py +53 -14
- {django_ledger-0.8.0.dist-info → django_ledger-0.8.1.dist-info}/METADATA +1 -1
- {django_ledger-0.8.0.dist-info → django_ledger-0.8.1.dist-info}/RECORD +51 -40
- {django_ledger-0.8.0.dist-info → django_ledger-0.8.1.dist-info}/WHEEL +0 -0
- {django_ledger-0.8.0.dist-info → django_ledger-0.8.1.dist-info}/licenses/AUTHORS.md +0 -0
- {django_ledger-0.8.0.dist-info → django_ledger-0.8.1.dist-info}/licenses/LICENSE +0 -0
- {django_ledger-0.8.0.dist-info → django_ledger-0.8.1.dist-info}/top_level.txt +0 -0
|
@@ -0,0 +1,1083 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Django Ledger created by Miguel Sanda <msanda@arrobalytics.com>.
|
|
3
|
+
Copyright© EDMA Group Inc licensed under the GPLv3 Agreement.
|
|
4
|
+
|
|
5
|
+
Receipt models and utilities.
|
|
6
|
+
|
|
7
|
+
This module provides models and helpers for recording monetary receipts within an
|
|
8
|
+
Entity's ledger. It supports receipts related to customers (sales and refunds),
|
|
9
|
+
vendors (expenses and refunds), and transfers between accounts. In addition to
|
|
10
|
+
Django ORM models, the module exposes queryset/manager helpers for filtering
|
|
11
|
+
receipts, utilities for generating sequential receipt numbers, and migration of
|
|
12
|
+
staged bank transactions into posted journal entries.
|
|
13
|
+
|
|
14
|
+
Notes
|
|
15
|
+
-----
|
|
16
|
+
- All public functions and methods are documented in NumPy docstring style.
|
|
17
|
+
- Unless otherwise noted, monetary amounts are Decimal-compatible and are
|
|
18
|
+
validated server-side.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from datetime import datetime
|
|
22
|
+
from decimal import Decimal
|
|
23
|
+
from typing import Literal, Optional
|
|
24
|
+
from uuid import UUID, uuid4
|
|
25
|
+
|
|
26
|
+
from django.core.exceptions import ObjectDoesNotExist, ValidationError
|
|
27
|
+
from django.core.validators import MinValueValidator
|
|
28
|
+
from django.db import IntegrityError, models, transaction
|
|
29
|
+
from django.db.models import F, Manager, Q, QuerySet
|
|
30
|
+
from django.db.models.signals import pre_save
|
|
31
|
+
from django.urls import reverse
|
|
32
|
+
from django.utils.timezone import localdate
|
|
33
|
+
from django.utils.translation import gettext_lazy as _
|
|
34
|
+
|
|
35
|
+
from django_ledger.io.io_core import IOMixIn
|
|
36
|
+
from django_ledger.models import (
|
|
37
|
+
AccountModel,
|
|
38
|
+
CreateUpdateMixIn,
|
|
39
|
+
CustomerModel,
|
|
40
|
+
EntityModel,
|
|
41
|
+
EntityStateModel,
|
|
42
|
+
EntityUnitModel,
|
|
43
|
+
MarkdownNotesMixIn,
|
|
44
|
+
VendorModel,
|
|
45
|
+
)
|
|
46
|
+
from django_ledger.settings import (
|
|
47
|
+
DJANGO_LEDGER_DOCUMENT_NUMBER_PADDING,
|
|
48
|
+
DJANGO_LEDGER_RECEIPT_NUMBER_PREFIX,
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class ReceiptModelValidationError(ValidationError):
|
|
53
|
+
"""Domain-specific validation error for receipt operations.
|
|
54
|
+
|
|
55
|
+
This exception is raised when invalid states or inputs are detected while
|
|
56
|
+
creating, configuring, validating, or mutating receipt instances.
|
|
57
|
+
"""
|
|
58
|
+
|
|
59
|
+
pass
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class ReceiptModelQuerySet(QuerySet):
|
|
63
|
+
def for_user(self, user_model) -> 'ReceiptModelQuerySet':
|
|
64
|
+
"""Filter receipts visible to a given user.
|
|
65
|
+
|
|
66
|
+
Parameters
|
|
67
|
+
----------
|
|
68
|
+
user_model : django.contrib.auth.models.User
|
|
69
|
+
The user for whom to filter receipts. The user must be either the
|
|
70
|
+
entity admin or listed among the entity managers.
|
|
71
|
+
|
|
72
|
+
Returns
|
|
73
|
+
-------
|
|
74
|
+
ReceiptModelQuerySet
|
|
75
|
+
QuerySet containing receipts associated with entities the user can
|
|
76
|
+
manage.
|
|
77
|
+
"""
|
|
78
|
+
return self.filter(
|
|
79
|
+
Q(ledger_model__entity__admin=user_model)
|
|
80
|
+
| Q(ledger_model__entity__managers__in=[user_model])
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
def for_dates(self, from_date, to_date) -> 'ReceiptModelQuerySet':
|
|
84
|
+
"""Filter receipts within an inclusive date range.
|
|
85
|
+
|
|
86
|
+
Parameters
|
|
87
|
+
----------
|
|
88
|
+
from_date : date
|
|
89
|
+
Start date (inclusive).
|
|
90
|
+
to_date : date
|
|
91
|
+
End date (inclusive).
|
|
92
|
+
|
|
93
|
+
Returns
|
|
94
|
+
-------
|
|
95
|
+
ReceiptModelQuerySet
|
|
96
|
+
QuerySet containing receipts between the specified dates.
|
|
97
|
+
"""
|
|
98
|
+
return self.filter(receipt_date__gte=from_date, receipt_date__lte=to_date)
|
|
99
|
+
|
|
100
|
+
def for_vendor(
|
|
101
|
+
self, vendor_model: VendorModel | str | UUID
|
|
102
|
+
) -> 'ReceiptModelQuerySet':
|
|
103
|
+
"""Filter receipts tied to a specific vendor.
|
|
104
|
+
|
|
105
|
+
Parameters
|
|
106
|
+
----------
|
|
107
|
+
vendor_model : VendorModel or str or UUID
|
|
108
|
+
The vendor instance, vendor number (str), or vendor UUID to filter
|
|
109
|
+
by. Only expense-related receipts are returned.
|
|
110
|
+
|
|
111
|
+
Returns
|
|
112
|
+
-------
|
|
113
|
+
ReceiptModelQuerySet
|
|
114
|
+
QuerySet containing receipts associated with the given vendor.
|
|
115
|
+
|
|
116
|
+
Raises
|
|
117
|
+
------
|
|
118
|
+
ReceiptModelValidationError
|
|
119
|
+
If the provided value is not a supported type.
|
|
120
|
+
"""
|
|
121
|
+
if isinstance(vendor_model, str):
|
|
122
|
+
return self.filter(
|
|
123
|
+
vendor_model__vendor_number__iexact=vendor_model,
|
|
124
|
+
customer_model__isnull=True,
|
|
125
|
+
)
|
|
126
|
+
elif isinstance(vendor_model, VendorModel):
|
|
127
|
+
return self.filter(
|
|
128
|
+
vendor_model=vendor_model,
|
|
129
|
+
customer_model__isnull=True,
|
|
130
|
+
)
|
|
131
|
+
elif isinstance(vendor_model, UUID):
|
|
132
|
+
return self.filter(
|
|
133
|
+
vendor_model_id=vendor_model,
|
|
134
|
+
customer_model__isnull=True,
|
|
135
|
+
)
|
|
136
|
+
raise ReceiptModelValidationError(
|
|
137
|
+
'Invalid Vendor Model: {}, must be instance of VendorModel, UUID, str'.format(
|
|
138
|
+
vendor_model
|
|
139
|
+
)
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
def for_customer(
|
|
143
|
+
self, customer_model: CustomerModel | str | UUID
|
|
144
|
+
) -> 'ReceiptModelQuerySet':
|
|
145
|
+
"""Filter receipts tied to a specific customer.
|
|
146
|
+
|
|
147
|
+
Parameters
|
|
148
|
+
----------
|
|
149
|
+
customer_model : CustomerModel or str or UUID
|
|
150
|
+
The customer instance, customer number (str), or customer UUID to
|
|
151
|
+
filter by. Only sales-related receipts are returned.
|
|
152
|
+
|
|
153
|
+
Returns
|
|
154
|
+
-------
|
|
155
|
+
ReceiptModelQuerySet
|
|
156
|
+
QuerySet containing receipts associated with the given customer.
|
|
157
|
+
|
|
158
|
+
Raises
|
|
159
|
+
------
|
|
160
|
+
ReceiptModelValidationError
|
|
161
|
+
If the provided value is not a supported type.
|
|
162
|
+
"""
|
|
163
|
+
if isinstance(customer_model, str):
|
|
164
|
+
return self.filter(
|
|
165
|
+
customer_model__customer_number__iexact=customer_model,
|
|
166
|
+
vendor_model__isnull=True,
|
|
167
|
+
)
|
|
168
|
+
elif isinstance(customer_model, CustomerModel):
|
|
169
|
+
return self.filter(
|
|
170
|
+
customer_model=customer_model,
|
|
171
|
+
vendor_model__isnull=True,
|
|
172
|
+
)
|
|
173
|
+
elif isinstance(customer_model, UUID):
|
|
174
|
+
return self.filter(
|
|
175
|
+
customer_model_id=customer_model,
|
|
176
|
+
vendor_model__isnull=True,
|
|
177
|
+
)
|
|
178
|
+
raise ReceiptModelValidationError(
|
|
179
|
+
'Invalid Customer Model: {}, must be instance of CustomerModel, UUID, str'.format(
|
|
180
|
+
customer_model
|
|
181
|
+
)
|
|
182
|
+
)
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
class ReceiptModelManager(Manager):
|
|
186
|
+
def get_queryset(self):
|
|
187
|
+
"""Return the base queryset with common annotations.
|
|
188
|
+
|
|
189
|
+
Returns
|
|
190
|
+
-------
|
|
191
|
+
ReceiptModelQuerySet
|
|
192
|
+
A queryset pre-annotated with entity UUID, slug, and last closing
|
|
193
|
+
date, and with ledger_model selected.
|
|
194
|
+
"""
|
|
195
|
+
return (
|
|
196
|
+
ReceiptModelQuerySet(self.model, using=self._db)
|
|
197
|
+
.select_related('ledger_model')
|
|
198
|
+
.annotate(
|
|
199
|
+
_entity_uuid=F('ledger_model__entity__uuid'),
|
|
200
|
+
_entity_slug=F('ledger_model__entity__slug'),
|
|
201
|
+
_last_closing_date=F('ledger_model__entity__last_closing_date'),
|
|
202
|
+
)
|
|
203
|
+
)
|
|
204
|
+
|
|
205
|
+
def for_entity(
|
|
206
|
+
self, entity_model: EntityModel | str | UUID
|
|
207
|
+
) -> ReceiptModelQuerySet:
|
|
208
|
+
"""Filter receipts for a specific entity.
|
|
209
|
+
|
|
210
|
+
Parameters
|
|
211
|
+
----------
|
|
212
|
+
entity_model : EntityModel or str or UUID
|
|
213
|
+
The entity instance, slug, or UUID to filter receipts for.
|
|
214
|
+
|
|
215
|
+
Returns
|
|
216
|
+
-------
|
|
217
|
+
ReceiptModelQuerySet
|
|
218
|
+
Receipts belonging to the specified entity.
|
|
219
|
+
|
|
220
|
+
Raises
|
|
221
|
+
------
|
|
222
|
+
ReceiptModelValidationError
|
|
223
|
+
If an unsupported type is provided.
|
|
224
|
+
"""
|
|
225
|
+
qs = self.get_queryset()
|
|
226
|
+
if isinstance(entity_model, str):
|
|
227
|
+
qs = qs.filter(ledger_model__entity__slug__exact=entity_model)
|
|
228
|
+
elif isinstance(entity_model, UUID):
|
|
229
|
+
qs = qs.filter(ledger_model__entity_id=entity_model)
|
|
230
|
+
elif isinstance(entity_model, EntityModel):
|
|
231
|
+
qs = qs.filter(ledger_model__entity=entity_model)
|
|
232
|
+
else:
|
|
233
|
+
raise ReceiptModelValidationError(
|
|
234
|
+
f'Must pass either EntityModel, string or UUID, not {type(entity_model)}.'
|
|
235
|
+
)
|
|
236
|
+
return qs
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
class ReceiptModelAbstract(CreateUpdateMixIn, MarkdownNotesMixIn, IOMixIn):
|
|
240
|
+
"""Abstract model representing a monetary receipt.
|
|
241
|
+
|
|
242
|
+
This class encapsulates the common fields and behaviors for receipts across
|
|
243
|
+
different types: sales, sales refund, expense, expense refund, and transfer.
|
|
244
|
+
It includes helpers to generate sequential receipt numbers, validate receipt
|
|
245
|
+
configuration, and migrate staged transactions to journal entries.
|
|
246
|
+
"""
|
|
247
|
+
|
|
248
|
+
SALES_RECEIPT = 'sales'
|
|
249
|
+
SALES_REFUND = 'customer_refund'
|
|
250
|
+
EXPENSE_RECEIPT = 'expense'
|
|
251
|
+
EXPENSE_REFUND = 'expense_refund'
|
|
252
|
+
TRANSFER_RECEIPT = 'transfer'
|
|
253
|
+
|
|
254
|
+
RECEIPT_TYPES = [
|
|
255
|
+
(SALES_RECEIPT, 'Sales Receipt'),
|
|
256
|
+
(SALES_REFUND, 'Sales Refund'),
|
|
257
|
+
(EXPENSE_RECEIPT, 'Expense Receipt'),
|
|
258
|
+
(EXPENSE_REFUND, 'Expense Refund'),
|
|
259
|
+
(TRANSFER_RECEIPT, 'Transfer Receipt'),
|
|
260
|
+
]
|
|
261
|
+
RECEIPT_TYPES_MAP = dict(RECEIPT_TYPES)
|
|
262
|
+
|
|
263
|
+
uuid = models.UUIDField(default=uuid4, editable=False, primary_key=True)
|
|
264
|
+
receipt_number = models.CharField(_('Receipt Number'), max_length=255)
|
|
265
|
+
receipt_date = models.DateField(_('Receipt Date'))
|
|
266
|
+
receipt_type = models.CharField(
|
|
267
|
+
choices=RECEIPT_TYPES, verbose_name=_('Receipt Type')
|
|
268
|
+
)
|
|
269
|
+
|
|
270
|
+
ledger_model = models.ForeignKey(
|
|
271
|
+
'django_ledger.LedgerModel',
|
|
272
|
+
on_delete=models.PROTECT,
|
|
273
|
+
verbose_name=_('Ledger Model'),
|
|
274
|
+
editable=False,
|
|
275
|
+
)
|
|
276
|
+
|
|
277
|
+
unit_model = models.ForeignKey(
|
|
278
|
+
'django_ledger.EntityUnitModel',
|
|
279
|
+
on_delete=models.PROTECT,
|
|
280
|
+
verbose_name=_('Unit Model'),
|
|
281
|
+
help_text=_(
|
|
282
|
+
'Helps segregate receipts and transactions into different classes or departments.'
|
|
283
|
+
),
|
|
284
|
+
null=True,
|
|
285
|
+
blank=True,
|
|
286
|
+
)
|
|
287
|
+
|
|
288
|
+
customer_model = models.ForeignKey(
|
|
289
|
+
'django_ledger.CustomerModel',
|
|
290
|
+
on_delete=models.PROTECT,
|
|
291
|
+
verbose_name=_('Customer Model'),
|
|
292
|
+
null=True,
|
|
293
|
+
blank=True,
|
|
294
|
+
)
|
|
295
|
+
vendor_model = models.ForeignKey(
|
|
296
|
+
'django_ledger.VendorModel',
|
|
297
|
+
on_delete=models.PROTECT,
|
|
298
|
+
verbose_name=_('Vendor Model'),
|
|
299
|
+
null=True,
|
|
300
|
+
blank=True,
|
|
301
|
+
)
|
|
302
|
+
|
|
303
|
+
charge_account = models.ForeignKey(
|
|
304
|
+
'django_ledger.AccountModel',
|
|
305
|
+
on_delete=models.PROTECT,
|
|
306
|
+
verbose_name=_('Charge Account'),
|
|
307
|
+
help_text=_(
|
|
308
|
+
'The financial account (cash or credit) where this transaction was made.'
|
|
309
|
+
),
|
|
310
|
+
related_name='charge_receiptmodel_set',
|
|
311
|
+
)
|
|
312
|
+
|
|
313
|
+
receipt_account = models.ForeignKey(
|
|
314
|
+
'django_ledger.AccountModel',
|
|
315
|
+
on_delete=models.PROTECT,
|
|
316
|
+
verbose_name=_('PnL Account'),
|
|
317
|
+
help_text=_(
|
|
318
|
+
'The income or expense account where this transaction will be reflected'
|
|
319
|
+
),
|
|
320
|
+
)
|
|
321
|
+
|
|
322
|
+
amount = models.DecimalField(
|
|
323
|
+
decimal_places=2,
|
|
324
|
+
max_digits=20,
|
|
325
|
+
verbose_name=_('Receipt Amount'),
|
|
326
|
+
help_text=_('Amount of the receipt.'),
|
|
327
|
+
validators=[MinValueValidator(0)],
|
|
328
|
+
)
|
|
329
|
+
|
|
330
|
+
staged_transaction_model = models.OneToOneField(
|
|
331
|
+
'django_ledger.StagedTransactionModel',
|
|
332
|
+
on_delete=models.RESTRICT,
|
|
333
|
+
null=True,
|
|
334
|
+
blank=True,
|
|
335
|
+
verbose_name=_('Staged Transaction Model'),
|
|
336
|
+
help_text=_(
|
|
337
|
+
'The staged transaction associated with the receipt from bank feeds.'
|
|
338
|
+
),
|
|
339
|
+
)
|
|
340
|
+
|
|
341
|
+
objects = ReceiptModelManager.from_queryset(queryset_class=ReceiptModelQuerySet)()
|
|
342
|
+
|
|
343
|
+
class Meta:
|
|
344
|
+
abstract = True
|
|
345
|
+
verbose_name = _('Sales/Expense Receipt')
|
|
346
|
+
verbose_name_plural = _('Sales/Expense Receipts')
|
|
347
|
+
indexes = [
|
|
348
|
+
models.Index(fields=['receipt_number']),
|
|
349
|
+
models.Index(fields=['ledger_model']),
|
|
350
|
+
models.Index(fields=['receipt_date']),
|
|
351
|
+
models.Index(fields=['receipt_type']),
|
|
352
|
+
models.Index(fields=['customer_model']),
|
|
353
|
+
models.Index(fields=['vendor_model']),
|
|
354
|
+
]
|
|
355
|
+
|
|
356
|
+
@property
|
|
357
|
+
def entity_slug(self) -> str:
|
|
358
|
+
"""Entity slug convenience property.
|
|
359
|
+
|
|
360
|
+
Returns
|
|
361
|
+
-------
|
|
362
|
+
str
|
|
363
|
+
The slug of the related entity, using the annotated value when
|
|
364
|
+
present to avoid extra queries.
|
|
365
|
+
"""
|
|
366
|
+
try:
|
|
367
|
+
return getattr(self, '_entity_slug')
|
|
368
|
+
except AttributeError:
|
|
369
|
+
pass
|
|
370
|
+
return self.ledger_model.entity_slug
|
|
371
|
+
|
|
372
|
+
@property
|
|
373
|
+
def entity_uuid(self) -> UUID:
|
|
374
|
+
"""Entity UUID convenience property.
|
|
375
|
+
|
|
376
|
+
Returns
|
|
377
|
+
-------
|
|
378
|
+
UUID
|
|
379
|
+
The UUID of the related entity, using the annotated value when
|
|
380
|
+
available to minimize queries.
|
|
381
|
+
"""
|
|
382
|
+
try:
|
|
383
|
+
return getattr(self, '_entity_uuid')
|
|
384
|
+
except AttributeError:
|
|
385
|
+
pass
|
|
386
|
+
return self.ledger_model.entity_uuid
|
|
387
|
+
|
|
388
|
+
@property
|
|
389
|
+
def ledger_posted(self):
|
|
390
|
+
"""Whether the underlying ledger is posted.
|
|
391
|
+
|
|
392
|
+
Returns
|
|
393
|
+
-------
|
|
394
|
+
bool
|
|
395
|
+
True if the associated ledger is posted, False otherwise.
|
|
396
|
+
"""
|
|
397
|
+
return self.ledger_model.is_posted()
|
|
398
|
+
|
|
399
|
+
@property
|
|
400
|
+
def posted(self):
|
|
401
|
+
"""Alias for ledger_posted for template convenience.
|
|
402
|
+
|
|
403
|
+
Returns
|
|
404
|
+
-------
|
|
405
|
+
bool
|
|
406
|
+
True if the associated ledger is posted, False otherwise.
|
|
407
|
+
"""
|
|
408
|
+
return self.ledger_model.is_posted()
|
|
409
|
+
|
|
410
|
+
@property
|
|
411
|
+
def last_closing_date(self):
|
|
412
|
+
"""Entity last closing date convenience property.
|
|
413
|
+
|
|
414
|
+
Returns
|
|
415
|
+
-------
|
|
416
|
+
date
|
|
417
|
+
The entity's last closing date, using the annotated value when
|
|
418
|
+
available to minimize queries.
|
|
419
|
+
"""
|
|
420
|
+
try:
|
|
421
|
+
return getattr(self, '_last_closing_date')
|
|
422
|
+
except AttributeError:
|
|
423
|
+
pass
|
|
424
|
+
return self.ledger_model.entity.last_closing_date
|
|
425
|
+
|
|
426
|
+
# Actions
|
|
427
|
+
def can_delete(self) -> bool:
|
|
428
|
+
"""Check if the receipt can be safely deleted.
|
|
429
|
+
|
|
430
|
+
Returns
|
|
431
|
+
-------
|
|
432
|
+
bool
|
|
433
|
+
True if the receipt date is after the entity's last closing date.
|
|
434
|
+
"""
|
|
435
|
+
return all(
|
|
436
|
+
[
|
|
437
|
+
self.last_closing_date < self.receipt_date,
|
|
438
|
+
]
|
|
439
|
+
)
|
|
440
|
+
|
|
441
|
+
def delete(
|
|
442
|
+
self, using=None, keep_parents=False, delete_ledger: bool = True, **kwargs
|
|
443
|
+
):
|
|
444
|
+
"""Delete the receipt and related journal entries if allowed.
|
|
445
|
+
|
|
446
|
+
Parameters
|
|
447
|
+
----------
|
|
448
|
+
using : str, optional
|
|
449
|
+
The database alias to use.
|
|
450
|
+
keep_parents : bool, default False
|
|
451
|
+
Whether to keep parent model records.
|
|
452
|
+
delete_ledger : bool, default True
|
|
453
|
+
Unused flag preserved for backward compatibility.
|
|
454
|
+
**kwargs
|
|
455
|
+
Additional options passed to Django's delete.
|
|
456
|
+
|
|
457
|
+
Returns
|
|
458
|
+
-------
|
|
459
|
+
tuple
|
|
460
|
+
A 2-tuple of the number of objects deleted and a dictionary with the
|
|
461
|
+
number of deletions per object type, as returned by Django.
|
|
462
|
+
|
|
463
|
+
Raises
|
|
464
|
+
------
|
|
465
|
+
ReceiptModelValidationError
|
|
466
|
+
If the receipt is within a closed period.
|
|
467
|
+
"""
|
|
468
|
+
if not self.can_delete():
|
|
469
|
+
raise ReceiptModelValidationError(
|
|
470
|
+
message=_(
|
|
471
|
+
'Receipt cannot be deleted because it falls within a closed period.'
|
|
472
|
+
),
|
|
473
|
+
)
|
|
474
|
+
ledger = self.ledger_model
|
|
475
|
+
with transaction.atomic():
|
|
476
|
+
if ledger.is_locked():
|
|
477
|
+
ledger.unlock(commit=True, raise_exception=True)
|
|
478
|
+
|
|
479
|
+
ledger.journal_entries.all().delete()
|
|
480
|
+
|
|
481
|
+
return super().delete(using=using, keep_parents=keep_parents)
|
|
482
|
+
|
|
483
|
+
def is_sales_receipt(self) -> bool:
|
|
484
|
+
"""Whether the receipt is sales-related (sales or refund).
|
|
485
|
+
|
|
486
|
+
Returns
|
|
487
|
+
-------
|
|
488
|
+
bool
|
|
489
|
+
True if receipt_type is SALES_RECEIPT or SALES_REFUND.
|
|
490
|
+
"""
|
|
491
|
+
return any(
|
|
492
|
+
[
|
|
493
|
+
self.receipt_type == self.SALES_RECEIPT,
|
|
494
|
+
self.receipt_type == self.SALES_REFUND,
|
|
495
|
+
]
|
|
496
|
+
)
|
|
497
|
+
|
|
498
|
+
def is_expense_receipt(self) -> bool:
|
|
499
|
+
"""Whether the receipt is expense-related (expense or refund).
|
|
500
|
+
|
|
501
|
+
Returns
|
|
502
|
+
-------
|
|
503
|
+
bool
|
|
504
|
+
True if receipt_type is EXPENSE_RECEIPT or EXPENSE_REFUND.
|
|
505
|
+
"""
|
|
506
|
+
return any(
|
|
507
|
+
[
|
|
508
|
+
self.receipt_type == self.EXPENSE_RECEIPT,
|
|
509
|
+
self.receipt_type == self.EXPENSE_REFUND,
|
|
510
|
+
]
|
|
511
|
+
)
|
|
512
|
+
|
|
513
|
+
def is_transfer_receipt(self) -> bool:
|
|
514
|
+
"""Whether the receipt is a transfer between accounts.
|
|
515
|
+
|
|
516
|
+
Returns
|
|
517
|
+
-------
|
|
518
|
+
bool
|
|
519
|
+
True if receipt_type is TRANSFER_RECEIPT.
|
|
520
|
+
"""
|
|
521
|
+
return self.receipt_type == self.TRANSFER_RECEIPT
|
|
522
|
+
|
|
523
|
+
def get_receipt_type_for_amount(self, amount: float | int | Decimal) -> str:
|
|
524
|
+
"""Derive a receipt type from context and amount sign.
|
|
525
|
+
|
|
526
|
+
Parameters
|
|
527
|
+
----------
|
|
528
|
+
amount : float or int or Decimal
|
|
529
|
+
The receipt amount. The sign determines whether a refund or normal
|
|
530
|
+
receipt is implied given the counterparty type.
|
|
531
|
+
|
|
532
|
+
Returns
|
|
533
|
+
-------
|
|
534
|
+
str
|
|
535
|
+
One of SALES_RECEIPT, SALES_REFUND, EXPENSE_RECEIPT, EXPENSE_REFUND.
|
|
536
|
+
|
|
537
|
+
Raises
|
|
538
|
+
------
|
|
539
|
+
ReceiptModelValidationError
|
|
540
|
+
If both a customer and vendor are set, or neither is set.
|
|
541
|
+
|
|
542
|
+
Notes
|
|
543
|
+
-----
|
|
544
|
+
Rules:
|
|
545
|
+
- Customer: amount >= 0 -> SALES_RECEIPT; amount < 0 -> SALES_REFUND
|
|
546
|
+
- Vendor: amount <= 0 -> EXPENSE_RECEIPT; amount > 0 -> EXPENSE_REFUND
|
|
547
|
+
"""
|
|
548
|
+
if self.customer_model_id and self.vendor_model_id:
|
|
549
|
+
raise ReceiptModelValidationError(
|
|
550
|
+
message='Cannot determine receipt type when both customer and vendor are set.'
|
|
551
|
+
)
|
|
552
|
+
|
|
553
|
+
if self.customer_model_id:
|
|
554
|
+
return self.SALES_RECEIPT if float(amount) >= 0 else self.SALES_REFUND
|
|
555
|
+
|
|
556
|
+
if self.vendor_model_id:
|
|
557
|
+
return self.EXPENSE_REFUND if float(amount) > 0 else self.EXPENSE_RECEIPT
|
|
558
|
+
|
|
559
|
+
raise ReceiptModelValidationError(
|
|
560
|
+
message='Cannot determine receipt type without a customer or vendor.'
|
|
561
|
+
)
|
|
562
|
+
|
|
563
|
+
def is_configured(self) -> bool:
|
|
564
|
+
"""Whether the receipt has enough data to operate.
|
|
565
|
+
|
|
566
|
+
Returns
|
|
567
|
+
-------
|
|
568
|
+
bool
|
|
569
|
+
True if receipt_date, receipt_type, and ledger_model are set.
|
|
570
|
+
"""
|
|
571
|
+
return all(
|
|
572
|
+
[
|
|
573
|
+
self.receipt_date is not None,
|
|
574
|
+
self.receipt_type is not None,
|
|
575
|
+
self.ledger_model_id is not None,
|
|
576
|
+
]
|
|
577
|
+
)
|
|
578
|
+
|
|
579
|
+
def can_generate_receipt_number(self) -> bool:
|
|
580
|
+
"""Whether a receipt number can be generated for this instance.
|
|
581
|
+
|
|
582
|
+
Returns
|
|
583
|
+
-------
|
|
584
|
+
bool
|
|
585
|
+
True if the receipt currently has no receipt_number assigned.
|
|
586
|
+
"""
|
|
587
|
+
return all([not self.receipt_number])
|
|
588
|
+
|
|
589
|
+
def _get_next_state_model(self, raise_exception: bool = True):
|
|
590
|
+
"""Fetch or create the next sequential state for receipt numbering.
|
|
591
|
+
|
|
592
|
+
Parameters
|
|
593
|
+
----------
|
|
594
|
+
raise_exception : bool, default True
|
|
595
|
+
If True, re-raise any IntegrityError encountered during creation.
|
|
596
|
+
|
|
597
|
+
Returns
|
|
598
|
+
-------
|
|
599
|
+
EntityStateModel or None
|
|
600
|
+
The state model containing the updated sequence number or None when
|
|
601
|
+
an IntegrityError occurs and raise_exception=False.
|
|
602
|
+
"""
|
|
603
|
+
entity_model = self.ledger_model.entity
|
|
604
|
+
fy_key = entity_model.get_fy_for_date(dt=self.receipt_date)
|
|
605
|
+
|
|
606
|
+
LOOKUP = {
|
|
607
|
+
'entity_model_id__exact': entity_model.uuid,
|
|
608
|
+
'entity_unit_id__exact': None,
|
|
609
|
+
'fiscal_year': fy_key,
|
|
610
|
+
'key__exact': EntityStateModel.KEY_RECEIPT,
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
try:
|
|
614
|
+
state_model_qs = (
|
|
615
|
+
EntityStateModel.objects.filter(**LOOKUP)
|
|
616
|
+
.select_related('entity_model')
|
|
617
|
+
.select_for_update()
|
|
618
|
+
)
|
|
619
|
+
|
|
620
|
+
state_model = state_model_qs.get()
|
|
621
|
+
state_model.sequence = F('sequence') + 1
|
|
622
|
+
state_model.save(update_fields=['sequence'])
|
|
623
|
+
state_model.refresh_from_db()
|
|
624
|
+
return state_model
|
|
625
|
+
except ObjectDoesNotExist:
|
|
626
|
+
LOOKUP = {
|
|
627
|
+
'entity_model_id': entity_model.uuid,
|
|
628
|
+
'entity_unit_id': None,
|
|
629
|
+
'fiscal_year': fy_key,
|
|
630
|
+
'key': EntityStateModel.KEY_RECEIPT,
|
|
631
|
+
'sequence': 1,
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
state_model = EntityStateModel.objects.create(**LOOKUP)
|
|
635
|
+
return state_model
|
|
636
|
+
except IntegrityError as e:
|
|
637
|
+
if raise_exception:
|
|
638
|
+
raise e
|
|
639
|
+
|
|
640
|
+
def generate_receipt_number(self, commit: bool = False) -> str:
|
|
641
|
+
"""Generate and optionally persist a sequential receipt number.
|
|
642
|
+
|
|
643
|
+
Parameters
|
|
644
|
+
----------
|
|
645
|
+
commit : bool, default False
|
|
646
|
+
If True, persist the newly generated number to the database.
|
|
647
|
+
|
|
648
|
+
Returns
|
|
649
|
+
-------
|
|
650
|
+
str
|
|
651
|
+
The generated or existing receipt number.
|
|
652
|
+
"""
|
|
653
|
+
if self.can_generate_receipt_number():
|
|
654
|
+
state_model = None
|
|
655
|
+
while not state_model:
|
|
656
|
+
state_model = self._get_next_state_model(raise_exception=False)
|
|
657
|
+
|
|
658
|
+
seq = str(state_model.sequence).zfill(DJANGO_LEDGER_DOCUMENT_NUMBER_PADDING)
|
|
659
|
+
self.receipt_number = (
|
|
660
|
+
f'{DJANGO_LEDGER_RECEIPT_NUMBER_PREFIX}-{state_model.fiscal_year}-{seq}'
|
|
661
|
+
)
|
|
662
|
+
|
|
663
|
+
if commit:
|
|
664
|
+
self.save(update_fields=['receipt_number', 'updated'])
|
|
665
|
+
|
|
666
|
+
return self.receipt_number
|
|
667
|
+
|
|
668
|
+
def configure(
|
|
669
|
+
self,
|
|
670
|
+
entity_model: EntityModel | str | UUID,
|
|
671
|
+
receipt_type: Literal[
|
|
672
|
+
SALES_RECEIPT, SALES_REFUND, EXPENSE_RECEIPT, EXPENSE_REFUND
|
|
673
|
+
],
|
|
674
|
+
amount: int | float | Decimal,
|
|
675
|
+
unit_model: Optional[EntityUnitModel | str | UUID] = None,
|
|
676
|
+
receipt_date: Optional[datetime | str] = None,
|
|
677
|
+
vendor_model: Optional[VendorModel | str | UUID] = None,
|
|
678
|
+
customer_model: Optional[CustomerModel | str | UUID] = None,
|
|
679
|
+
charge_account: Optional[AccountModel] = None,
|
|
680
|
+
receipt_account: Optional[AccountModel] = None,
|
|
681
|
+
staged_transaction_model=None,
|
|
682
|
+
commit: bool = True,
|
|
683
|
+
):
|
|
684
|
+
"""Configure the receipt with entity, counterpart, and accounting data.
|
|
685
|
+
|
|
686
|
+
This method sets related models, validates inputs, creates a posted ledger
|
|
687
|
+
for the receipt, and generates a receipt number.
|
|
688
|
+
|
|
689
|
+
Parameters
|
|
690
|
+
----------
|
|
691
|
+
entity_model : EntityModel or str or UUID
|
|
692
|
+
The entity instance, slug, or UUID that owns the receipt.
|
|
693
|
+
receipt_type : {SALES_RECEIPT, SALES_REFUND, EXPENSE_RECEIPT, EXPENSE_REFUND}
|
|
694
|
+
The type of receipt.
|
|
695
|
+
amount : int or float or Decimal
|
|
696
|
+
Positive monetary amount of the receipt.
|
|
697
|
+
unit_model : EntityUnitModel or str or UUID, optional
|
|
698
|
+
Unit/class/department context.
|
|
699
|
+
receipt_date : datetime or str, optional
|
|
700
|
+
Date to use for the receipt; defaults to local date.
|
|
701
|
+
vendor_model : VendorModel or str or UUID, optional
|
|
702
|
+
The vendor related to the receipt (mutually exclusive with customer).
|
|
703
|
+
customer_model : CustomerModel or str or UUID, optional
|
|
704
|
+
The customer related to the receipt (mutually exclusive with vendor).
|
|
705
|
+
charge_account : AccountModel, optional
|
|
706
|
+
The cash/credit account where the transaction occurred.
|
|
707
|
+
receipt_account : AccountModel, optional
|
|
708
|
+
The income/expense account impacted by the receipt.
|
|
709
|
+
staged_transaction_model : StagedTransactionModel, optional
|
|
710
|
+
Optional staged transaction to link for migration.
|
|
711
|
+
commit : bool, default True
|
|
712
|
+
If True, persist the configured receipt to the database.
|
|
713
|
+
|
|
714
|
+
Raises
|
|
715
|
+
------
|
|
716
|
+
ReceiptModelValidationError
|
|
717
|
+
On invalid combinations or values.
|
|
718
|
+
"""
|
|
719
|
+
if not self.is_configured():
|
|
720
|
+
with transaction.atomic():
|
|
721
|
+
if amount < 0:
|
|
722
|
+
raise ReceiptModelValidationError(
|
|
723
|
+
message='Receipt amount must be greater than zero'
|
|
724
|
+
)
|
|
725
|
+
if isinstance(entity_model, EntityModel):
|
|
726
|
+
pass
|
|
727
|
+
elif isinstance(entity_model, UUID):
|
|
728
|
+
entity_model = EntityModel.objects.get(uuid__exact=entity_model)
|
|
729
|
+
elif isinstance(entity_model, str):
|
|
730
|
+
entity_model = EntityModel.objects.get(slug__exact=entity_model)
|
|
731
|
+
|
|
732
|
+
if all([vendor_model, customer_model]):
|
|
733
|
+
raise ReceiptModelValidationError(
|
|
734
|
+
message='Must pass VendorModel or CustomerModel, not both.',
|
|
735
|
+
)
|
|
736
|
+
|
|
737
|
+
if not any([vendor_model, customer_model]):
|
|
738
|
+
raise ReceiptModelValidationError(
|
|
739
|
+
message='Must pass VendorModel or CustomerModel.',
|
|
740
|
+
)
|
|
741
|
+
|
|
742
|
+
# checks if a vendor model has been previously assigned....
|
|
743
|
+
if all(
|
|
744
|
+
[
|
|
745
|
+
vendor_model is not None,
|
|
746
|
+
self.vendor_model_id is not None,
|
|
747
|
+
]
|
|
748
|
+
):
|
|
749
|
+
raise ReceiptModelValidationError(
|
|
750
|
+
message='Vendor Model already set.'
|
|
751
|
+
)
|
|
752
|
+
|
|
753
|
+
# checks if a customer model has been previously assigned....
|
|
754
|
+
if all(
|
|
755
|
+
[
|
|
756
|
+
customer_model is not None,
|
|
757
|
+
self.customer_model_id is not None,
|
|
758
|
+
]
|
|
759
|
+
):
|
|
760
|
+
raise ReceiptModelValidationError(
|
|
761
|
+
message='Customer Model already set.'
|
|
762
|
+
)
|
|
763
|
+
|
|
764
|
+
# get vendor model...
|
|
765
|
+
if vendor_model:
|
|
766
|
+
if isinstance(vendor_model, str):
|
|
767
|
+
vendor_model = VendorModel.objects.for_entity(
|
|
768
|
+
entity_model=entity_model
|
|
769
|
+
).get(vendor_number__iexact=vendor_model)
|
|
770
|
+
elif isinstance(customer_model, UUID):
|
|
771
|
+
vendor_model = VendorModel.objects.for_entity(
|
|
772
|
+
entity_model=entity_model
|
|
773
|
+
).get(uuid__exact=vendor_model)
|
|
774
|
+
elif isinstance(vendor_model, VendorModel):
|
|
775
|
+
vendor_model.validate_for_entity(entity_model=entity_model)
|
|
776
|
+
else:
|
|
777
|
+
raise ReceiptModelValidationError(
|
|
778
|
+
message='VendorModel must be either a VendorModel, UUID or Vendor Number.'
|
|
779
|
+
)
|
|
780
|
+
self.vendor_model = vendor_model
|
|
781
|
+
|
|
782
|
+
# get customer model
|
|
783
|
+
if customer_model:
|
|
784
|
+
if isinstance(customer_model, str):
|
|
785
|
+
customer_model = CustomerModel.objects.for_entity(
|
|
786
|
+
entity_model=customer_model
|
|
787
|
+
).get(customer_number__iexact=customer_model)
|
|
788
|
+
elif isinstance(customer_model, UUID):
|
|
789
|
+
customer_model = CustomerModel.objects.for_entity(
|
|
790
|
+
entity_model=customer_model
|
|
791
|
+
).get(uuid__exact=customer_model)
|
|
792
|
+
elif isinstance(customer_model, CustomerModel):
|
|
793
|
+
customer_model.validate_for_entity(entity_model=entity_model)
|
|
794
|
+
else:
|
|
795
|
+
raise ReceiptModelValidationError(
|
|
796
|
+
message='Customer Model must be either a CustomerModel, UUID or Customer Number.'
|
|
797
|
+
)
|
|
798
|
+
self.customer_model = customer_model
|
|
799
|
+
|
|
800
|
+
if unit_model:
|
|
801
|
+
if isinstance(unit_model, str):
|
|
802
|
+
unit_model = EntityUnitModel.objects.for_entity(
|
|
803
|
+
entity_model=entity_model
|
|
804
|
+
).get(slug__exact=unit_model)
|
|
805
|
+
elif isinstance(unit_model, UUID):
|
|
806
|
+
unit_model = EntityUnitModel.objects.for_entity(
|
|
807
|
+
entity_model=entity_model
|
|
808
|
+
).get(uuid__exact=unit_model)
|
|
809
|
+
elif isinstance(unit_model, EntityUnitModel):
|
|
810
|
+
unit_model.validate_for_entity(entity_model=entity_model)
|
|
811
|
+
|
|
812
|
+
self.receipt_type = receipt_type
|
|
813
|
+
self.amount = amount
|
|
814
|
+
self.receipt_date = localdate() if not receipt_date else receipt_date
|
|
815
|
+
self.charge_account = charge_account
|
|
816
|
+
self.receipt_account = receipt_account
|
|
817
|
+
self.unit_model = unit_model
|
|
818
|
+
self.staged_transaction_model = staged_transaction_model
|
|
819
|
+
|
|
820
|
+
self.ledger_model = entity_model.create_ledger(
|
|
821
|
+
name=entity_model.name,
|
|
822
|
+
posted=True,
|
|
823
|
+
commit=False,
|
|
824
|
+
)
|
|
825
|
+
receipt_number = self.generate_receipt_number(commit=True)
|
|
826
|
+
self.ledger_model.name = receipt_number
|
|
827
|
+
self.ledger_model.save()
|
|
828
|
+
self.full_clean()
|
|
829
|
+
|
|
830
|
+
if commit:
|
|
831
|
+
self.save()
|
|
832
|
+
|
|
833
|
+
def can_migrate(self) -> bool:
|
|
834
|
+
"""Whether the receipt has enough data to migrate staged transactions.
|
|
835
|
+
|
|
836
|
+
Returns
|
|
837
|
+
-------
|
|
838
|
+
bool
|
|
839
|
+
True if a receipt_date is set and either vendor or customer is set.
|
|
840
|
+
"""
|
|
841
|
+
return all(
|
|
842
|
+
[
|
|
843
|
+
self.receipt_date is not None,
|
|
844
|
+
any(
|
|
845
|
+
[
|
|
846
|
+
self.vendor_model_id is not None,
|
|
847
|
+
self.customer_model_id is not None,
|
|
848
|
+
]
|
|
849
|
+
),
|
|
850
|
+
]
|
|
851
|
+
)
|
|
852
|
+
|
|
853
|
+
def migrate_receipt(self):
|
|
854
|
+
"""Post staged transactions into the ledger as journal entries.
|
|
855
|
+
|
|
856
|
+
This method commits staged transactions linked to the receipt into the
|
|
857
|
+
associated ledger, marking them as posted journal entries.
|
|
858
|
+
|
|
859
|
+
Raises
|
|
860
|
+
------
|
|
861
|
+
ReceiptModelValidationError
|
|
862
|
+
If the receipt is not configured or lacks the required counterparty
|
|
863
|
+
to migrate (vendor or customer).
|
|
864
|
+
"""
|
|
865
|
+
if not self.is_configured():
|
|
866
|
+
raise ReceiptModelValidationError(
|
|
867
|
+
message='Receipt Model must be configured. Call configure() before migrating receipt.',
|
|
868
|
+
)
|
|
869
|
+
if not self.can_migrate():
|
|
870
|
+
raise ReceiptModelValidationError(
|
|
871
|
+
message='Must have VendorModel or CustomerModel, not both.',
|
|
872
|
+
)
|
|
873
|
+
|
|
874
|
+
commit_dict = self.staged_transaction_model.commit_dict(split_txs=False)
|
|
875
|
+
ledger_model = self.ledger_model
|
|
876
|
+
staged_to_save = list()
|
|
877
|
+
for je_data in commit_dict:
|
|
878
|
+
_, _ = ledger_model.commit_txs(
|
|
879
|
+
je_timestamp=self.receipt_date,
|
|
880
|
+
je_unit_model=self.unit_model,
|
|
881
|
+
je_posted=True,
|
|
882
|
+
je_desc=f'Receipt Number: {self.receipt_number}',
|
|
883
|
+
je_origin='migrate_receipt',
|
|
884
|
+
je_txs=je_data,
|
|
885
|
+
)
|
|
886
|
+
staged_to_save += [i['staged_tx_model'] for i in je_data]
|
|
887
|
+
staged_to_save = set(staged_to_save)
|
|
888
|
+
for i in staged_to_save:
|
|
889
|
+
i.save(update_fields=['transaction_model', 'updated'])
|
|
890
|
+
|
|
891
|
+
# URL helpers
|
|
892
|
+
def get_absolute_url(self) -> str:
|
|
893
|
+
"""Absolute URL for the receipt detail view.
|
|
894
|
+
|
|
895
|
+
Returns
|
|
896
|
+
-------
|
|
897
|
+
str
|
|
898
|
+
URL string for this receipt's detail page.
|
|
899
|
+
"""
|
|
900
|
+
return reverse(
|
|
901
|
+
'django_ledger:receipt-detail',
|
|
902
|
+
kwargs={'entity_slug': self.entity_slug, 'receipt_pk': self.uuid},
|
|
903
|
+
)
|
|
904
|
+
|
|
905
|
+
def get_list_url(self) -> str:
|
|
906
|
+
"""URL for the entity's receipt list view.
|
|
907
|
+
|
|
908
|
+
Returns
|
|
909
|
+
-------
|
|
910
|
+
str
|
|
911
|
+
URL string for listing receipts of the same entity.
|
|
912
|
+
"""
|
|
913
|
+
return reverse(
|
|
914
|
+
'django_ledger:receipt-list', kwargs={'entity_slug': self.entity_slug}
|
|
915
|
+
)
|
|
916
|
+
|
|
917
|
+
def get_delete_url(self) -> str:
|
|
918
|
+
"""URL for the receipt delete view.
|
|
919
|
+
|
|
920
|
+
Returns
|
|
921
|
+
-------
|
|
922
|
+
str
|
|
923
|
+
URL string for deleting this receipt.
|
|
924
|
+
"""
|
|
925
|
+
return reverse(
|
|
926
|
+
'django_ledger:receipt-delete',
|
|
927
|
+
kwargs={'entity_slug': self.entity_slug, 'receipt_pk': self.uuid},
|
|
928
|
+
)
|
|
929
|
+
|
|
930
|
+
def get_customer_list_url(self) -> Optional[str]:
|
|
931
|
+
"""URL for listing receipts for the same customer.
|
|
932
|
+
|
|
933
|
+
Returns
|
|
934
|
+
-------
|
|
935
|
+
str or None
|
|
936
|
+
URL string to the customer's receipt list, or None when the receipt
|
|
937
|
+
has no customer.
|
|
938
|
+
"""
|
|
939
|
+
if not self.customer_model_id:
|
|
940
|
+
return None
|
|
941
|
+
return reverse(
|
|
942
|
+
'django_ledger:receipt-list-customer',
|
|
943
|
+
kwargs={
|
|
944
|
+
'entity_slug': self.entity_slug,
|
|
945
|
+
'customer_pk': self.customer_model_id,
|
|
946
|
+
},
|
|
947
|
+
)
|
|
948
|
+
|
|
949
|
+
def get_vendor_list_url(self) -> Optional[str]:
|
|
950
|
+
"""URL for listing receipts for the same vendor.
|
|
951
|
+
|
|
952
|
+
Returns
|
|
953
|
+
-------
|
|
954
|
+
str or None
|
|
955
|
+
URL string to the vendor's receipt list, or None when the receipt
|
|
956
|
+
has no vendor.
|
|
957
|
+
"""
|
|
958
|
+
if not self.vendor_model_id:
|
|
959
|
+
return None
|
|
960
|
+
return reverse(
|
|
961
|
+
'django_ledger:receipt-list-vendor',
|
|
962
|
+
kwargs={'entity_slug': self.entity_slug, 'vendor_pk': self.vendor_model_id},
|
|
963
|
+
)
|
|
964
|
+
|
|
965
|
+
def get_customer_report_url(self) -> Optional[str]:
|
|
966
|
+
"""URL for a report focused on this receipt's customer.
|
|
967
|
+
|
|
968
|
+
Returns
|
|
969
|
+
-------
|
|
970
|
+
str or None
|
|
971
|
+
URL string to the customer report, or None when the receipt has no
|
|
972
|
+
customer.
|
|
973
|
+
"""
|
|
974
|
+
if not self.customer_model_id:
|
|
975
|
+
return None
|
|
976
|
+
return reverse(
|
|
977
|
+
'django_ledger:receipt-report-customer',
|
|
978
|
+
kwargs={
|
|
979
|
+
'entity_slug': self.entity_slug,
|
|
980
|
+
'customer_pk': self.customer_model_id,
|
|
981
|
+
},
|
|
982
|
+
)
|
|
983
|
+
|
|
984
|
+
def get_vendor_report_url(self) -> Optional[str]:
|
|
985
|
+
"""URL for a report focused on this receipt's vendor.
|
|
986
|
+
|
|
987
|
+
Returns
|
|
988
|
+
-------
|
|
989
|
+
str or None
|
|
990
|
+
URL string to the vendor report, or None when the receipt has no
|
|
991
|
+
vendor.
|
|
992
|
+
"""
|
|
993
|
+
if not self.vendor_model_id:
|
|
994
|
+
return None
|
|
995
|
+
return reverse(
|
|
996
|
+
'django_ledger:receipt-report-vendor',
|
|
997
|
+
kwargs={'entity_slug': self.entity_slug, 'vendor_pk': self.vendor_model_id},
|
|
998
|
+
)
|
|
999
|
+
|
|
1000
|
+
def get_import_job_url(self) -> Optional[str]:
|
|
1001
|
+
"""URL of the import job page associated with the staged transaction.
|
|
1002
|
+
|
|
1003
|
+
Returns
|
|
1004
|
+
-------
|
|
1005
|
+
str or None
|
|
1006
|
+
URL string to the import job transactions page, or None when there
|
|
1007
|
+
is no staged transaction linked.
|
|
1008
|
+
"""
|
|
1009
|
+
if not self.staged_transaction_model_id:
|
|
1010
|
+
return None
|
|
1011
|
+
job = self.staged_transaction_model.import_job
|
|
1012
|
+
return reverse(
|
|
1013
|
+
'django_ledger:data-import-job-txs',
|
|
1014
|
+
kwargs={'entity_slug': self.entity_slug, 'job_pk': job.uuid},
|
|
1015
|
+
)
|
|
1016
|
+
|
|
1017
|
+
def get_staged_tx_url(self) -> Optional[str]:
|
|
1018
|
+
"""Anchor URL pointing to the staged transaction within the import job page.
|
|
1019
|
+
|
|
1020
|
+
Returns
|
|
1021
|
+
-------
|
|
1022
|
+
str or None
|
|
1023
|
+
URL string anchored to this receipt's staged transaction, or None
|
|
1024
|
+
when there is no staged transaction or import job URL.
|
|
1025
|
+
"""
|
|
1026
|
+
if not self.staged_transaction_model_id:
|
|
1027
|
+
return None
|
|
1028
|
+
base = self.get_import_job_url()
|
|
1029
|
+
return f'{base}#staged-tx-{self.staged_transaction_model_id}' if base else None
|
|
1030
|
+
|
|
1031
|
+
def clean(self):
|
|
1032
|
+
"""Model validation for mutually exclusive counterparties.
|
|
1033
|
+
|
|
1034
|
+
Raises
|
|
1035
|
+
------
|
|
1036
|
+
ReceiptModelValidationError
|
|
1037
|
+
If a sales receipt lacks a customer or an expense receipt lacks a
|
|
1038
|
+
vendor.
|
|
1039
|
+
"""
|
|
1040
|
+
if self.is_sales_receipt():
|
|
1041
|
+
if not self.customer_model_id:
|
|
1042
|
+
raise ReceiptModelValidationError(
|
|
1043
|
+
message=_('Sales receipt must have a customer model.')
|
|
1044
|
+
)
|
|
1045
|
+
self.vendor_model = None
|
|
1046
|
+
|
|
1047
|
+
if self.is_expense_receipt():
|
|
1048
|
+
if not self.vendor_model_id:
|
|
1049
|
+
raise ReceiptModelValidationError(
|
|
1050
|
+
message=_('Expense receipt must have a vendor model.')
|
|
1051
|
+
)
|
|
1052
|
+
self.customer_model = None
|
|
1053
|
+
|
|
1054
|
+
|
|
1055
|
+
class ReceiptModel(ReceiptModelAbstract):
|
|
1056
|
+
"""Concrete receipt model ready for migration and admin usage.
|
|
1057
|
+
|
|
1058
|
+
Inherits all behavior from ReceiptModelAbstract and is used as the concrete
|
|
1059
|
+
Django model for persistence.
|
|
1060
|
+
"""
|
|
1061
|
+
|
|
1062
|
+
class Meta:
|
|
1063
|
+
abstract = False
|
|
1064
|
+
|
|
1065
|
+
|
|
1066
|
+
def receiptmodel_presave(instance: ReceiptModel, **kwargs):
|
|
1067
|
+
"""Pre-save signal hook for ReceiptModel.
|
|
1068
|
+
|
|
1069
|
+
Parameters
|
|
1070
|
+
----------
|
|
1071
|
+
instance : ReceiptModel
|
|
1072
|
+
The model instance being saved.
|
|
1073
|
+
**kwargs
|
|
1074
|
+
Additional keyword arguments passed by Django's pre_save.
|
|
1075
|
+
|
|
1076
|
+
Notes
|
|
1077
|
+
-----
|
|
1078
|
+
Currently a no-op placeholder for future pre-save logic.
|
|
1079
|
+
"""
|
|
1080
|
+
pass
|
|
1081
|
+
|
|
1082
|
+
|
|
1083
|
+
pre_save.connect(receiptmodel_presave, sender=ReceiptModel)
|