From f9822b43029e1f9b5846ae85e2dbd2fc21e10fab Mon Sep 17 00:00:00 2001 From: rikuke <33894149+rikuke@users.noreply.github.com> Date: Wed, 30 Oct 2024 15:28:04 +0200 Subject: [PATCH] feat: create instalments when calculating benefit (#3479) --- backend/benefit/calculator/models.py | 6 ++ backend/benefit/calculator/rules.py | 47 +++++++++++++- .../calculator/tests/test_calculator_api.py | 64 ++++++++++++++++++- backend/benefit/helsinkibenefit/settings.py | 2 + 4 files changed, 117 insertions(+), 2 deletions(-) diff --git a/backend/benefit/calculator/models.py b/backend/benefit/calculator/models.py index f3c76e2d22..af3aca8e1b 100644 --- a/backend/benefit/calculator/models.py +++ b/backend/benefit/calculator/models.py @@ -864,6 +864,12 @@ class Instalment(UUIDModel, TimeStampedModel): blank=True, ) + def __str__(self): + return f"Instalment of {self.amount}€, \ +number {self.instalment_number}/{self.calculation.instalments.count()} \ +for application {self.calculation.application.application_number}, \ +due in {self.due_date}, status: {self.status}." + class Meta: db_table = "bf_calculator_instalment" verbose_name = _("instalment") diff --git a/backend/benefit/calculator/rules.py b/backend/benefit/calculator/rules.py index 21e90b6371..fd44b96bb5 100644 --- a/backend/benefit/calculator/rules.py +++ b/backend/benefit/calculator/rules.py @@ -6,6 +6,7 @@ from django.conf import settings from django.db import transaction +from django.utils import timezone from applications.enums import ApplicationStatus, BenefitType, PaySubsidyGranted from calculator.enums import DescriptionType, RowType @@ -16,6 +17,7 @@ DescriptionRow, EmployeeBenefitMonthlyRow, EmployeeBenefitTotalRow, + Instalment, ManualOverrideTotalRow, PaySubsidy, PaySubsidyMonthlyRow, @@ -41,6 +43,7 @@ class HelsinkiBenefitCalculator: def __init__(self, calculation: Calculation): self.calculation = calculation self._row_counter = 0 + self.instalment_threshold = settings.INSTALMENT_THRESHOLD def _get_change_days( self, pay_subsidies, training_compensations, start_date, end_date @@ -156,6 +159,45 @@ def can_calculate(self): return False return True + def create_instalments(self, total_benefit_amount: decimal.Decimal) -> None: + """Create one instalment in the usual case, + two instalments if the benefit amount exceeds the threshold, + with the second instalment due 6 months later. + """ + instalments = self._calculate_instalment_amounts(total_benefit_amount) + self._create_instalments(instalments) + + def _calculate_instalment_amounts( + self, total_benefit_amount: decimal.Decimal + ) -> list[tuple[int, decimal.Decimal, datetime.datetime]]: + """Calculate the number of instalments and their amounts based on the total benefit. + Returns a list of tuples containing (instalment_number, amount, due_date). + """ + if total_benefit_amount <= self.instalment_threshold: + return [(1, total_benefit_amount, timezone.now())] + + second_instalment_amount = total_benefit_amount - self.instalment_threshold + return [ + (1, self.instalment_threshold, timezone.now()), + ( + 2, + second_instalment_amount, + timezone.now() + datetime.timedelta(days=181), + ), + ] + + def _create_instalments( + self, instalments: list[tuple[int, decimal.Decimal, datetime.datetime]] + ) -> None: + """Create instalment objects from the provided instalment data.""" + for instalment_number, amount, due_date in instalments: + Instalment.objects.create( + calculation=self.calculation, + instalment_number=instalment_number, + amount=amount, + due_date=due_date, + ) + @transaction.atomic def calculate(self, override_status=False): if ( @@ -163,12 +205,15 @@ def calculate(self, override_status=False): or override_status ): self.calculation.rows.all().delete() + self.calculation.instalments.all().delete() if self.can_calculate(): self.create_rows() # the total benefit amount is stored in Calculation model, for easier processing. - self.calculation.calculated_benefit_amount = self.get_amount( + total_benefit_amount = self.get_amount( RowType.HELSINKI_BENEFIT_TOTAL_EUR ) + self.calculation.calculated_benefit_amount = total_benefit_amount + self.create_instalments(total_benefit_amount) else: self.calculation.calculated_benefit_amount = None self.calculation.save() diff --git a/backend/benefit/calculator/tests/test_calculator_api.py b/backend/benefit/calculator/tests/test_calculator_api.py index 2d0e6cd8fe..a83fadb885 100644 --- a/backend/benefit/calculator/tests/test_calculator_api.py +++ b/backend/benefit/calculator/tests/test_calculator_api.py @@ -5,6 +5,7 @@ import factory import pytest +from django.utils import timezone from applications.api.v1.serializers.application import ( ApplicantApplicationSerializer, @@ -99,7 +100,10 @@ def test_application_try_retrieve_calculation_as_applicant(api_client, applicati def test_application_create_calculation_on_submit( - request, api_client, handler_api_client, application + request, + api_client, + handler_api_client, + application, ): # also test that calculation rows are not created yet, # as all fields are not filled yet. @@ -691,3 +695,61 @@ def test_application_calculation_rows_id_exists( assert response.status_code == 200 assert len(response.data["calculation"]["rows"]) > 1 assert "id" in response.data["calculation"]["rows"][0].keys() + + +@pytest.mark.parametrize( + "number_of_instalments, has_subsidies", + [(2, False), (1, True)], +) +def test_application_calculation_instalments( + handling_application, settings, number_of_instalments, has_subsidies +): + settings.PAYMENT_INSTALMENTS_ENABLED = True + settings.INSTALMENT_THRESHOLD = 9600 + settings.SALARY_BENEFIT_NEW_MAX = 1500 + + if has_subsidies is False: + handling_application.pay_subsidies.all().delete() + assert handling_application.pay_subsidies.count() == 0 + handling_application.pay_subsidy_granted = PaySubsidyGranted.NOT_GRANTED + handling_application.save() + + handling_application.calculation.start_date = datetime.now() + handling_application.calculation.end_date = ( + handling_application.start_date + timedelta(weeks=52) + ) + handling_application.calculation.save() + + handling_application.calculation.init_calculator() + handling_application.calculation.calculate() + + assert handling_application.calculation.instalments.count() == number_of_instalments + instalment_1 = handling_application.calculation.instalments.all()[0] + + assert instalment_1.due_date is not None + + due_date = instalment_1.due_date + now_date = timezone.now().date() + + assert due_date == now_date + + assert due_date == now_date + + if number_of_instalments == 1: + assert ( + instalment_1.amount + == handling_application.calculation.calculated_benefit_amount + ) + + if number_of_instalments == 2: + assert instalment_1.amount == decimal.Decimal(settings.INSTALMENT_THRESHOLD) + instalment_2 = handling_application.calculation.instalments.all()[1] + assert ( + instalment_2.amount + == handling_application.calculation.calculated_benefit_amount + - decimal.Decimal(settings.INSTALMENT_THRESHOLD) + ) + + due_date = instalment_2.due_date + future_date = timezone.now() + timedelta(days=181) + assert due_date == future_date.date() diff --git a/backend/benefit/helsinkibenefit/settings.py b/backend/benefit/helsinkibenefit/settings.py index a039a8d917..f3408c4de8 100644 --- a/backend/benefit/helsinkibenefit/settings.py +++ b/backend/benefit/helsinkibenefit/settings.py @@ -179,6 +179,7 @@ TALPA_CALLBACK_ENABLED=(bool, False), PAYMENT_INSTALMENTS_ENABLED=(bool, False), SALARY_BENEFIT_NEW_MAX=(int, 1500), + INSTALMENT_THRESHOLD=(int, 9600), ) if os.path.exists(env_file): env.read_env(env_file) @@ -568,3 +569,4 @@ TALPA_CALLBACK_ENABLED = env.bool("TALPA_CALLBACK_ENABLED") PAYMENT_INSTALMENTS_ENABLED = env.bool("PAYMENT_INSTALMENTS_ENABLED") SALARY_BENEFIT_NEW_MAX = env.int("SALARY_BENEFIT_NEW_MAX") +INSTALMENT_THRESHOLD = env.int("INSTALMENT_THRESHOLD")