Coverage for jacc/models.py: 90%

# Coverage for jacc/models.py : 90%

## 261 statements 234 run 27 missing 0 excluded

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"""

2 Double entry accounting system:

3

4 A debit is an accounting entry that either increases an asset or expense account,

5 or decreases a liability or equity account. It is positioned to the left in an accounting entry.

6 Debit means "left", dividends/expenses/assets/losses increased with debit.

7

8 A credit is an accounting entry that either increases a liability or equity account,

9 or decreases an asset or expense account.

10 Credit means "right", gains/income/revenues/liabilities/equity increased with credit.

11"""

12 from datetime import datetime, timedelta

13 from decimal import Decimal

14 from typing import Optional, Type

15

16 from math import floor

17 from django.core.exceptions import ValidationError

18 from jacc.helpers import sum_queryset

19 from django.conf import settings

20 from django.db import models, transaction

21 from django.db.models import QuerySet, Q

22 from django.utils.timezone import now

23 from jutil.cache import CachedFieldsMixin

24 from django.utils.translation import gettext_lazy as _

25

26 from jutil.format import choices_label

27 from jutil.modelfields import SafeCharField, SafeTextField

28

29 CATEGORY_ANY = ''

30 CATEGORY_DEBIT = 'D' # "left", dividends/expenses/assets/losses increased with debit

31 CATEGORY_CREDIT = 'C' # "right", gains/income/revenues/liabilities/equity increased with credit

32

33 CATEGORY_TYPE = (

34 (CATEGORY_ANY, ''),

35 (CATEGORY_DEBIT, _('Debit')),

36 (CATEGORY_CREDIT, _('Credit')),

37)

38

39 CURRENCY_TYPE = (

40 ('EUR', 'EUR'),

41 ('USD', 'USD'),

42)

43

44 INVOICE_NOT_DUE_YET = 'N'

45 INVOICE_DUE = 'D'

46 INVOICE_LATE = 'L'

47 INVOICE_PAID = 'P'

48

49 INVOICE_STATE = (

50 (INVOICE_NOT_DUE_YET, _('Not due yet')),

51 (INVOICE_DUE, _('Due')),

52 (INVOICE_LATE, _('Late')),

53 (INVOICE_PAID, _('Paid')),

54)

55

56 INVOICE_DEFAULT = 'I1'

57 INVOICE_CREDIT_NOTE = 'I2'

58

59 INVOICE_TYPE = (

60 (INVOICE_DEFAULT, _('Invoice')),

61 (INVOICE_CREDIT_NOTE, _('Credit Note')),

62)

63

64

65 class 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)

72

73 class Meta:

74 verbose_name = _('account entry source file')

75 verbose_name_plural = _('account entry source files')

76

77 def __str__(self):

78 return '[{}] {}'.format(self.id, self.name)

79

80

81 class 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)

90

91 class Meta:

92 verbose_name = _('entry type')

93 verbose_name_plural = _('entry types')

94

95 def __str__(self):

96 return '{} ({})'.format(self.name, self.code)

97

98

99 class AccountEntryManager(models.Manager):

100 pass

101

102

103 class 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)

121

122 class Meta:

123 verbose_name = _('account entry')

124 verbose_name_plural = _('account entries')

125

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)

129

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))

134

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

143

144

145 class 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)

151

152 class Meta:

153 verbose_name = _('account type')

154 verbose_name_plural = _('account types')

155

156 def __str__(self):

157 return self.name

158

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

163

164

165 class 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)

174

175 class Meta:

176 verbose_name = _('account')

177 verbose_name_plural = _('accounts')

178

179 def __str__(self):

180 return '[{}] {}'.format(self.id, self.name if self.name else self.type)

181

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

186

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

191

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

196

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))

204

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)

218

219

220 class 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()

225

226

227 def get_default_due_date():

228 return now() + timedelta(days=settings.DEFAULT_DUE_DATE_DAYS) if hasattr(settings, 'DEFAULT_DUE_DATE_DAYS') else None

229

230

231 class Invoice(models.Model, CachedFieldsMixin):

232 """

233 Invoice model. Typically used as base model for actual app-specific invoice model.

234

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)

238

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 ]

267

268 class Meta:

269 verbose_name = _('invoice')

270 verbose_name_plural = _('invoices')

271

272 def __str__(self):

273 return '[{}] {} {}'.format(self.id, self.due_date.date().isoformat() if self.due_date else '', self.amount)

274

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

283

284 @property

285 def currency(self) -> str:

286 recv = self.receivables_account

287 return recv.currency if recv else ''

288

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()

297

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))

305

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

320

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])]

340

341 def get_amount(self) -> Decimal:

342 return sum_queryset(self.items, 'amount')

343

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)

350

351 @property

352 def items(self) -> QuerySet:

353 return self.receivables.filter(source_invoice=self)

354

355 def get_paid_amount(self) -> Decimal:

356 return self.get_amount() - self.get_unpaid_amount()

357

358 def get_unpaid_amount(self) -> Decimal:

359 return sum_queryset(self.receivables)

360

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)

366

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

373

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

378

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

392

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))

398

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

404

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

414

415 def get_state_name(self) -> str:

416 return choices_label(INVOICE_STATE, self.get_state())

417

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

422

423

424 class 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)

431

432 class Meta:

433 verbose_name = _('contract')

434 verbose_name_plural = _('contracts')

435

436 def __str__(self):

437 return '[{}]'.format(self.id)