Hide keyboard shortcuts

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: 

3 

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. 

7 

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 

15 

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 _ 

25 

26from jutil.format import choices_label 

27from jutil.modelfields import SafeCharField, SafeTextField 

28 

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 

32 

33CATEGORY_TYPE = ( 

34 (CATEGORY_ANY, ''), 

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

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

37) 

38 

39CURRENCY_TYPE = ( 

40 ('EUR', 'EUR'), 

41 ('USD', 'USD'), 

42) 

43 

44INVOICE_NOT_DUE_YET = 'N' 

45INVOICE_DUE = 'D' 

46INVOICE_LATE = 'L' 

47INVOICE_PAID = 'P' 

48 

49INVOICE_STATE = ( 

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

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

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

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

54) 

55 

56INVOICE_DEFAULT = 'I1' 

57INVOICE_CREDIT_NOTE = 'I2' 

58 

59INVOICE_TYPE = ( 

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

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

62) 

63 

64 

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) 

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 

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) 

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 

99class AccountEntryManager(models.Manager): 

100 pass 

101 

102 

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) 

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 

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) 

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 

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) 

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 

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

225 

226 

227def 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 

231class 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 

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) 

431 

432 class Meta: 

433 verbose_name = _('contract') 

434 verbose_name_plural = _('contracts') 

435 

436 def __str__(self): 

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