You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
kompass/jdav_web/finance/models.py

625 lines
24 KiB
Python

import math
from itertools import groupby
from decimal import Decimal, ROUND_HALF_DOWN
from django.utils import timezone
from .rules import is_creator, not_submitted, leads_excursion
from members.rules import is_leader, statement_not_submitted
from django.core.exceptions import ValidationError
from django.db import models
from django.db.models import Sum
from django.utils.translation import gettext_lazy as _
from members.models import Member, Freizeit, OEFFENTLICHE_ANREISE, MUSKELKRAFT_ANREISE
from django.conf import settings
import rules
from contrib.models import CommonModel
from contrib.rules import has_global_perm
from utils import cvt_to_decimal, RestrictedFileField
from schwifty import IBAN
import re
# Create your models here.
class Ledger(models.Model):
name = models.CharField(verbose_name=_('Name'), max_length=30)
def __str__(self):
return self.name
class Meta:
verbose_name = _('Ledger')
verbose_name_plural = _('Ledgers')
class TransactionIssue:
def __init__(self, member, current, target):
self.member, self.current, self.target = member, current, target
@property
def difference(self):
return self.target - self.current
class StatementManager(models.Manager):
def get_queryset(self):
return super().get_queryset().filter(submitted=False, confirmed=False)
class Statement(CommonModel):
MISSING_LEDGER, NON_MATCHING_TRANSACTIONS, INVALID_ALLOWANCE_TO, INVALID_TOTAL, VALID = 0, 1, 2, 3, 4
short_description = models.CharField(verbose_name=_('Short description'),
max_length=30,
blank=True)
explanation = models.TextField(verbose_name=_('Explanation'), blank=True)
excursion = models.OneToOneField(Freizeit, verbose_name=_('Associated excursion'),
blank=True,
null=True,
on_delete=models.SET_NULL)
allowance_to = models.ManyToManyField(Member, verbose_name=_('Pay allowance to'),
related_name='receives_allowance_for_statements',
blank=True,
help_text=_('The youth leaders to which an allowance should be paid.'))
subsidy_to = models.ForeignKey(Member, verbose_name=_('Pay subsidy to'),
null=True,
blank=True,
on_delete=models.SET_NULL,
related_name='receives_subsidy_for_statements',
help_text=_('The person that should receive the subsidy for night and travel costs. Typically the person who paid for them.'))
night_cost = models.DecimalField(verbose_name=_('Price per night'), default=0, decimal_places=2, max_digits=5)
submitted = models.BooleanField(verbose_name=_('Submitted'), default=False)
submitted_date = models.DateTimeField(verbose_name=_('Submitted on'), default=None, null=True)
confirmed = models.BooleanField(verbose_name=_('Confirmed'), default=False)
confirmed_date = models.DateTimeField(verbose_name=_('Paid on'), default=None, null=True)
created_by = models.ForeignKey(Member, verbose_name=_('Created by'),
blank=True,
null=True,
on_delete=models.SET_NULL,
related_name='created_statements')
submitted_by = models.ForeignKey(Member, verbose_name=_('Submitted by'),
blank=True,
null=True,
on_delete=models.SET_NULL,
related_name='submitted_statements')
confirmed_by = models.ForeignKey(Member, verbose_name=_('Authorized by'),
blank=True,
null=True,
on_delete=models.SET_NULL,
related_name='confirmed_statements')
class Meta(CommonModel.Meta):
verbose_name = _('Statement')
verbose_name_plural = _('Statements')
permissions = [
('may_edit_submitted_statements', 'Is allowed to edit submitted statements')
]
rules_permissions = {
# this is suboptimal, but Statement is only ever used as an inline on Freizeit
# so we check for excursion permissions
'add_obj': is_leader,
'view_obj': is_leader | has_global_perm('members.view_global_freizeit'),
'change_obj': is_leader & statement_not_submitted,
'delete_obj': is_leader & statement_not_submitted,
}
def __str__(self):
if self.excursion is not None:
return _('Statement: %(excursion)s') % {'excursion': str(self.excursion)}
else:
return self.short_description
def submit(self, submitter=None):
self.submitted = True
self.submitted_date = timezone.now()
self.submitted_by = submitter
self.save()
@property
def transaction_issues(self):
needed_paiments = [(b.paid_by, b.amount) for b in self.bill_set.all() if b.costs_covered and b.paid_by]
if self.excursion is not None:
needed_paiments.extend([(yl, self.allowance_per_yl) for yl in self.allowance_to.all()])
if self.subsidy_to:
needed_paiments.append((self.subsidy_to, self.total_subsidies))
needed_paiments = sorted(needed_paiments, key=lambda p: p[0].pk)
target = dict(map(lambda p: (p[0], sum([x[1] for x in p[1]])), groupby(needed_paiments, lambda p: p[0])))
transactions = sorted(self.transaction_set.all(), key=lambda trans: trans.member.pk)
current = dict(map(lambda p: (p[0], sum([t.amount for t in p[1]])), groupby(transactions, lambda trans: trans.member)))
issues = []
for member, amount in target.items():
if amount == 0 and member not in current:
continue
elif member not in current:
issue = TransactionIssue(member=member, current=0, target=amount)
issues.append(issue)
elif current[member] != amount:
issue = TransactionIssue(member=member, current=current[member], target=amount)
issues.append(issue)
for member, amount in current.items():
if amount != 0 and member not in target:
issue = TransactionIssue(member=member, current=amount, target=0)
issues.append(issue)
return issues
@property
def ledgers_configured(self):
return all([trans.ledger is not None for trans in self.transaction_set.all()])
@property
def transactions_match_expenses(self):
return len(self.transaction_issues) == 0
@property
def allowance_to_valid(self):
"""Checks if the configured `allowance_to` field matches the regulations."""
if self.allowances_paid > self.real_staff_count:
# it is allowed that less allowances are utilized than youth leaders are enlisted
return False
if self.excursion is not None:
yls = self.excursion.jugendleiter.all()
for yl in self.allowance_to.all():
if yl not in yls:
return False
return True
@property
def total_valid(self):
"""Checks if the calculated total agrees with the total amount of all transactions."""
total_transactions = 0
for transaction in self.transaction_set.all():
total_transactions += transaction.amount
return self.total == total_transactions
@property
def validity(self):
if not self.transactions_match_expenses:
return Statement.NON_MATCHING_TRANSACTIONS
if not self.ledgers_configured:
return Statement.MISSING_LEDGER
if not self.allowance_to_valid:
return Statement.INVALID_ALLOWANCE_TO
if not self.total_valid:
return Statement.INVALID_TOTAL
else:
return Statement.VALID
def is_valid(self):
return self.validity == Statement.VALID
is_valid.boolean = True
is_valid.short_description = _('Ready to confirm')
def confirm(self, confirmer=None):
if not self.submitted:
return False
if not self.validity == Statement.VALID:
return False
self.confirmed = True
self.confirmed_date = timezone.now()
self.confirmed_by = confirmer
for trans in self.transaction_set.all():
trans.confirmed = True
trans.confirmed_date = timezone.now()
trans.confirmed_by = confirmer
trans.save()
self.save()
return True
def generate_transactions(self):
# bills
for bill in self.bill_set.all():
if not bill.costs_covered:
continue
if not bill.paid_by:
return False
ref = "{}: {}".format(str(self), bill.short_description)
Transaction(statement=self, member=bill.paid_by, amount=bill.amount, confirmed=False, reference=ref).save()
# excursion specific
if self.excursion is None:
return True
# allowance
for yl in self.allowance_to.all():
ref = _("Allowance for %(excu)s") % {'excu': self.excursion.name}
Transaction(statement=self, member=yl, amount=self.allowance_per_yl, confirmed=False, reference=ref).save()
# subsidies (i.e. night and transportation costs)
if self.subsidy_to:
ref = _("Night and travel costs for %(excu)s") % {'excu': self.excursion.name}
Transaction(statement=self, member=self.subsidy_to, amount=self.total_subsidies, confirmed=False, reference=ref).save()
return True
def reduce_transactions(self):
# to minimize the number of needed bank transactions, we bundle transactions from same ledger to
# same member
transactions = self.transaction_set.all()
if any((t.ledger is None for t in transactions)):
return
sort_key = lambda trans: (trans.member.pk, trans.ledger.pk)
group_key = lambda trans: (trans.member, trans.ledger)
transactions = sorted(transactions, key=sort_key)
for pair, transaction_group in groupby(transactions, group_key):
member, ledger = pair
grp = list(transaction_group)
if len(grp) == 1:
continue
new_amount = sum((trans.amount for trans in grp))
new_ref = ", ".join((f"{trans.reference} EUR{trans.amount: .2f}" for trans in grp))
Transaction(statement=self, member=member, amount=new_amount, confirmed=False, reference=new_ref,
ledger=ledger).save()
for trans in grp:
trans.delete()
@property
def total_bills(self):
return sum([bill.amount for bill in self.bill_set.all() if bill.costs_covered])
@property
def total_bills_theoretic(self):
return sum([bill.amount for bill in self.bill_set.all()])
@property
def euro_per_km(self):
if self.excursion is None:
return 0
if self.excursion.tour_approach == MUSKELKRAFT_ANREISE \
or self.excursion.tour_approach == OEFFENTLICHE_ANREISE:
return 0.15
else:
return 0.1
@property
def transportation_per_yl(self):
if self.excursion is None:
return 0
return cvt_to_decimal(self.excursion.kilometers_traveled * self.euro_per_km)
@property
def allowance_per_yl(self):
if self.excursion is None:
return 0
return cvt_to_decimal(self.excursion.duration * settings.ALLOWANCE_PER_DAY)
@property
def allowances_paid(self):
return self.allowance_to.count()
@property
def total_allowance(self):
return self.allowance_per_yl * self.allowances_paid
@property
def total_transportation(self):
return self.transportation_per_yl * self.real_staff_count
@property
def real_night_cost(self):
return min(self.night_cost, settings.MAX_NIGHT_COST)
@property
def nights_per_yl(self):
if self.excursion is None:
return 0
return self.excursion.night_count * self.real_night_cost
@property
def total_nights(self):
return self.nights_per_yl * self.real_staff_count
@property
def total_per_yl(self):
return self.transportation_per_yl \
+ self.allowance_per_yl \
+ self.nights_per_yl
@property
def real_per_yl(self):
if self.excursion is None:
return 0
return cvt_to_decimal(self.total_staff / self.excursion.staff_count)
@property
def total_subsidies(self):
"""
The total amount of subsidies excluding the allowance, i.e. the transportation
and night costs per youth leader multiplied with the real number of youth leaders.
"""
if self.subsidy_to:
return (self.transportation_per_yl + self.nights_per_yl) * self.real_staff_count
else:
return cvt_to_decimal(0)
@property
def theoretical_total_staff(self):
"""
the sum of subsidies and allowances if all eligible youth leaders would collect them.
"""
return self.total_per_yl * self.real_staff_count
@property
def total_staff(self):
"""
the sum of subsidies and allowances that youth leaders are actually collecting
"""
return self.total_allowance + self.total_subsidies
@property
def real_staff_count(self):
if self.excursion is None:
return 0
return min(self.excursion.staff_count, self.admissible_staff_count)
@property
def admissible_staff_count(self):
"""An excursion can have as many youth leaders as the max bound on integers allows. Not all youth leaders
are refinanced though."""
if self.excursion is None:
return 0
else:
return self.excursion.approved_staff_count
@property
def total(self):
return self.total_bills + self.total_staff
@property
def total_theoretic(self):
"""
The theoretic total used in SJR and LJP applications. This is the sum of all
bills (ignoring whether they are paid by the association or not) plus the
total allowance. This does not include the subsidies for night and travel costs,
since they are expected to be included in the bills.
"""
return self.total_bills_theoretic + self.total_allowance
def total_pretty(self):
return "{}".format(self.total)
total_pretty.short_description = _('Total')
def template_context(self):
context = {
'total_bills': self.total_bills,
'total_bills_theoretic': self.total_bills_theoretic,
'total': self.total,
}
if self.excursion:
excursion_context = {
'nights': self.excursion.night_count,
'price_per_night': self.real_night_cost,
'duration': self.excursion.duration,
'staff_count': self.real_staff_count,
'kilometers_traveled': self.excursion.kilometers_traveled,
'means_of_transport': self.excursion.get_tour_approach(),
'euro_per_km': self.euro_per_km,
'allowance_per_day': settings.ALLOWANCE_PER_DAY,
'allowances_paid': self.allowances_paid,
'nights_per_yl': self.nights_per_yl,
'allowance_per_yl': self.allowance_per_yl,
'transportation_per_yl': self.transportation_per_yl,
'total_per_yl': self.total_per_yl,
'total_staff': self.total_staff,
'theoretical_total_staff': self.theoretical_total_staff,
'real_staff_count': self.real_staff_count,
'total_subsidies': self.total_subsidies,
}
return dict(context, **excursion_context)
else:
return context
def grouped_bills(self):
return self.bill_set.values('short_description')\
.order_by('short_description')\
.annotate(amount=Sum('amount'))
class StatementUnSubmittedManager(models.Manager):
def get_queryset(self):
return super().get_queryset().filter(submitted=False, confirmed=False)
class StatementUnSubmitted(Statement):
objects = StatementUnSubmittedManager()
class Meta(CommonModel.Meta):
proxy = True
verbose_name = _('Statement in preparation')
verbose_name_plural = _('Statements in preparation')
rules_permissions = {
'add_obj': rules.is_staff,
'view_obj': is_creator | leads_excursion | has_global_perm('finance.view_global_statementunsubmitted'),
'change_obj': is_creator | leads_excursion,
'delete_obj': is_creator | leads_excursion,
}
class StatementSubmittedManager(models.Manager):
def get_queryset(self):
return super().get_queryset().filter(submitted=True, confirmed=False)
class StatementSubmitted(Statement):
objects = StatementSubmittedManager()
class Meta(CommonModel.Meta):
proxy = True
verbose_name = _('Submitted statement')
verbose_name_plural = _('Submitted statements')
permissions = [
('process_statementsubmitted', 'Can manage submitted statements.'),
]
class StatementConfirmedManager(models.Manager):
def get_queryset(self):
return super().get_queryset().filter(confirmed=True)
class StatementConfirmed(Statement):
objects = StatementConfirmedManager()
class Meta(CommonModel.Meta):
proxy = True
verbose_name = _('Paid statement')
verbose_name_plural = _('Paid statements')
permissions = [
('may_manage_confirmed_statements', 'Can view and manage confirmed statements.'),
]
class Bill(CommonModel):
statement = models.ForeignKey(Statement, verbose_name=_('Statement'), on_delete=models.CASCADE)
short_description = models.CharField(verbose_name=_('Short description'), max_length=30)
explanation = models.TextField(verbose_name=_('Explanation'), blank=True)
amount = models.DecimalField(verbose_name=_('Amount'), max_digits=6, decimal_places=2, default=0)
paid_by = models.ForeignKey(Member, verbose_name=_('Paid by'), null=True,
on_delete=models.SET_NULL)
costs_covered = models.BooleanField(verbose_name=_('Covered'), default=False)
refunded = models.BooleanField(verbose_name=_('Refunded'), default=False)
proof = RestrictedFileField(verbose_name=_('Proof'),
upload_to='bill_images',
blank=True,
max_upload_size=5,
content_types=['application/pdf',
'image/jpeg',
'image/png',
'image/gif'])
def __str__(self):
return "{} ({}€)".format(self.short_description, self.amount)
def pretty_amount(self):
return "{}".format(self.amount)
pretty_amount.admin_order_field = 'amount'
pretty_amount.short_description = _('Amount')
class Meta(CommonModel.Meta):
verbose_name = _('Bill')
verbose_name_plural = _('Bills')
class BillOnExcursionProxy(Bill):
class Meta(CommonModel.Meta):
proxy = True
verbose_name = _('Bill')
verbose_name_plural = _('Bills')
rules_permissions = {
'add_obj': leads_excursion & not_submitted,
'view_obj': leads_excursion | has_global_perm('finance.view_global_billonexcursionproxy'),
'change_obj': (leads_excursion | has_global_perm('finance.change_global_billonexcursionproxy')) & not_submitted,
'delete_obj': (leads_excursion | has_global_perm('finance.delete_global_billonexcursionproxy')) & not_submitted,
}
class BillOnStatementProxy(Bill):
class Meta(CommonModel.Meta):
proxy = True
verbose_name = _('Bill')
verbose_name_plural = _('Bills')
rules_permissions = {
'add_obj': (is_creator | leads_excursion) & not_submitted,
'view_obj': is_creator | leads_excursion | has_global_perm('finance.view_global_billonstatementproxy'),
'change_obj': (is_creator | leads_excursion | has_global_perm('finance.change_global_billonstatementproxy'))
& (not_submitted | has_global_perm('finance.process_statementsubmitted')),
'delete_obj': (is_creator | leads_excursion | has_global_perm('finance.delete_global_billonstatementproxy'))
& not_submitted,
}
class Transaction(models.Model):
reference = models.TextField(verbose_name=_('Reference'))
amount = models.DecimalField(max_digits=6, decimal_places=2, verbose_name=_('Amount'))
member = models.ForeignKey(Member, verbose_name=_('Recipient'),
on_delete=models.CASCADE)
ledger = models.ForeignKey(Ledger, blank=False, null=True, default=None, verbose_name=_('Ledger'),
on_delete=models.SET_NULL)
statement = models.ForeignKey(Statement, verbose_name=_('Statement'),
on_delete=models.CASCADE)
confirmed = models.BooleanField(verbose_name=_('Paid'), default=False)
confirmed_date = models.DateTimeField(verbose_name=_('Paid on'), default=None, null=True)
confirmed_by = models.ForeignKey(Member, verbose_name=_('Authorized by'),
blank=True,
null=True,
on_delete=models.SET_NULL,
related_name='confirmed_transactions')
def __str__(self):
return "T#{}".format(self.pk)
@staticmethod
def escape_reference(reference):
umlaut_map = {
'ä': 'ae', 'ö': 'oe', 'ü': 'ue',
'Ä': 'Ae', 'Ö': 'Oe', 'Ü': 'Ue',
'ß': 'ss'
}
pattern = re.compile('|'.join(umlaut_map.keys()))
int_reference = pattern.sub(lambda x: umlaut_map[x.group()], reference)
allowed_chars = r"[^a-z0-9 /?: .,'+-]"
clean_reference = re.sub(allowed_chars, '', int_reference, flags=re.IGNORECASE)
return clean_reference
def code(self):
if self.amount == 0:
return ""
iban = IBAN(self.member.iban, allow_invalid=True)
if not iban.is_valid:
return ""
bic = iban.bic
reference = self.escape_reference(self.reference)
# also escaping receiver as umlaute are also not allowed here
receiver = self.escape_reference(f"{self.member.prename} {self.member.lastname}")
return f"""BCD
001
1
SCT
{bic}
{receiver}
{iban}
EUR{self.amount}
{reference}"""
class Meta:
verbose_name = _('Transaction')
verbose_name_plural = _('Transactions')
class Receipt(models.Model):
short_description = models.CharField(verbose_name=_('Short description'), max_length=30)
ledger = models.ForeignKey(Ledger, blank=False, null=False, verbose_name=_('Ledger'),
on_delete=models.CASCADE)
amount = models.DecimalField(max_digits=6, decimal_places=2)
comments = models.TextField()