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