From dfbf6a5d2dc2c7c56c3be699447d0097fe8372d6 Mon Sep 17 00:00:00 2001 From: George Katsikas <giorgakis.katsikas@gmail.com> Date: Wed, 16 Oct 2024 14:56:11 +0200 Subject: [PATCH] add bank-mirror finance models --- scipost_django/finances/admin.py | 24 ++++ ...tion_futureperiodictransaction_and_more.py | 126 ++++++++++++++++++ scipost_django/finances/models/__init__.py | 6 + scipost_django/finances/models/account.py | 111 +++++++++++++++ scipost_django/finances/models/balance.py | 22 +++ scipost_django/finances/models/transaction.py | 40 ++++++ 6 files changed, 329 insertions(+) create mode 100644 scipost_django/finances/migrations/0046_account_transaction_futureperiodictransaction_and_more.py create mode 100644 scipost_django/finances/models/account.py create mode 100644 scipost_django/finances/models/balance.py create mode 100644 scipost_django/finances/models/transaction.py diff --git a/scipost_django/finances/admin.py b/scipost_django/finances/admin.py index 3e333816c..f2fd82f5f 100644 --- a/scipost_django/finances/admin.py +++ b/scipost_django/finances/admin.py @@ -4,6 +4,10 @@ __license__ = "AGPL v3" from django.contrib import admin +from finances.models.account import Account +from finances.models.balance import Balance +from finances.models.transaction import FuturePeriodicTransaction + from .models import ( Subsidy, SubsidyPayment, @@ -160,3 +164,23 @@ class WorkLogAdmin(admin.ModelAdmin): admin.site.register(PeriodicReportType) admin.site.register(PeriodicReport) + + +class BalanceInline(admin.TabularInline): + model = Balance + extra = 0 + + +class FuturePeriodicTransactionInline(admin.TabularInline): + model = FuturePeriodicTransaction + extra = 0 + + +@admin.register(Account) +class AccountAdmin(admin.ModelAdmin): + list_display = ["number", "name", "description"] + search_fields = ["number", "name"] + inlines = [ + FuturePeriodicTransactionInline, + BalanceInline, + ] diff --git a/scipost_django/finances/migrations/0046_account_transaction_futureperiodictransaction_and_more.py b/scipost_django/finances/migrations/0046_account_transaction_futureperiodictransaction_and_more.py new file mode 100644 index 000000000..54e945b2c --- /dev/null +++ b/scipost_django/finances/migrations/0046_account_transaction_futureperiodictransaction_and_more.py @@ -0,0 +1,126 @@ +# Generated by Django 4.2.15 on 2024-10-16 12:34 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + dependencies = [ + ("finances", "0045_alter_subsidypayment_options"), + ] + + operations = [ + migrations.CreateModel( + name="Account", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("name", models.CharField(max_length=128)), + ("number", models.CharField(max_length=64, unique=True)), + ("description", models.TextField(blank=True)), + ], + ), + migrations.CreateModel( + name="Transaction", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("amount", models.DecimalField(decimal_places=2, max_digits=10)), + ("datetime", models.DateTimeField()), + ( + "account", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + to="finances.account", + ), + ), + ], + options={ + "ordering": ["-datetime"], + "default_related_name": "transactions", + }, + ), + migrations.CreateModel( + name="FuturePeriodicTransaction", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("amount", models.DecimalField(decimal_places=2, max_digits=10)), + ("date_from", models.DateField()), + ("period", models.DurationField()), + ("name", models.CharField(max_length=128)), + ("description", models.TextField(blank=True)), + ( + "account", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + to="finances.account", + ), + ), + ], + options={ + "ordering": ["-date_from"], + "default_related_name": "future_transactions", + }, + ), + migrations.CreateModel( + name="Balance", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("date", models.DateField()), + ("amount", models.DecimalField(decimal_places=2, max_digits=12)), + ( + "account", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="balance_entries", + to="finances.account", + ), + ), + ], + options={ + "ordering": ["-date"], + }, + ), + migrations.AddConstraint( + model_name="transaction", + constraint=models.UniqueConstraint( + fields=("account", "datetime"), name="unique_transaction" + ), + ), + migrations.AddConstraint( + model_name="balance", + constraint=models.UniqueConstraint( + fields=("account", "date"), name="unique_balance" + ), + ), + ] diff --git a/scipost_django/finances/models/__init__.py b/scipost_django/finances/models/__init__.py index a3406bafc..85b5bfb69 100644 --- a/scipost_django/finances/models/__init__.py +++ b/scipost_django/finances/models/__init__.py @@ -20,3 +20,9 @@ from .subsidy_attachment import ( ) from .work_log import WorkLog + +from .account import Account + +from .balance import Balance + +from .transaction import Transaction, FuturePeriodicTransaction diff --git a/scipost_django/finances/models/account.py b/scipost_django/finances/models/account.py new file mode 100644 index 000000000..d418fb1a2 --- /dev/null +++ b/scipost_django/finances/models/account.py @@ -0,0 +1,111 @@ +__copyright__ = "Copyright © Stichting SciPost (SciPost Foundation)" +__license__ = "AGPL v3" + +from datetime import date +import datetime +from typing import TYPE_CHECKING +from django.db import models +from django.utils.functional import cached_property + +if TYPE_CHECKING: + from django.db.models.manager import RelatedManager + from finances.models.balance import Balance + from finances.models.transaction import FuturePeriodicTransaction + + +class Account(models.Model): + + name = models.CharField(max_length=128) + number = models.CharField(max_length=64, unique=True) + description = models.TextField(blank=True) + + if TYPE_CHECKING: + balance_entries: "RelatedManager[Balance]" + future_transactions: "RelatedManager[FuturePeriodicTransaction]" + + def __str__(self): + return self.name + + @property + def balance(self): + return self.balance_entries.order_by("date").last() + + def projected_balance(self, projection_date: date, smooth=False) -> float: + """ + Returns the projected balance at a given date, based on the last balance entry + and FuturePeriodicTransaction entries. + + If smooth is True, the future transactions are distributed evenly over the period + until the given date instead of being applied at the end of each of their periods. + """ + balance = float(self.balance.amount if self.balance else 0) + + today = date.today() + for transaction in self.future_transactions: + + projection_duration_days = (projection_date - today).days + if projection_duration_days <= 0: + continue + + periods_until_date = projection_duration_days / transaction.period.days + if not smooth: + days_until_next_transaction = transaction.period.days - ( + (today - transaction.date_from).days % transaction.period.days + ) + + days_past_period = projection_duration_days % transaction.period.days + triggers_first_transaction = ( + days_past_period >= days_until_next_transaction + ) + periods_until_date = int(periods_until_date) + int( + triggers_first_transaction + ) + + balance += periods_until_date * float(transaction.amount) + + return balance + + @cached_property + def zero_balance_projection(self): + """ + Returns the date at which the balance will be zero. Returns None if the balance + will never be zero, e.g. if the balance is positive and no future transactions. + """ + + if not self.future_transactions: + return None + + future_transactions = self.future_transactions.values_list( + "period", "date_from", "amount" + ).order_by("date_from") + + # Define linearly changing daily rate ranges by aggregating the future transactions + # The date specifies the start of the period for which the rate is valid until the next entry + daily_change: dict[date, float] = {} + for period, transition_start, amount in future_transactions: + daily_rate = amount / period.days + + if transition_start not in daily_change: + daily_change[transition_start] = 0 + daily_change[transition_start] += float(daily_rate) + + balance = float(self.balance.amount if self.balance else 0) + + start_date = datetime.date.today() + for (rate_start, rate), rate_end in zip( + daily_change.items(), list(daily_change.keys())[1:] + [datetime.date.max] + ): + # Clip the start date to when transactions start if it is before + if start_date < rate_start: + start_date = rate_start + + # A root exists only if the daily rate is negative + if rate < 0: + zero_date = start_date + datetime.timedelta(days=balance / -rate) + + # Return the first root if it is before the next change in daily rate + if zero_date < rate_end: + return zero_date + + balance += rate * (rate_end - start_date).days + start_date = rate_start diff --git a/scipost_django/finances/models/balance.py b/scipost_django/finances/models/balance.py new file mode 100644 index 000000000..c77a1b4bf --- /dev/null +++ b/scipost_django/finances/models/balance.py @@ -0,0 +1,22 @@ +__copyright__ = "Copyright © Stichting SciPost (SciPost Foundation)" +__license__ = "AGPL v3" + +from django.db import models + + +class Balance(models.Model): + + account = models.ForeignKey( + "Account", on_delete=models.CASCADE, related_name="balance_entries" + ) + date = models.DateField() + amount = models.DecimalField(max_digits=12, decimal_places=2) + + class Meta: + constraints = [ + models.UniqueConstraint(fields=["account", "date"], name="unique_balance") + ] + ordering = ["-date"] + + def __str__(self): + return f"{self.account} at EUR {self.amount} on {self.date}" diff --git a/scipost_django/finances/models/transaction.py b/scipost_django/finances/models/transaction.py new file mode 100644 index 000000000..7b459e2e8 --- /dev/null +++ b/scipost_django/finances/models/transaction.py @@ -0,0 +1,40 @@ +__copyright__ = "Copyright © Stichting SciPost (SciPost Foundation)" +__license__ = "AGPL v3" + +from django.db import models + +from finances.models import Account + + +class Transaction(models.Model): + + account = models.ForeignKey[Account](Account, on_delete=models.CASCADE) + amount = models.DecimalField(max_digits=10, decimal_places=2) + datetime = models.DateTimeField() + + class Meta: + ordering = ["-datetime"] + default_related_name = "transactions" + constraints = [ + models.UniqueConstraint( + fields=["account", "datetime"], name="unique_transaction" + ) + ] + + +class FuturePeriodicTransaction(models.Model): + """ + Represents a future transaction (expense or income) that we expect to happen + in the future at some frequency. This is used to predict future balances. + """ + + account = models.ForeignKey[Account](Account, on_delete=models.CASCADE) + amount = models.DecimalField(max_digits=10, decimal_places=2) + date_from = models.DateField() + period = models.DurationField() + name = models.CharField(max_length=128) + description = models.TextField(blank=True) + + class Meta: + ordering = ["-date_from"] + default_related_name = "future_transactions" -- GitLab