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): """ 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, '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()