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. The count must match the number of permitted youth leaders.')) subsidy_to = models.ForeignKey(Member, verbose_name=_('Pay subsidy to'), null=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.allowance_to.count() != self.real_staff_count: 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) 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 = "\n".join((trans.reference 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 total_allowance(self): return self.allowance_per_yl * self.real_staff_count @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. """ return (self.transportation_per_yl + self.nights_per_yl) * self.real_staff_count @property def total_staff(self): return self.total_per_yl * self.real_staff_count @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): return self.total_bills_theoretic + self.total_staff 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, '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, '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()