Skip to content

Commit

Permalink
Merge pull request #2021 from City-of-Helsinki/HL-737-calculator
Browse files Browse the repository at this point in the history
HL-737 | Calculator changes
  • Loading branch information
rikuke authored Aug 22, 2023
2 parents 34e3341 + 3d1e4ee commit 15481ef
Show file tree
Hide file tree
Showing 13 changed files with 281 additions and 92 deletions.
3 changes: 1 addition & 2 deletions backend/benefit/applications/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,9 +38,8 @@
)

PAY_SUBSIDY_PERCENT_CHOICES = (
(30, "30%"),
(40, "40%"),
(50, "50%"),
(70, "70%"),
(100, "100%"),
)

Expand Down
8 changes: 5 additions & 3 deletions backend/benefit/applications/tests/test_ahjo_integration.py
Original file line number Diff line number Diff line change
Expand Up @@ -248,7 +248,7 @@ def test_multiple_benefit_per_application(mock_pdf_convert):
override_monthly_benefit_amount=None,
)
pay_subsidy = PaySubsidyFactory(
pay_subsidy_percent=40, start_date=date(2021, 7, 10), end_date=date(2021, 9, 10)
pay_subsidy_percent=50, start_date=date(2021, 7, 10), end_date=date(2021, 9, 10)
)
application.pay_subsidies.add(pay_subsidy)
application.save()
Expand All @@ -262,15 +262,17 @@ def test_multiple_benefit_per_application(mock_pdf_convert):
assert (
html.count(application.ahjo_application_number) == 2
) # Make sure there are two rows in the report
print(html)
_assert_html_content(
html,
(
application.ahjo_application_number,
application.employee.first_name,
application.employee.last_name,
"691",
"340",
"440",
"893",
"1600",
"800",
"2493",
),
)
Original file line number Diff line number Diff line change
Expand Up @@ -1455,7 +1455,7 @@ def test_application_modified_at_non_draft(api_client, application, status):
[
(None, None, None, 200), # empty application
(True, 50, None, 200), # one pay subsidy
(True, 100, 30, 200), # two pay subsidies
(True, 100, 30, 400), # two pay subsidies
(None, 100, None, 400), # invalid
(True, None, 50, 400), # invalid percent
(True, 99, None, 400), # invalid choice
Expand Down
66 changes: 26 additions & 40 deletions backend/benefit/calculator/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@

STATE_AID_MAX_PERCENTAGE_CHOICES = (
(50, "50%"),
(70, "70%"),
(100, "100%"),
)

Expand Down Expand Up @@ -527,10 +528,13 @@ class SalaryCostsRow(CalculationRow):
proxy_row_type = RowType.SALARY_COSTS_EUR
description_fi_template = "Palkkakustannukset / kk"

"""Calculate the amount of salary costs for the application.
Notice that the vacation money is reported per month by the applicant."""

def calculate_amount(self):
return (
self.calculation.monthly_pay
+ self.calculation.vacation_money / self.calculation.duration_in_months
+ self.calculation.vacation_money
+ self.calculation.other_expenses
)

Expand All @@ -555,7 +559,7 @@ class Meta:

class PaySubsidyMonthlyRow(CalculationRow):
proxy_row_type = RowType.PAY_SUBSIDY_MONTHLY_EUR
description_fi_template = "Palkkatuki (enintään {row.max_subsidy} €)"
description_fi_template = "Palkkatuki"

"""
Special rule regarding a 100% pay subsidy. The 100% subsidy is limited so, that it's only possible
Expand All @@ -566,9 +570,10 @@ class PaySubsidyMonthlyRow(CalculationRow):
* vacation money = 498,85
* 100% pay subsidy has been granted for 6 months
* Pay subsidy is calcuated using formula:
min(1800, (monthly_pay+additional_expenses)/0.8*0.65) + vacation_money/6/0.8*0.65
min(2020, (monthly_pay / work_time_fraction * 0.65) * 1.23
"""
MAX_WORK_TIME_FRACTION_FOR_FULL_PAY_SUBSIDY = decimal.Decimal("0.65")
GROSS_WAGE_COEFFICIENT_FOR_FULL_PAY_SUBSIDY = decimal.Decimal("1.23")

def __init__(self, *args, **kwargs):
self.pay_subsidy = kwargs.pop("pay_subsidy", None)
Expand All @@ -577,10 +582,7 @@ def __init__(self, *args, **kwargs):

def calculate_amount(self):
"""
Rule regarding the vacation money:
"Palkkatuen enimmäismäärä yritykselle vuonna 2021 on 1400 €/kk, jonka lisäksi maksetaan enintään
palkkatukipäätöksen mukainen prosenttiosuus lomarahasta."
Therefore, the pay subsidy limit does not apply to the vacation_money
1.7.2023 voimaantulevan lain mukaan lomarahaa ja sivukuluja ei oteta enää huomioon palkkatuen määrää laskiessa
"""
assert self.max_subsidy is not None
assert self.pay_subsidy is not None
Expand All @@ -592,39 +594,23 @@ def calculate_amount(self):
pay_subsidy_fraction = self.pay_subsidy.pay_subsidy_percent * decimal.Decimal(
"0.01"
)

if (
pay_subsidy_fraction == 1
and work_time_fraction > self.MAX_WORK_TIME_FRACTION_FOR_FULL_PAY_SUBSIDY
):
full_time_salary_cost_excluding_vacation_money = (
self.calculation.monthly_pay + self.calculation.other_expenses
) / work_time_fraction
full_time_vacation_money = (
self.calculation.vacation_money / work_time_fraction
) / self.calculation.duration_in_months
subsidy_amount = (
min(
self.max_subsidy,
full_time_salary_cost_excluding_vacation_money
* self.MAX_WORK_TIME_FRACTION_FOR_FULL_PAY_SUBSIDY,
# Pay subsidy max is 100%:
if pay_subsidy_fraction == 1:
full_time_salary_cost = (self.calculation.monthly_pay) / work_time_fraction

subsidy_amount = min(
self.max_subsidy,
(
full_time_salary_cost
* self.MAX_WORK_TIME_FRACTION_FOR_FULL_PAY_SUBSIDY
)
+ full_time_vacation_money
* self.MAX_WORK_TIME_FRACTION_FOR_FULL_PAY_SUBSIDY
* self.GROSS_WAGE_COEFFICIENT_FOR_FULL_PAY_SUBSIDY,
)
# Pay subsidy max is less than 100% (50% or 70%):
else:
salary_cost_excluding_vacation_money = (
self.calculation.monthly_pay + self.calculation.other_expenses
)
monthly_vacation_money = (
self.calculation.vacation_money / self.calculation.duration_in_months
)
subsidy_amount = (
min(
self.max_subsidy,
pay_subsidy_fraction * salary_cost_excluding_vacation_money,
)
+ monthly_vacation_money * pay_subsidy_fraction
subsidy_amount = min(
self.max_subsidy,
pay_subsidy_fraction * self.calculation.monthly_pay,
)
return subsidy_amount

Expand Down Expand Up @@ -674,7 +660,7 @@ class Meta:

class SalaryBenefitMonthlyRow(CalculationRow):
proxy_row_type = RowType.HELSINKI_BENEFIT_MONTHLY_EUR
description_fi_template = "Helsinki-lisä / kk (enintään {row.max_benefit} €)"
description_fi_template = "Helsinki-lisä / kk"

def __init__(self, *args, **kwargs):
self.max_benefit = kwargs.pop("max_benefit", None)
Expand Down Expand Up @@ -733,7 +719,7 @@ class SalaryBenefitTotalRow(CalculationRow, TotalRowMixin):

def calculate_amount(self):
return to_decimal(
self.calculation.duration_in_months
self.calculation.duration_in_months_rounded
* self.calculation.calculator.get_amount(
RowType.HELSINKI_BENEFIT_MONTHLY_EUR
),
Expand All @@ -753,7 +739,7 @@ def __init__(self, *args, **kwargs):

def calculate_amount(self):
return to_decimal(
duration_in_months(self.start_date, self.end_date)
duration_in_months(self.start_date, self.end_date, 2)
* self.calculation.calculator.get_amount(
RowType.HELSINKI_BENEFIT_MONTHLY_EUR
),
Expand Down
37 changes: 25 additions & 12 deletions backend/benefit/calculator/rules.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,15 @@
import datetime
import decimal
import logging
from typing import Union

from django.db import transaction

from applications.enums import ApplicationStatus, BenefitType
from calculator.enums import RowType
from calculator.models import (
Calculation,
CalculationRow,
DateRangeDescriptionRow,
DescriptionRow,
EmployeeBenefitMonthlyRow,
Expand All @@ -34,17 +37,17 @@


class HelsinkiBenefitCalculator:
def __init__(self, calculation):
def __init__(self, calculation: Calculation):
self.calculation = calculation
self._row_counter = 0

@staticmethod
def get_calculator(calculation):
def get_calculator(calculation: Calculation):
# in future, one might use e.g. application date to determine the correct calculator
if calculation.override_monthly_benefit_amount is not None:
return ManualOverrideCalculator(calculation)
elif calculation.application.benefit_type == BenefitType.SALARY_BENEFIT:
return SalaryBenefitCalculator2021(calculation)
return SalaryBenefitCalculator2023(calculation)
elif calculation.application.benefit_type == BenefitType.EMPLOYMENT_BENEFIT:
return EmployeeBenefitCalculator2021(calculation)
else:
Expand Down Expand Up @@ -77,7 +80,12 @@ def get_sub_total_ranges(self):
# change day is the day after end_date
change_days.add(item.end_date + datetime.timedelta(days=1))

def get_item_in_effect(items, day):
def get_item_in_effect(
items: list[PaySubsidy], day: datetime.date
) -> Union[PaySubsidy, None]:
# Return the first item in the list whose start date is less than or equal to the given day,
# and whose end date is greater than or equal to the given day.
# If no such item is found, it returns None.
for item in items:
if item.start_date <= day <= item.end_date:
return item
Expand All @@ -102,7 +110,7 @@ def get_item_in_effect(items, day):
assert ranges[-1].end_date == self.calculation.end_date
return ranges

def get_amount(self, row_type, default=None):
def get_amount(self, row_type: RowType, default=None):
# This function is used by the various CalculationRow to retrieve a previously calculated value
row = (
self.calculation.rows.order_by("-ordering")
Expand Down Expand Up @@ -146,7 +154,7 @@ def calculate(self):
self.calculation.calculated_benefit_amount = None
self.calculation.save()

def _create_row(self, row_class, **kwargs):
def _create_row(self, row_class: CalculationRow, **kwargs):
row = row_class(
calculation=self.calculation, ordering=self._row_counter, **kwargs
)
Expand All @@ -169,7 +177,7 @@ def create_rows(self):
SalaryBenefitTotalRow,
)

def get_amount(self, row_type, default=None):
def get_amount(self, row_type: RowType, default=None):
return decimal.Decimal(0)


Expand All @@ -182,14 +190,15 @@ def create_rows(self):
)


class SalaryBenefitCalculator2021(HelsinkiBenefitCalculator):
class SalaryBenefitCalculator2023(HelsinkiBenefitCalculator):
"""
Calculation of salary benefit, according to rules in effect 2021 (and possibly onwards)
Calculation of salary benefit, according to rules in effect starting from 1.7.2023
"""

# The maximum amount of pay subsidy depends on the pay subsidy percent in the pay subsidy decision.
PAY_SUBSIDY_MAX_FOR_100_PERCENT = 1800
DEFAULT_PAY_SUBSIDY_MAX = 1400
PAY_SUBSIDY_MAX_FOR_100_PERCENT = 2020
PAY_SUBSIDY_MAX_FOR_70_PERCENT = 1770
PAY_SUBSIDY_MAX_FOR_50_PERCENT = 1260
SALARY_BENEFIT_MAX = 800

def can_calculate(self):
Expand All @@ -209,10 +218,14 @@ def can_calculate(self):
def get_maximum_monthly_pay_subsidy(self, pay_subsidy):
if pay_subsidy.pay_subsidy_percent == 100:
return self.PAY_SUBSIDY_MAX_FOR_100_PERCENT
elif pay_subsidy.pay_subsidy_percent == 70:
return self.PAY_SUBSIDY_MAX_FOR_70_PERCENT
else:
return self.DEFAULT_PAY_SUBSIDY_MAX
return self.PAY_SUBSIDY_MAX_FOR_50_PERCENT

def create_deduction_rows(self, benefit_sub_range):
# Create the rows for the calculation
# that display the deduction amounts for pay subsidy and training compensation
if benefit_sub_range.pay_subsidy or benefit_sub_range.training_compensation:
self._create_row(
DescriptionRow,
Expand Down
Binary file not shown.
10 changes: 5 additions & 5 deletions backend/benefit/calculator/tests/test_calculator_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ def _set_two_pay_subsidies_with_empty_dates(data: dict) -> dict:
{
"start_date": None,
"end_date": None,
"pay_subsidy_percent": 40,
"pay_subsidy_percent": 70,
"work_time_percent": 40,
},
]
Expand Down Expand Up @@ -249,7 +249,7 @@ def test_modify_calculation_invalid_status(
{
"start_date": str(handling_application.start_date),
"end_date": str(handling_application.end_date),
"pay_subsidy_percent": 40,
"pay_subsidy_percent": 50,
"work_time_percent": "50.00",
}
]
Expand Down Expand Up @@ -315,7 +315,7 @@ def test_application_edit_pay_subsidy(handler_api_client, handling_application):

# edit fields
data["pay_subsidies"][0]["start_date"] = "2021-06-01"
data["pay_subsidies"][0]["pay_subsidy_percent"] = 40
data["pay_subsidies"][0]["pay_subsidy_percent"] = 70
# swap order
data["pay_subsidies"][0], data["pay_subsidies"][1] = (
data["pay_subsidies"][1],
Expand All @@ -328,7 +328,7 @@ def test_application_edit_pay_subsidy(handler_api_client, handling_application):
assert response.status_code == 200
assert len(response.data["pay_subsidies"]) == 2
assert response.data["pay_subsidies"][1]["start_date"] == "2021-06-01"
assert response.data["pay_subsidies"][1]["pay_subsidy_percent"] == 40
assert response.data["pay_subsidies"][1]["pay_subsidy_percent"] == 70


def test_application_delete_pay_subsidy(handler_api_client, handling_application):
Expand Down Expand Up @@ -472,7 +472,7 @@ def test_pay_subsidies_validation_in_handling(
company=mock_get_organisation_roles_and_create_company,
pay_subsidy_granted=True,
pay_subsidy_percent=100,
additional_pay_subsidy_percent=40,
additional_pay_subsidy_percent=70,
)
data = HandlerApplicationSerializer(handling_application).data
_set_two_pay_subsidies_with_empty_dates(data)
Expand Down
2 changes: 1 addition & 1 deletion backend/benefit/calculator/tests/test_models.py
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,7 @@ def test_create_for_application_fail(received_application):


@pytest.mark.parametrize(
"pay_subsidy_percent, max_subsidy", [(40, 1400), (50, 1400), (100, 1800)]
"pay_subsidy_percent, max_subsidy", [(70, 1770), (50, 1260), (100, 2020)]
)
def test_pay_subsidy_maximum(handling_application, pay_subsidy_percent, max_subsidy):
assert handling_application.pay_subsidies.count() == 1
Expand Down
Loading

0 comments on commit 15481ef

Please sign in to comment.