django-ledger 0.8.0__py3-none-any.whl → 0.8.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


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

Files changed (51) hide show
  1. django_ledger/__init__.py +1 -1
  2. django_ledger/forms/account.py +45 -46
  3. django_ledger/forms/data_import.py +182 -63
  4. django_ledger/io/io_core.py +507 -374
  5. django_ledger/migrations/0026_stagedtransactionmodel_customer_model_and_more.py +56 -0
  6. django_ledger/models/__init__.py +2 -1
  7. django_ledger/models/bill.py +337 -300
  8. django_ledger/models/customer.py +47 -34
  9. django_ledger/models/data_import.py +770 -289
  10. django_ledger/models/entity.py +882 -637
  11. django_ledger/models/mixins.py +421 -280
  12. django_ledger/models/receipt.py +1083 -0
  13. django_ledger/models/transactions.py +105 -41
  14. django_ledger/models/unit.py +42 -30
  15. django_ledger/models/utils.py +12 -2
  16. django_ledger/models/vendor.py +85 -66
  17. django_ledger/settings.py +1 -0
  18. django_ledger/templates/django_ledger/components/period_navigator.html +5 -3
  19. django_ledger/templates/django_ledger/customer/customer_detail.html +87 -0
  20. django_ledger/templates/django_ledger/customer/customer_list.html +0 -1
  21. django_ledger/templates/django_ledger/customer/tags/customer_table.html +3 -1
  22. django_ledger/templates/django_ledger/data_import/tags/data_import_job_txs_imported.html +24 -3
  23. django_ledger/templates/django_ledger/data_import/tags/data_import_job_txs_table.html +26 -10
  24. django_ledger/templates/django_ledger/entity/entity_dashboard.html +2 -2
  25. django_ledger/templates/django_ledger/layouts/base.html +1 -1
  26. django_ledger/templates/django_ledger/layouts/content_layout_1.html +1 -1
  27. django_ledger/templates/django_ledger/receipt/customer_receipt_report.html +115 -0
  28. django_ledger/templates/django_ledger/receipt/receipt_delete.html +30 -0
  29. django_ledger/templates/django_ledger/receipt/receipt_detail.html +89 -0
  30. django_ledger/templates/django_ledger/receipt/receipt_list.html +134 -0
  31. django_ledger/templates/django_ledger/receipt/vendor_receipt_report.html +115 -0
  32. django_ledger/templates/django_ledger/vendor/tags/vendor_table.html +3 -2
  33. django_ledger/templates/django_ledger/vendor/vendor_detail.html +86 -0
  34. django_ledger/templatetags/django_ledger.py +338 -191
  35. django_ledger/urls/__init__.py +1 -0
  36. django_ledger/urls/customer.py +3 -0
  37. django_ledger/urls/data_import.py +3 -0
  38. django_ledger/urls/receipt.py +102 -0
  39. django_ledger/urls/vendor.py +1 -0
  40. django_ledger/views/__init__.py +1 -0
  41. django_ledger/views/customer.py +56 -14
  42. django_ledger/views/data_import.py +119 -66
  43. django_ledger/views/mixins.py +112 -86
  44. django_ledger/views/receipt.py +294 -0
  45. django_ledger/views/vendor.py +53 -14
  46. {django_ledger-0.8.0.dist-info → django_ledger-0.8.1.dist-info}/METADATA +1 -1
  47. {django_ledger-0.8.0.dist-info → django_ledger-0.8.1.dist-info}/RECORD +51 -40
  48. {django_ledger-0.8.0.dist-info → django_ledger-0.8.1.dist-info}/WHEEL +0 -0
  49. {django_ledger-0.8.0.dist-info → django_ledger-0.8.1.dist-info}/licenses/AUTHORS.md +0 -0
  50. {django_ledger-0.8.0.dist-info → django_ledger-0.8.1.dist-info}/licenses/LICENSE +0 -0
  51. {django_ledger-0.8.0.dist-info → django_ledger-0.8.1.dist-info}/top_level.txt +0 -0
@@ -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)