SciPost Code Repository

Skip to content
Snippets Groups Projects
Commit dfbf6a5d authored by George Katsikas's avatar George Katsikas :goat:
Browse files

add bank-mirror finance models

parent 29d47168
No related branches found
No related tags found
No related merge requests found
...@@ -4,6 +4,10 @@ __license__ = "AGPL v3" ...@@ -4,6 +4,10 @@ __license__ = "AGPL v3"
from django.contrib import admin 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 ( from .models import (
Subsidy, Subsidy,
SubsidyPayment, SubsidyPayment,
...@@ -160,3 +164,23 @@ class WorkLogAdmin(admin.ModelAdmin): ...@@ -160,3 +164,23 @@ class WorkLogAdmin(admin.ModelAdmin):
admin.site.register(PeriodicReportType) admin.site.register(PeriodicReportType)
admin.site.register(PeriodicReport) 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,
]
# 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"
),
),
]
...@@ -20,3 +20,9 @@ from .subsidy_attachment import ( ...@@ -20,3 +20,9 @@ from .subsidy_attachment import (
) )
from .work_log import WorkLog from .work_log import WorkLog
from .account import Account
from .balance import Balance
from .transaction import Transaction, FuturePeriodicTransaction
__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
__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}"
__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"
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment