Coverage for jacc/models.py : 90%

Hot-keys on this page
r m x p toggle line displays
j k next/prev highlighted chunk
0 (zero) top of page
1 (one) first highlighted chunk
1"""
2Double entry accounting system:
4A debit is an accounting entry that either increases an asset or expense account,
5or decreases a liability or equity account. It is positioned to the left in an accounting entry.
6Debit means "left", dividends/expenses/assets/losses increased with debit.
8A credit is an accounting entry that either increases a liability or equity account,
9or decreases an asset or expense account.
10Credit means "right", gains/income/revenues/liabilities/equity increased with credit.
11"""
12from datetime import datetime, timedelta
13from decimal import Decimal
14from typing import Optional, Type
16from math import floor
17from django.core.exceptions import ValidationError
18from jacc.helpers import sum_queryset
19from django.conf import settings
20from django.db import models, transaction
21from django.db.models import QuerySet, Q
22from django.utils.timezone import now
23from jutil.cache import CachedFieldsMixin
24from django.utils.translation import gettext_lazy as _
26from jutil.format import choices_label
27from jutil.modelfields import SafeCharField, SafeTextField
29CATEGORY_ANY = ''
30CATEGORY_DEBIT = 'D' # "left", dividends/expenses/assets/losses increased with debit
31CATEGORY_CREDIT = 'C' # "right", gains/income/revenues/liabilities/equity increased with credit
33CATEGORY_TYPE = (
34 (CATEGORY_ANY, ''),
35 (CATEGORY_DEBIT, _('Debit')),
36 (CATEGORY_CREDIT, _('Credit')),
37)
39CURRENCY_TYPE = (
40 ('EUR', 'EUR'),
41 ('USD', 'USD'),
42)
44INVOICE_NOT_DUE_YET = 'N'
45INVOICE_DUE = 'D'
46INVOICE_LATE = 'L'
47INVOICE_PAID = 'P'
49INVOICE_STATE = (
50 (INVOICE_NOT_DUE_YET, _('Not due yet')),
51 (INVOICE_DUE, _('Due')),
52 (INVOICE_LATE, _('Late')),
53 (INVOICE_PAID, _('Paid')),
54)
56INVOICE_DEFAULT = 'I1'
57INVOICE_CREDIT_NOTE = 'I2'
59INVOICE_TYPE = (
60 (INVOICE_DEFAULT, _('Invoice')),
61 (INVOICE_CREDIT_NOTE, _('Credit Note')),
62)
65class AccountEntrySourceFile(models.Model):
66 """
67 Account entry source is set for entries based on some event like payment file import
68 """
69 name = SafeCharField(verbose_name=_('name'), max_length=255, db_index=True, blank=True, default='')
70 created = models.DateTimeField(verbose_name=_('created'), default=now, db_index=True, editable=False, blank=True)
71 last_modified = models.DateTimeField(verbose_name=_('last modified'), auto_now=True, db_index=True, editable=False, blank=True)
73 class Meta:
74 verbose_name = _('account entry source file')
75 verbose_name_plural = _('account entry source files')
77 def __str__(self):
78 return '[{}] {}'.format(self.id, self.name)
81class EntryType(models.Model):
82 code = SafeCharField(verbose_name=_('code'), max_length=64, db_index=True, unique=True)
83 identifier = SafeCharField(verbose_name=_('identifier'), max_length=40, db_index=True, blank=True, default='')
84 name = SafeCharField(verbose_name=_('name'), max_length=128, db_index=True, blank=True, default='')
85 created = models.DateTimeField(verbose_name=_('created'), default=now, db_index=True, editable=False, blank=True)
86 last_modified = models.DateTimeField(verbose_name=_('last modified'), auto_now=True, db_index=True, editable=False, blank=True)
87 payback_priority = models.SmallIntegerField(verbose_name=_('payback priority'), default=0, blank=True, db_index=True)
88 is_settlement = models.BooleanField(verbose_name=_('is settlement'), default=False, blank=True, db_index=True)
89 is_payment = models.BooleanField(verbose_name=_('is payment'), default=False, blank=True, db_index=True)
91 class Meta:
92 verbose_name = _('entry type')
93 verbose_name_plural = _('entry types')
95 def __str__(self):
96 return '{} ({})'.format(self.name, self.code)
99class AccountEntryManager(models.Manager):
100 pass
103class AccountEntry(models.Model):
104 """
105 Single mutation in account state.
106 """
107 objects: models.Manager = AccountEntryManager()
108 account = models.ForeignKey('Account', verbose_name=_('record account'), related_name='accountentry_set', db_index=True, on_delete=models.PROTECT)
109 created = models.DateTimeField(verbose_name=_('created'), default=now, db_index=True, editable=False, blank=True)
110 last_modified = models.DateTimeField(verbose_name=_('last modified'), auto_now=True, editable=False, blank=True)
111 timestamp = models.DateTimeField(verbose_name=_('timestamp'), default=now, db_index=True, blank=True)
112 type = models.ForeignKey(EntryType, verbose_name=_('type'), related_name='+', on_delete=models.PROTECT, null=True, default=None, blank=True)
113 description = SafeCharField(verbose_name=_('description'), max_length=256, default='', blank=True)
114 amount = models.DecimalField(verbose_name=_('amount'), max_digits=10, decimal_places=2, blank=True, default=None, null=True, db_index=True)
115 source_file = models.ForeignKey(AccountEntrySourceFile, verbose_name=_('account entry source file'), related_name='+', null=True, default=None, blank=True, on_delete=models.CASCADE, help_text=_('entry.source.file.help.text')) # nopep8, pylint: disable=line-too-long
116 source_invoice = models.ForeignKey('Invoice', verbose_name=_('source invoice'), null=True, related_name='+', default=None, blank=True, on_delete=models.CASCADE, help_text=_('entry.source.invoice.help.text')) # nopep8, pylint: disable=line-too-long
117 settled_invoice = models.ForeignKey('Invoice', verbose_name=_('settled invoice'), null=True, related_name='+', default=None, blank=True, on_delete=models.PROTECT, help_text=_('entry.settled.invoice.help.text')) # nopep8, pylint: disable=line-too-long
118 settled_item = models.ForeignKey('AccountEntry', verbose_name=_('settled item'), null=True, related_name='settlement_set', default=None, blank=True, on_delete=models.PROTECT, help_text=_('entry.settled.item.help.text')) # nopep8, pylint: disable=line-too-long
119 parent = models.ForeignKey('AccountEntry', verbose_name=_('account.entry.parent'), related_name='child_set', db_index=True, on_delete=models.CASCADE, null=True, default=None, blank=True) # nopep8, pylint: disable=line-too-long
120 archived = models.BooleanField(_('archived'), default=False, blank=True)
122 class Meta:
123 verbose_name = _('account entry')
124 verbose_name_plural = _('account entries')
126 def __str__(self):
127 return '[{}] {} {} {}'.format(self.id, self.timestamp.date().isoformat() if self.timestamp else '',
128 self.type if self.type else '', self.amount)
130 def clean(self):
131 if self.source_invoice and self.settled_invoice:
132 raise ValidationError('Both source_invoice ({}) and settled_invoice ({}) cannot be set same time for account entry ({})'.format(
133 self.source_invoice, self.settled_invoice, self))
135 @property
136 def balance(self) -> Decimal:
137 """
138 Returns account balance after this entry.
139 :return: Decimal
140 """
141 return sum_queryset(AccountEntry.objects.filter(account=self.account, timestamp__lte=self.timestamp).exclude(timestamp=self.timestamp, id__gt=self.id))
142 balance.fget.short_description = _('balance') # type: ignore # pytype: disable=attribute-error
145class AccountType(models.Model):
146 code = SafeCharField(verbose_name=_('code'), max_length=32, db_index=True, unique=True)
147 name = SafeCharField(verbose_name=_('name'), max_length=64, db_index=True, unique=True)
148 is_asset = models.BooleanField(verbose_name=_('asset'))
149 created = models.DateTimeField(verbose_name=_('created'), default=now, db_index=True, editable=False, blank=True)
150 last_modified = models.DateTimeField(verbose_name=_('last modified'), auto_now=True, db_index=True, editable=False, blank=True)
152 class Meta:
153 verbose_name = _('account type')
154 verbose_name_plural = _('account types')
156 def __str__(self):
157 return self.name
159 @property
160 def is_liability(self) -> bool:
161 return not self.is_asset
162 is_liability.fget.short_description = _('liability') # type: ignore # pytype: disable=attribute-error
165class Account(models.Model):
166 """
167 Collects together accounting entries and provides summarizing functionality.
168 """
169 type = models.ForeignKey(AccountType, verbose_name=_('type'), related_name='+', on_delete=models.PROTECT)
170 name = SafeCharField(verbose_name=_('name'), max_length=64, blank=True, default='', db_index=True)
171 currency = SafeCharField(verbose_name=_('currency'), max_length=3, default='EUR', choices=CURRENCY_TYPE, blank=True)
172 created = models.DateTimeField(verbose_name=_('created'), default=now, db_index=True, editable=False, blank=True)
173 last_modified = models.DateTimeField(verbose_name=_('last modified'), auto_now=True, db_index=True, editable=False, blank=True)
175 class Meta:
176 verbose_name = _('account')
177 verbose_name_plural = _('accounts')
179 def __str__(self):
180 return '[{}] {}'.format(self.id, self.name if self.name else self.type)
182 def is_asset(self) -> bool:
183 return self.type.is_asset
184 is_asset.boolean = True # type: ignore
185 is_asset.short_description = _('asset') # type: ignore
187 def is_liability(self) -> bool:
188 return self.type.is_liability
189 is_liability.boolean = True # type: ignore
190 is_liability.short_description = _('liability') # type: ignore
192 @property
193 def balance(self) -> Decimal:
194 return sum_queryset(self.accountentry_set.all())
195 balance.fget.short_description = _('balance') # type: ignore # pytype: disable=attribute-error
197 def get_balance(self, t: datetime):
198 """
199 Returns account balance before specified datetime (excluding entries on the datetime).
200 :param t: datetime
201 :return: Decimal
202 """
203 return sum_queryset(self.accountentry_set.all().filter(timestamp__lt=t))
205 def needs_settling(self, e: AccountEntry) -> bool:
206 """
207 Returns True if all of following conditions are True:
208 a) entry has valid amount set
209 b) entry type is settlement
210 c) entry has been recorded to this account
211 d) invoice to be settled has been set
212 e) entry has not been settled (=child set empty)
213 :param e: AccountEntry (settlement)
214 :return: bool
215 """
216 return bool(e.amount is not None and e.type and e.type.is_settlement and e.account.id == self.id and
217 e.settled_invoice and AccountEntry.objects.filter(parent=e).count() == 0)
220class InvoiceManager(models.Manager):
221 @transaction.atomic
222 def update_cached_fields(self, **kw):
223 for obj in self.filter(**kw):
224 obj.update_cached_fields()
227def get_default_due_date():
228 return now() + timedelta(days=settings.DEFAULT_DUE_DATE_DAYS) if hasattr(settings, 'DEFAULT_DUE_DATE_DAYS') else None
231class Invoice(models.Model, CachedFieldsMixin):
232 """
233 Invoice model. Typically used as base model for actual app-specific invoice model.
235 Convention for naming date/time variables:
236 1) date fields are suffixed with _date if they are either plain date fields or interpreted as such (due_date)
237 2) natural datetime fields are in past tense, e.g. created, sent (instead of create_date, send_date)
239 Note: It is useful sometimes to have full datetime with timezone even for plain dates like due_date,
240 because this to be processing to be independent of server, client and invoice time zones.
241 """
242 objects: models.Manager = InvoiceManager()
243 type = SafeCharField(verbose_name=_('type'), max_length=2, db_index=True, default=INVOICE_DEFAULT, blank=True, choices=INVOICE_TYPE)
244 number = SafeCharField(verbose_name=_('invoice number'), max_length=32, default='', blank=True, db_index=True)
245 created = models.DateTimeField(verbose_name=_('created'), default=now, db_index=True, editable=False, blank=True)
246 last_modified = models.DateTimeField(verbose_name=_('last modified'), auto_now=True, db_index=True, editable=False, blank=True)
247 sent = models.DateTimeField(verbose_name=_('sent'), db_index=True, default=None, blank=True, null=True)
248 due_date = models.DateTimeField(verbose_name=_('due date'), db_index=True, default=get_default_due_date)
249 notes = SafeTextField(verbose_name=_('notes'), blank=True, default='')
250 filename = SafeCharField(verbose_name=_('filename'), max_length=255, blank=True, default='', db_index=True)
251 amount = models.DecimalField(verbose_name=_('amount'), max_digits=10, decimal_places=2, default=0, blank=True)
252 paid_amount = models.DecimalField(verbose_name=_('paid amount'), max_digits=10, decimal_places=2, editable=False, blank=True, null=True, default=None, db_index=True) # nopep8, pylint: disable=line-too-long
253 unpaid_amount = models.DecimalField(verbose_name=_('unpaid amount'), max_digits=10, decimal_places=2, editable=False, blank=True, null=True, default=None, db_index=True) # nopep8, pylint: disable=line-too-long
254 overpaid_amount = models.DecimalField(verbose_name=_('overpaid amount'), max_digits=10, decimal_places=2, editable=False, blank=True, null=True, default=None, db_index=True) # nopep8, pylint: disable=line-too-long
255 close_date = models.DateTimeField(verbose_name=_('close date'), default=None, null=True, blank=True, db_index=True)
256 late_days = models.SmallIntegerField(verbose_name=_('late days'), default=None, null=True, blank=True, db_index=True)
257 state = SafeCharField(verbose_name=_('state'), max_length=1, blank=True, default='', db_index=True, choices=INVOICE_STATE)
258 cached_fields = [
259 'amount',
260 'paid_amount',
261 'unpaid_amount',
262 'overpaid_amount',
263 'close_date',
264 'late_days',
265 'state',
266 ]
268 class Meta:
269 verbose_name = _('invoice')
270 verbose_name_plural = _('invoices')
272 def __str__(self):
273 return '[{}] {} {}'.format(self.id, self.due_date.date().isoformat() if self.due_date else '', self.amount)
275 @property
276 def receivables_account(self) -> Optional[Account]:
277 """
278 Returns receivables account. Receivables account is assumed to be the one were invoice rows were recorded.
279 :return: Account or None
280 """
281 row = AccountEntry.objects.filter(source_invoice=self).order_by('id').first()
282 return row.account if row else None
284 @property
285 def currency(self) -> str:
286 recv = self.receivables_account
287 return recv.currency if recv else ''
289 def get_entries(self, acc: Account, cls: Type[AccountEntry] = AccountEntry) -> QuerySet:
290 """
291 Returns entries related to this invoice on specified account.
292 :param acc: Account
293 :param cls: AccountEntry class
294 :return: QuerySet
295 """
296 return cls.objects.filter(Q(account=acc) & (Q(source_invoice=self) | Q(settled_invoice=self))) if acc else cls.objects.none()
298 def get_balance(self, acc: Account) -> Decimal:
299 """
300 Returns balance of this invoice on specified account.
301 :param acc: Account
302 :return:
303 """
304 return sum_queryset(self.get_entries(acc))
306 def get_item_balances(self, acc: Account) -> list:
307 """
308 Returns balances of items of the invoice.
309 :param acc: Account
310 :return: list (AccountEntry, Decimal) in item id order
311 """
312 items = []
313 entries = self.get_entries(acc)
314 for item in entries.filter(source_invoice=self).order_by('id'):
315 assert isinstance(item, AccountEntry)
316 settlements = sum_queryset(entries.filter(settled_item=item))
317 bal = item.amount + settlements if item.amount is not None else settlements
318 items.append((item, bal))
319 return items
321 def get_unpaid_items(self, acc: Account) -> list:
322 """
323 Returns unpaid items of the invoice in payback priority order.
324 :param acc: Account
325 :return: list (AccountEntry, Decimal) in payback priority order
326 """
327 unpaid_items = []
328 for item, bal in self.get_item_balances(acc):
329 assert isinstance(item, AccountEntry)
330 priority = item.type.payback_priority if item.type is not None else 0
331 if self.type == INVOICE_DEFAULT:
332 if bal > Decimal(0):
333 unpaid_items.append((priority, item, bal))
334 elif self.type == INVOICE_CREDIT_NOTE:
335 if bal < Decimal(0):
336 unpaid_items.append((priority, item, bal))
337 else:
338 raise Exception('jacc.models.Invoice.get_unpaid_items() unimplemented for invoice type {}'.format(self.type))
339 return [i[1:] for i in sorted(unpaid_items, key=lambda x: x[0])]
341 def get_amount(self) -> Decimal:
342 return sum_queryset(self.items, 'amount')
344 @property
345 def receivables(self) -> QuerySet:
346 acc = self.receivables_account
347 if acc is None:
348 return AccountEntry.objects.none()
349 return self.get_entries(acc)
351 @property
352 def items(self) -> QuerySet:
353 return self.receivables.filter(source_invoice=self)
355 def get_paid_amount(self) -> Decimal:
356 return self.get_amount() - self.get_unpaid_amount()
358 def get_unpaid_amount(self) -> Decimal:
359 return sum_queryset(self.receivables)
361 def get_overpaid_amount(self) -> Decimal:
362 amt = sum_queryset(self.receivables)
363 if self.type == INVOICE_CREDIT_NOTE:
364 return max(Decimal('0.00'), amt)
365 return max(Decimal('0.00'), -amt)
367 @property
368 def is_paid(self) -> bool:
369 if self.unpaid_amount is None:
370 return False
371 return self.unpaid_amount >= Decimal('0.00') if self.type == INVOICE_CREDIT_NOTE else self.unpaid_amount <= Decimal('0.00')
372 is_paid.fget.short_description = _('is paid') # type: ignore # pytype: disable=attribute-error
374 @property
375 def is_due(self) -> bool:
376 return not self.is_paid and now() >= self.due_date
377 is_due.fget.short_description = _('is due') # type: ignore # pytype: disable=attribute-error
379 def get_close_date(self) -> Optional[datetime]:
380 recv = self.receivables.order_by('-timestamp', '-id')
381 first = recv.first()
382 if first is None:
383 return None
384 total = sum_queryset(recv)
385 if self.type == INVOICE_CREDIT_NOTE:
386 if total >= Decimal('0.00'):
387 return first.timestamp
388 else:
389 if total <= Decimal('0.00'):
390 return first.timestamp
391 return None
393 def get_late_days(self, t: Optional[datetime] = None) -> int:
394 t = self.close_date or t
395 if t is None:
396 t = now()
397 return int(floor((t - self.due_date).total_seconds() / 86400.0))
399 @property
400 def is_late(self) -> bool:
401 if self.late_days is None:
402 return False
403 return not self.is_paid and self.late_days >= settings.LATE_LIMIT_DAYS
405 def get_state(self) -> str:
406 if self.is_paid:
407 return INVOICE_PAID
408 t = now()
409 if t - self.due_date >= timedelta(days=settings.LATE_LIMIT_DAYS):
410 return INVOICE_LATE
411 if t >= self.due_date:
412 return INVOICE_DUE
413 return INVOICE_NOT_DUE_YET
415 def get_state_name(self) -> str:
416 return choices_label(INVOICE_STATE, self.get_state())
418 @property
419 def state_name(self) -> str:
420 return choices_label(INVOICE_STATE, self.state)
421 state_name.fget.short_description = _('state') # type: ignore # pytype: disable=attribute-error
424class Contract(models.Model):
425 """
426 Base class for contracts (e.g. rent contracts, loans, etc.)
427 """
428 created = models.DateTimeField(verbose_name=_('created'), default=now, db_index=True, editable=False, blank=True)
429 last_modified = models.DateTimeField(verbose_name=_('last modified'), auto_now=True, db_index=True, editable=False, blank=True)
430 name = SafeCharField(verbose_name=_('name'), max_length=128, default='', blank=True, db_index=True)
432 class Meta:
433 verbose_name = _('contract')
434 verbose_name_plural = _('contracts')
436 def __str__(self):
437 return '[{}]'.format(self.id)