From a8d46257191e2183b40ba09f9271383e801ceb74 Mon Sep 17 00:00:00 2001 From: "marius.klein" Date: Thu, 24 Jul 2025 22:55:39 +0200 Subject: [PATCH] feat(finance/tests): tests for new rules (#155) Also makes some checks safe. Reviewed-on: https://git.jdav-hd.merten.dev/digitales/kompass/pulls/155 Reviewed-by: Christian Merten Co-authored-by: marius.klein Co-committed-by: marius.klein --- jdav_web/finance/models.py | 5 ++ jdav_web/finance/tests.py | 113 ++++++++++++++++++++++++++++++++++++- jdav_web/members/models.py | 13 ++++- 3 files changed, 127 insertions(+), 4 deletions(-) diff --git a/jdav_web/finance/models.py b/jdav_web/finance/models.py index c09cf22..b9c8a7a 100644 --- a/jdav_web/finance/models.py +++ b/jdav_web/finance/models.py @@ -405,6 +405,8 @@ class Statement(CommonModel): @property def org_fee_payant(self): + if self.total_org_fee == 0: + return None return self.subsidy_to if self.subsidy_to else self.allowance_to.all()[0] @property @@ -466,8 +468,11 @@ class Statement(CommonModel): return cvt_to_decimal( min( + # if total costs are more than the max amount of the LJP contribution, we pay the max amount, reduced by taxes (1-settings.LJP_TAX) * settings.LJP_CONTRIBUTION_PER_DAY * self.excursion.ljp_participant_count * self.excursion.ljp_duration, + # if the total costs are less than the max amount, we pay up to 90% of the total costs, reduced by taxes (1-settings.LJP_TAX) * 0.9 * (float(self.total_bills_not_covered) + float(self.total_staff) ), + # we never pay more than the maximum costs of the trip float(self.total_bills_not_covered) ) ) diff --git a/jdav_web/finance/tests.py b/jdav_web/finance/tests.py index 9e04c97..949b124 100644 --- a/jdav_web/finance/tests.py +++ b/jdav_web/finance/tests.py @@ -5,8 +5,9 @@ from django.conf import settings from .models import Statement, StatementUnSubmitted, StatementSubmitted, Bill, Ledger, Transaction,\ StatementUnSubmittedManager, StatementSubmittedManager, StatementConfirmedManager,\ StatementConfirmed, TransactionIssue, StatementManager -from members.models import Member, Group, Freizeit, GEMEINSCHAFTS_TOUR, MUSKELKRAFT_ANREISE, NewMemberOnList,\ +from members.models import Member, Group, Freizeit, LJPProposal, Intervention, GEMEINSCHAFTS_TOUR, MUSKELKRAFT_ANREISE, NewMemberOnList,\ FAHRGEMEINSCHAFT_ANREISE, MALE, FEMALE, DIVERSE +from dateutil.relativedelta import relativedelta # Create your tests here. class StatementTestCase(TestCase): @@ -66,6 +67,116 @@ class StatementTestCase(TestCase): email=settings.TEST_MAIL, gender=DIVERSE) mol = NewMemberOnList.objects.create(member=m, memberlist=ex) ex.membersonlist.add(mol) + + base = timezone.now() + ex = Freizeit.objects.create(name='Wild trip with old people', kilometers_traveled=self.kilometers_traveled, + tour_type=GEMEINSCHAFTS_TOUR, + tour_approach=MUSKELKRAFT_ANREISE, + difficulty=2, date=timezone.datetime(2024, 1, 2, 8, 0, 0, tzinfo=base.tzinfo), end=timezone.datetime(2024, 1, 5, 17, 0, 0, tzinfo=base.tzinfo) ) + + settings.EXCURSION_ORG_FEE = 20 + settings.LJP_TAX = 0.2 + settings.LJP_CONTRIBUTION_PER_DAY = 20 + + self.st5 = Statement.objects.create(night_cost=self.night_cost, excursion=ex) + + for i in range(9): + m = Member.objects.create(prename='Peter {}'.format(i), lastname='Walter', birth_date=timezone.now().date() - relativedelta(years=i+21), + email=settings.TEST_MAIL, gender=DIVERSE) + mol = NewMemberOnList.objects.create(member=m, memberlist=ex) + ex.membersonlist.add(mol) + + ljpproposal = LJPProposal.objects.create( + title='Test proposal', + category=LJPProposal.LJP_STAFF_TRAINING, + goal=LJPProposal.LJP_ENVIRONMENT, + goal_strategy='my strategy', + not_bw_reason=LJPProposal.NOT_BW_ROOMS, + excursion=self.st5.excursion) + + for i in range(3): + int = Intervention.objects.create( + date_start=timezone.datetime(2024, 1, 2+i, 12, 0, 0, tzinfo=base.tzinfo), + duration = 2+i, + activity = 'hi', + ljp_proposal=ljpproposal + ) + + self.b1 = Bill.objects.create( + statement=self.st5, + short_description='covered bill', + explanation='hi', + amount='300', + paid_by=self.fritz, + costs_covered=True, + refunded=False + ) + + self.b2 = Bill.objects.create( + statement=self.st5, + short_description='non-covered bill', + explanation='hi', + amount='900', + paid_by=self.fritz, + costs_covered=False, + refunded=False + ) + + def test_org_fee(self): + # org fee should be collected if participants are older than 26 + self.assertEqual(self.st5.excursion.old_participant_count, 3, 'Calculation of number of old people in excursion is incorrect.') + + total_org = 4 * 3 * 20 # 4 days, 3 old people, 20€ per day + + self.assertEqual(self.st5.total_org_fee_theoretical, total_org, 'Theoretical org_fee should equal to amount per day per person * n_persons * n_days if there are old people.') + self.assertEqual(self.st5.total_org_fee, 0, 'Paid org fee should be 0 if no allowance and subsidies are paid if there are old people.') + + self.assertIsNone(self.st5.org_fee_payant) + + # now collect subsidies + self.st5.subsidy_to = self.fritz + self.assertEqual(self.st5.total_org_fee, total_org, 'Paid org fee should equal to amount per day per person * n_persons * n_days if subsidies are paid.') + + # now collect allowances + self.st5.allowance_to.add(self.fritz) + self.st5.subsidy_to = None + self.assertEqual(self.st5.total_org_fee, total_org, 'Paid org fee should equal to amount per day per person * n_persons * n_days if allowances are paid.') + + # now collect both + self.st5.subsidy_to = self.fritz + self.assertEqual(self.st5.total_org_fee, total_org, 'Paid org fee should equal to amount per day per person * n_persons * n_days if subsidies and allowances are paid.') + + self.assertEqual(self.st5.org_fee_payant, self.fritz, 'Org fee payant should be the receiver allowances and subsidies.') + + # return to previous state + self.st5.subsidy_to = None + self.st5.allowance_to.remove(self.fritz) + + + def test_ljp_payment(self): + + expected_intervention_hours = 2 + 3 + 4 + expected_seminar_days = 0 + 0.5 + 0.5 # >=2.5h = 0.5days, >=5h = 1.0day + expected_ljp = (1-settings.LJP_TAX) * expected_seminar_days * settings.LJP_CONTRIBUTION_PER_DAY * 9 + # (1 - 20% tax) * 1 seminar day * 20€ * 9 participants + + self.assertEqual(self.st5.excursion.total_intervention_hours, expected_intervention_hours, 'Calculation of total intervention hours is incorrect.') + self.assertEqual(self.st5.excursion.total_seminar_days, expected_seminar_days, 'Calculation of total seminar days is incorrect.') + + self.assertEqual(self.st5.paid_ljp_contributions, 0, 'No LJP contributions should be paid if no receiver is set.') + + # now we want to pay out the LJP contributions + self.st5.ljp_to = self.fritz + self.assertEqual(self.st5.paid_ljp_contributions, expected_ljp, 'LJP contributions should be paid if a receiver is set.') + + # now the total costs paid by trip organisers is lower than expected ljp contributions, should be reduced automatically + self.b2.amount=100 + self.b2.save() + + self.assertEqual(self.st5.total_bills_not_covered, 100, 'Changes in bills should be reflected in the total costs paid by trip organisers') + self.assertGreaterEqual(self.st5.total_bills_not_covered, self.st5.paid_ljp_contributions, 'LJP contributions should be less than or equal to the costs paid by trip organisers') + + self.st5.ljp_to = None def test_staff_count(self): self.assertEqual(self.st4.admissible_staff_count, 0, diff --git a/jdav_web/members/models.py b/jdav_web/members/models.py index 1700f2b..a5377e3 100644 --- a/jdav_web/members/models.py +++ b/jdav_web/members/models.py @@ -1423,23 +1423,30 @@ class Freizeit(CommonModel): @property def maximal_ljp_contributions(self): + """This is the maximal amount of LJP contributions that can be requested given participants and length + This calculation if intended for the LJP application, not for the payout.""" return cvt_to_decimal(settings.LJP_CONTRIBUTION_PER_DAY * self.ljp_participant_count * self.duration) @property def potential_ljp_contributions(self): + """The maximal amount can be reduced if the actual costs are lower than the maximal amount + This calculation if intended for the LJP application, not for the payout.""" + if not hasattr(self, 'statement'): + return cvt_to_decimal(0) return cvt_to_decimal(min(self.maximal_ljp_contributions, 0.9 * float(self.statement.total_bills_theoretic) + float(self.statement.total_staff))) @property def payable_ljp_contributions(self): - """from the requested ljp contributions, a tax may be deducted for risk reduction""" - if self.statement.ljp_to: + """the payable contributions can differ from potential contributions if a tax is deducted for risk reduction. + the actual payout depends on more factors, e.g. the actual costs that had to be paid by the trip organisers.""" + if hasattr(self, 'statement') and self.statement.ljp_to: return self.statement.paid_ljp_contributions return cvt_to_decimal(self.potential_ljp_contributions * cvt_to_decimal(1 - settings.LJP_TAX)) @property def total_relative_costs(self): - if not self.statement: + if not hasattr(self, 'statement'): return 0 total_costs = self.statement.total_bills_theoretic total_contributions = self.statement.total_subsidies + self.payable_ljp_contributions