diff --git a/horilla/urls.py b/horilla/urls.py index 052176d37..9bed4ea20 100755 --- a/horilla/urls.py +++ b/horilla/urls.py @@ -31,6 +31,7 @@ urlpatterns = [ path('pms/',include('pms.urls')), path('asset/',include('asset.urls')), path('attendance/',include('attendance.urls')), + path('payroll/',include('payroll.urls.urls')), re_path('^inbox/notifications/', include(notifications.urls, namespace='notifications')), path('i18n/', include('django.conf.urls.i18n')), diff --git a/payroll/__init__.py b/payroll/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/payroll/admin.py b/payroll/admin.py new file mode 100644 index 000000000..20ea37905 --- /dev/null +++ b/payroll/admin.py @@ -0,0 +1,31 @@ +""" +admin.py + +Used to register models on admin site +""" +from django.contrib import admin +from payroll.models.tax_models import ( + FederalTax, +) +from payroll.models.models import ( + Allowance, + Deduction, + FilingStatus, + Payslip, + WorkRecord, +) +from payroll.models.tax_models import ( + PayrollSettings, + TaxBracket, +) +from payroll.models.models import Contract + +# Register your models here. +admin.site.register(FilingStatus) +admin.site.register(TaxBracket) +admin.site.register(FederalTax) +admin.site.register([Contract, WorkRecord]) +admin.site.register(Allowance) +admin.site.register(Deduction) +admin.site.register(Payslip) +admin.site.register(PayrollSettings) diff --git a/payroll/apps.py b/payroll/apps.py new file mode 100644 index 000000000..a1eab344e --- /dev/null +++ b/payroll/apps.py @@ -0,0 +1,12 @@ +""" +App configuration for the 'payroll' app. +""" +from django.apps import AppConfig + + +class PayrollConfig(AppConfig): + """ + AppConfig for the 'payroll' app. + """ + default_auto_field = 'django.db.models.BigAutoField' + name = 'payroll' diff --git a/payroll/context_processors.py b/payroll/context_processors.py new file mode 100644 index 000000000..e3aabf9e6 --- /dev/null +++ b/payroll/context_processors.py @@ -0,0 +1,18 @@ +""" +context_processor.py + +This module is used to register context processor` +""" +from payroll.models import tax_models as models + + +def default_currency(request): + """ + This method will return the currency + """ + if models.PayrollSettings.objects.first() is None: + settings = models.PayrollSettings() + settings.currency_symbol = "$" + settings.save() + symbol = models.PayrollSettings.objects.first().currency_symbol + return {"currency": request.session.get("currency", symbol)} diff --git a/payroll/filters.py b/payroll/filters.py new file mode 100644 index 000000000..905e53edf --- /dev/null +++ b/payroll/filters.py @@ -0,0 +1,239 @@ +""" +Module containing filter set classes for payroll models. + +This module defines the filter set classes used for filtering data in the payroll app. +Each filter set class corresponds to a specific model and contains filter fields and methods +to customize the filtering behavior. + +""" +import django_filters +from django import forms +from horilla.filters import filter_by_name +from attendance.filters import FilterSet +from payroll.models.models import Allowance, Contract, Deduction +from payroll.models.models import Payslip + + +class ContractFilter(FilterSet): + """ + Filter set class for Contract model + + Args: + FilterSet (class): custom filter set class to apply styling + """ + + search = django_filters.CharFilter(method="filter_by_contract") + contract_start_date = django_filters.DateFilter( + field_name="contract_start_date", + widget=forms.DateInput(attrs={"type": "date"}), + ) + contract_end_date = django_filters.DateFilter( + field_name="contract_end_date", + widget=forms.DateInput(attrs={"type": "date"}), + ) + + class Meta: + """ + Meta class to add additional options + """ + + model = Contract + fields = [ + "employee_id", + "contract_name", + "contract_start_date", + "contract_end_date", + "wage_type", + "filing_status", + "pay_frequency", + "contract_status", + ] + + def filter_by_contract(self, queryset, _, value): + """ + Filter queryset by first name or last name. + """ + # Split the search value into first name and last name + parts = value.split() + first_name = parts[0] + og_queryset = queryset + last_name = " ".join(parts[1:]) if len(parts) > 1 else "" + # Filter the queryset by first name and last name + if first_name and last_name: + queryset = queryset.filter( + employee_id__employee_first_name__icontains=first_name, + employee_id__employee_last_name__icontains=last_name, + ) + elif first_name: + queryset = queryset.filter( + employee_id__employee_first_name__icontains=first_name + ) + elif last_name: + queryset = queryset.filter( + employee_id__employee_last_name__icontains=last_name + ) + queryset = queryset | og_queryset.filter(contract_name__icontains=value) + return queryset + + +class AllowanceFilter(FilterSet): + """ + Filter set class for Allowance model. + """ + + search = django_filters.CharFilter(method="filter_by_employee") + + class Meta: + """ + Meta class to add additional options + """ + + model = Allowance + fields = [ + "title", + "is_taxable", + "is_condition_based", + "is_fixed", + "based_on", + ] + + def filter_by_employee(self, queryset, _, value): + """ + Filter queryset by first name or last name. + """ + # Split the search value into first name and last name + parts = value.split() + first_name = parts[0] + og_queryset = queryset + last_name = " ".join(parts[1:]) if len(parts) > 1 else "" + # Filter the queryset by first name and last name + if first_name and last_name: + queryset = queryset.filter( + specific_employees__employee_first_name__icontains=first_name, + specific_employees__employee_last_name__icontains=last_name, + ) + elif first_name: + queryset = queryset.filter( + specific_employees__employee_first_name__icontains=first_name + ) + elif last_name: + queryset = queryset.filter( + specific_employees__employee_last_name__icontains=last_name + ) + queryset = queryset | og_queryset.filter(title__icontains=value) + return queryset + + +class DeductionFilter(FilterSet): + """ + Filter set class for Deduction model. + """ + + search = django_filters.CharFilter(method="filter_by_employee") + + class Meta: + """ + Meta class to add additional options + """ + + model = Deduction + fields = [ + "title", + "is_pretax", + "is_condition_based", + "is_fixed", + "based_on", + ] + + def filter_by_employee(self, queryset, _, value): + """ + Filter queryset by first name or last name. + """ + # Split the search value into first name and last name + parts = value.split() + first_name = parts[0] + og_queryset = queryset + last_name = " ".join(parts[1:]) if len(parts) > 1 else "" + # Filter the queryset by first name and last name + if first_name and last_name: + queryset = queryset.filter( + specific_employees__employee_first_name__icontains=first_name, + specific_employees__employee_last_name__icontains=last_name, + ) + elif first_name: + queryset = queryset.filter( + specific_employees__employee_first_name__icontains=first_name + ) + elif last_name: + queryset = queryset.filter( + specific_employees__employee_last_name__icontains=last_name + ) + queryset = queryset | og_queryset.filter(title__icontains=value) + return queryset + + +class PayslipFilter(FilterSet): + """ + Filter set class for payslip model. + """ + + search = django_filters.CharFilter(method=filter_by_name) + start_date = django_filters.DateFilter( + widget=forms.DateInput(attrs={"type": "date"}), + ) + end_date = django_filters.DateFilter( + widget=forms.DateInput(attrs={"type": "date"}), + ) + start_date_from = django_filters.DateFilter( + widget=forms.DateInput(attrs={"type": "date"}), + field_name="start_date", + lookup_expr="gte", + ) + start_date_till = django_filters.DateFilter( + widget=forms.DateInput(attrs={"type": "date"}), + field_name="start_date", + lookup_expr="lte", + ) + end_date_from = django_filters.DateFilter( + widget=forms.DateInput(attrs={"type": "date"}), + field_name="end_date", + lookup_expr="gte", + ) + end_date_till = django_filters.DateFilter( + widget=forms.DateInput(attrs={"type": "date"}), + field_name="end_date", + lookup_expr="lte", + ) + gross_pay__lte = django_filters.NumberFilter( + field_name="gross_pay", lookup_expr="lte" + ) + gross_pay__gte = django_filters.NumberFilter( + field_name="gross_pay", lookup_expr="gte" + ) + deduction__lte = django_filters.NumberFilter( + field_name="deduction", lookup_expr="lte" + ) + deduction__gte = django_filters.NumberFilter( + field_name="deduction", lookup_expr="gte" + ) + net_pay__lte = django_filters.NumberFilter(field_name="net_pay", lookup_expr="lte") + net_pay__gte = django_filters.NumberFilter(field_name="net_pay", lookup_expr="gte") + + class Meta: + """ + Meta class to add additional options + """ + + model = Payslip + fields = [ + "employee_id", + "start_date", + "end_date", + "status", + "gross_pay__lte", + "gross_pay__gte", + "deduction__lte", + "deduction__gte", + "net_pay__lte", + "net_pay__gte", + ] diff --git a/payroll/forms/__init__.py b/payroll/forms/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/payroll/forms/component_forms.py b/payroll/forms/component_forms.py new file mode 100644 index 000000000..f14205494 --- /dev/null +++ b/payroll/forms/component_forms.py @@ -0,0 +1,225 @@ +""" +These forms provide a convenient way to handle data input, validation, and customization +of form fields and widgets for the corresponding models in the payroll management system. +""" +import uuid +import datetime +from django import forms +from django.utils.translation import gettext_lazy as _ +from django.template.loader import render_to_string +from base.forms import ModelForm +from employee.models import Employee +from payroll.models import tax_models as models +from payroll.widgets import component_widgets as widget +from payroll.models.models import Contract +import payroll.models.models + + +class AllowanceForm(forms.ModelForm): + """ + Form for Allowance model + """ + + load = forms.CharField(widget=widget.AllowanceConditionalVisibility, required=False) + style = forms.CharField(required=False) + verbose_name = _("Allowance") + + class Meta: + """ + Meta class for additional options + """ + + model = payroll.models.models.Allowance + fields = "__all__" + widgets = { + "one_time_date": forms.DateTimeInput(attrs={"type": "date"}), + } + + def __init__(self, *args, **kwargs): + if instance := kwargs.get("instance"): + # django forms not showing value inside the date, time html element. + # so here overriding default forms instance method to set initial value + initial = {} + if instance.one_time_date is not None: + initial = { + "one_time_date": instance.one_time_date.strftime("%Y-%m-%d"), + } + kwargs["initial"] = initial + super().__init__(*args, **kwargs) + self.fields["style"].widget = widget.StyleWidget(form=self) + + def as_p(self): + """ + Render the form fields as HTML table rows with Bootstrap styling. + """ + context = {"form": self} + table_html = render_to_string("common_form.html", context) + return table_html + + +class DeductionForm(forms.ModelForm): + """ + Form for Deduction model + """ + + load = forms.CharField(widget=widget.DeductionConditionalVisibility, required=False) + style = forms.CharField(required=False) + verbose_name = _("Deduction") + + + class Meta: + """ + Meta class for additional options + """ + + model = payroll.models.models.Deduction + fields = "__all__" + widgets = { + "one_time_date": forms.DateTimeInput(attrs={"type": "date"}), + } + + def __init__(self, *args, **kwargs): + if instance := kwargs.get("instance"): + # django forms not showing value inside the date, time html element. + # so here overriding default forms instance method to set initial value + initial = {} + if instance.one_time_date is not None: + initial = { + "one_time_date": instance.one_time_date.strftime("%Y-%m-%d"), + } + kwargs["initial"] = initial + super().__init__(*args, **kwargs) + self.fields["style"].widget = widget.StyleWidget(form=self) + + def clean(self): + if ( + self.data.get("update_compensation") is not None + and self.data.get("update_compensation") != "" + ): + if ( + self.data.getlist("specific_employees") is None + and len(self.data.getlist("specific_employees")) == 0 + ): + raise forms.ValidationError( + {"specific_employees": "You need to choose the employee."} + ) + + if ( + self.data.get("one_time_date") is None + and self.data.get("one_time_date") == "" + ): + raise forms.ValidationError( + {"one_time_date": "This field is required."} + ) + if self.data.get("amount") is None and self.data.get("amount") == "": + raise forms.ValidationError({"amount": "This field is required."}) + return super().clean() + + def as_p(self): + """ + Render the form fields as HTML table rows with Bootstrap styling. + """ + context = {"form": self} + table_html = render_to_string("common_form.html", context) + return table_html + + +class PayslipForm(ModelForm): + """ + Form for Payslip + """ + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + active_contracts = Contract.objects.filter(contract_status="active") + self.fields["employee_id"].choices = [ + (contract.employee_id.id, contract.employee_id) + for contract in active_contracts + ] + + class Meta: + """ + Meta class for additional options + """ + + model = payroll.models.models.Payslip + fields = [ + "employee_id", + "start_date", + "end_date", + ] + widgets = { + "start_date": forms.DateInput(attrs={"type": "date"}), + "end_date": forms.DateInput(attrs={"type": "date"}), + } + + +class GeneratePayslipForm(forms.Form): + """ + Form for Payslip + """ + + employee_id = forms.ModelMultipleChoiceField( + queryset=Employee.objects.filter( + contract_set__isnull=False, contract_set__contract_status="active" + ), + widget=forms.SelectMultiple, + ) + start_date = forms.DateField(widget=forms.DateInput(attrs={"type": "date"})) + end_date = forms.DateField(widget=forms.DateInput(attrs={"type": "date"})) + + def clean(self): + cleaned_data = super().clean() + start_date = cleaned_data.get("start_date") + end_date = cleaned_data.get("end_date") + + today = datetime.date.today() + if end_date <= start_date: + raise forms.ValidationError( + { + "end_date": "The end date must be greater than or equal to the start date." + } + ) + if start_date > today: + raise forms.ValidationError( + {"end_date": "The start date cannot be in the future."} + ) + + if end_date > today: + raise forms.ValidationError( + {"end_date": "The end date cannot be in the future."} + ) + return cleaned_data + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + self.fields["employee_id"].widget.attrs.update( + {"class": "oh-select oh-select-2", "id": uuid.uuid4()} + ) + self.fields["start_date"].widget.attrs.update({"class": "oh-input w-100"}) + self.fields["end_date"].widget.attrs.update({"class": "oh-input w-100"}) + + class Meta: + """ + Meta class for additional options + """ + + widgets = { + "start_date": forms.DateInput(attrs={"type": "date"}), + "end_date": forms.DateInput(attrs={"type": "date"}), + } + + +class PayrollSettingsForm(ModelForm): + """ + Form for PayrollSettings model + """ + + class Meta: + """ + Meta class for additional options + """ + + model = models.PayrollSettings + fields = "__all__" diff --git a/payroll/forms/forms.py b/payroll/forms/forms.py new file mode 100644 index 000000000..3c07d4b84 --- /dev/null +++ b/payroll/forms/forms.py @@ -0,0 +1,110 @@ +""" +forms.py +""" +from django import forms +from django.utils.translation import gettext_lazy as trans +from django.template.loader import render_to_string +from payroll.models.models import WorkRecord +from payroll.models.models import Contract + + +class ModelForm(forms.ModelForm): + """ + ModelForm override for additional style + """ + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + for _, field in self.fields.items(): + widget = field.widget + + if isinstance(widget, (forms.DateInput)): + field.widget.attrs.update({"class": "oh-input oh-calendar-input w-100"}) + elif isinstance( + widget, (forms.NumberInput, forms.EmailInput, forms.TextInput) + ): + label = trans(field.label) + field.widget.attrs.update( + {"class": "oh-input w-100", "placeholder": label} + ) + elif isinstance(widget, (forms.Select,)): + field.widget.attrs.update( + {"class": "oh-select oh-select-2 select2-hidden-accessible"} + ) + elif isinstance(widget, (forms.Textarea)): + label = trans(field.label) + field.widget.attrs.update( + { + "class": "oh-input w-100", + "placeholder": label, + "rows": 2, + "cols": 40, + } + ) + elif isinstance( + widget, + ( + forms.CheckboxInput, + forms.CheckboxSelectMultiple, + ), + ): + field.widget.attrs.update({"class": "oh-switch__checkbox"}) + + +class ContractForm(ModelForm): + """ + ContactForm + """ + verbose_name = trans("Contract") + class Meta: + """ + Meta class for additional options + """ + + fields = "__all__" + exclude = [ + "is_active", + ] + model = Contract + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.fields["contract_name"].widget.attrs["autocomplete"] = "off" + self.fields["contract_start_date"].widget.attrs["autocomplete"] = "off" + self.fields["contract_start_date"].widget.attrs["class"] = "oh-input w-100" + self.fields["contract_start_date"].widget = forms.TextInput( + attrs={"type": "date", "class": "oh-input w-100"} + ) + self.fields["contract_end_date"].widget.attrs["autocomplete"] = "off" + self.fields["contract_end_date"].widget.attrs["class"] = "oh-input w-100" + self.fields["contract_end_date"].widget = forms.TextInput( + attrs={"type": "date", "class": "oh-input w-100"} + ) + self.fields["employee_id"].widget.attrs["data-contract-style"] = "" + self.fields["department"].widget.attrs["data-contract-style"] = "" + self.fields["job_position"].widget.attrs["data-contract-style"] = "" + self.fields["job_role"].widget.attrs["data-contract-style"] = "" + self.fields["work_type"].widget.attrs["data-contract-style"] = "" + self.fields["shift"].widget.attrs["data-contract-style"] = "" + + def as_p(self): + """ + Render the form fields as HTML table rows with Bootstrap styling. + """ + context = {"form": self} + table_html = render_to_string("contract_form.html", context) + return table_html + + +class WorkRecordForm(ModelForm): + """ + WorkRecordForm + """ + + class Meta: + """ + Meta class for additional options + """ + + fields = "__all__" + model = WorkRecord diff --git a/payroll/forms/tax_forms.py b/payroll/forms/tax_forms.py new file mode 100644 index 000000000..c442a4410 --- /dev/null +++ b/payroll/forms/tax_forms.py @@ -0,0 +1,101 @@ +""" +Forms for handling payroll-related operations. + +This module provides Django ModelForms for creating and managing payroll-related data, +including filing status, tax brackets, and federal tax records. + +The forms in this module inherit from the Django `forms.ModelForm` class and customize +the widget attributes to enhance the user interface and provide a better user experience. + +""" +import uuid +from django import forms +from django.utils.translation import gettext_lazy as _ +from payroll.models.tax_models import TaxBracket + + +from payroll.models.tax_models import ( + FederalTax, +) +from payroll.models.models import FilingStatus + + +class ModelForm(forms.ModelForm): + """Custom ModelForm with enhanced widget attributes.""" + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + for field_name, field in self.fields.items(): + input_widget = field.widget + if isinstance( + input_widget, (forms.NumberInput, forms.EmailInput, forms.TextInput) + ): + label = _(field.label) + input_widget.attrs.update( + {"class": "oh-input w-100", "placeholder": label} + ) + elif isinstance(input_widget, (forms.Select,)): + label = "" + if field.label is not None: + label = _(field.label) + field.empty_label = str(_("---Choose {label}---")).format(label=label) + self.fields[field_name].widget.attrs.update( + { + "class": "oh-select oh-select-2 w-100", + "id": uuid.uuid4(), + "style": "height:50px;border-radius:0;", + } + ) + elif isinstance(input_widget, (forms.Textarea)): + label = _(field.label.title()) + input_widget.attrs.update( + { + "class": "oh-input w-100", + "placeholder": label, + "rows": 2, + "cols": 40, + } + ) + elif isinstance( + input_widget, + ( + forms.CheckboxInput, + forms.CheckboxSelectMultiple, + ), + ): + input_widget.attrs.update({"class": "oh-switch__checkbox"}) + + +class FilingStatusForm(ModelForm): + """Form for creating and updating filing status.""" + + class Meta: + """Meta options for the form.""" + + model = FilingStatus + fields = "__all__" + + +class TaxBracketForm(ModelForm): + """Form for creating and updating tax bracket.""" + + class Meta: + """Meta options for the form.""" + + model = TaxBracket + fields = "__all__" + widgets = { + "filing_status_id": forms.Select( + attrs={"class": "oh-select oh-select-2 select2-hidden-accessible"} + ), + } + + +class FederalTaxForm(ModelForm): + """Form for creating and updating tax bracket.""" + + class Meta: + """Meta options for the form.""" + + model = FederalTax + fields = "__all__" diff --git a/payroll/methods/__init__.py b/payroll/methods/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/payroll/methods/deductions.py b/payroll/methods/deductions.py new file mode 100644 index 000000000..459d2bf08 --- /dev/null +++ b/payroll/methods/deductions.py @@ -0,0 +1,37 @@ +""" +deductions.py + +This module is used to compute the deductions of employees +""" +from payroll.models.models import Deduction + + +def update_compensation_deduction( + employee, compensation_amount, compensation_type, start_date, end_date +): + """ + This method is used to update the basic or gross pay + + Args: + compensation_amount (_type_): Gross pay or Basic pay or employee + """ + deduction_heads = ( + Deduction.objects.filter( + update_compensation=compensation_type, specific_employees=employee + ) + .exclude(one_time_date__lt=start_date) + .exclude(one_time_date__gt=end_date) + # .exclude(exclude_employees=employee) + ) + deductions = [] + temp = compensation_amount + for deduction in deduction_heads: + compensation_amount = compensation_amount - float(deduction.amount) + deductions.append({"title": deduction.title, "amount": deduction.amount}) + + difference_amount = temp - compensation_amount + return { + "compensation_amount": compensation_amount, + "deductions": deductions, + "difference_amount": difference_amount, + } diff --git a/payroll/methods/limits.py b/payroll/methods/limits.py new file mode 100644 index 000000000..71d0d334a --- /dev/null +++ b/payroll/methods/limits.py @@ -0,0 +1,56 @@ +""" +limit.py + +This module is used to compute the limit for allowance and deduction +""" +# from payroll.models.models import Allowance + + +def compute_limit(component, amount, _day_dict): + """ + limit compute method + """ + # day_dict = [ + # { + # "month": 5, + # "year": 2023, + # "days": 31, + # "start_date": "2023-05-01", + # "end_date": "2023-05-31", + # "working_days_on_period": 27, + # "working_days_on_month": 27, + # "per_day_amount": 555.5555555555555, + # }, + # { + # "month": 6, + # "year": 2023, + # "days": 30, + # "start_date": "2023-06-01", + # "end_date": "2023-06-23", + # "working_days_on_period": 20, + # "working_days_on_month": 26, + # "per_day_amount": 576.9230769230769, + # }, + # ] + if component.has_max_limit: + max_amount = component.maximum_amount + amount = min(amount, max_amount) + # if ( + # isinstance(component, Allowance) + # and component.has_max_limit + # and not component.is_fixed + # ): + # amount = min(amount, max_amount) + + # elif component.has_max_limit: + # unit = component.maximum_unit + # amount = 0 + # if unit == "month_working_days": + # for month in day_dict: + # working_days_on_month = month["working_days_on_month"] + # working_days_on_period = month["working_days_on_period"] + # # maximum amount for one working day + # max_day_amount = max_amount / working_days_on_month + # amount = amount + (max_day_amount * working_days_on_period) + + return amount diff --git a/payroll/methods/methods.py b/payroll/methods/methods.py new file mode 100644 index 000000000..4485e6d10 --- /dev/null +++ b/payroll/methods/methods.py @@ -0,0 +1,573 @@ +""" +methods.py + +Payroll related module to write custom calculation methods +""" +import calendar +from datetime import timedelta, datetime, date +from django.db.models import F +from django.core.paginator import Paginator +from dateutil.relativedelta import relativedelta +from leave.models import Holiday, CompanyLeave +from attendance.models import Attendance +from payroll.models.models import Contract + + +def get_holiday_dates(): + """ + :return: this functions returns a list of all holiday dates. + """ + holiday_dates = [] + holidays = Holiday.objects.all() + for holiday in holidays: + holiday_start_date = holiday.start_date + holiday_end_date = holiday.end_date + if holiday_end_date is None: + holiday_end_date = holiday_start_date + holiday_days = holiday_end_date - holiday_start_date + for i in range(holiday_days.days + 1): + holiday_date = holiday_start_date + timedelta(i) + holiday_dates.append(holiday_date) + return holiday_dates + + +def get_company_leave_dates(year): + """ + :return: This function returns a list of all company leave dates + """ + company_leaves = CompanyLeave.objects.all() + company_leave_dates = [] + for company_leave in company_leaves: + based_on_week = company_leave.based_on_week + based_on_week_day = company_leave.based_on_week_day + for month in range(1, 13): + if based_on_week is not None: + # Set Sunday as the first day of the week + calendar.setfirstweekday(6) + month_calendar = calendar.monthcalendar(year, month) + weeks = month_calendar[int(based_on_week)] + weekdays_in_weeks = [day for day in weeks if day != 0] + for day in weekdays_in_weeks: + leave_date = datetime.strptime( + f"{year}-{month:02}-{day:02}", "%Y-%m-%d" + ).date() + if ( + leave_date.weekday() == int(based_on_week_day) + and leave_date not in company_leave_dates + ): + company_leave_dates.append(leave_date) + else: + # Set Monday as the first day of the week + calendar.setfirstweekday(0) + month_calendar = calendar.monthcalendar(year, month) + for week in month_calendar: + if week[int(based_on_week_day)] != 0: + leave_date = datetime.strptime( + f"{year}-{month:02}-{week[int(based_on_week_day)]:02}", + "%Y-%m-%d", + ).date() + if leave_date not in company_leave_dates: + company_leave_dates.append(leave_date) + return company_leave_dates + + +def get_date_range(start_date, end_date): + """ + Returns a list of all dates within a given date range. + + Args: + start_date (date): The start date of the range. + end_date (date): The end date of the range. + + Returns: + list: A list of date objects representing all dates within the range. + + Example: + start_date = date(2023, 1, 1) + end_date = date(2023, 1, 10) + date_range = get_date_range(start_date, end_date) + for date_obj in date_range: + print(date_obj) + """ + date_list = [] + delta = end_date - start_date + + for i in range(delta.days + 1): + current_date = start_date + timedelta(days=i) + date_list.append(current_date) + + return date_list + + +def get_total_days(start_date, end_date): + """ + Calculates the total number of days in a given period. + + Args: + start_date (date): The start date of the period. + end_date (date): The end date of the period. + + Returns: + int: The total number of days in the period, including the end date. + + Example: + start_date = date(2023, 1, 1) + end_date = date(2023, 1, 10) + days_on_period = get_total_days(start_date, end_date) + print(days_on_period) # Output: 10 + """ + delta = end_date - start_date + total_days = delta.days + 1 # Add 1 to include the end date itself + return total_days + + +def get_working_days(start_date, end_date): + """ + This method is used to calculate the total working days, total leave, worked days on that period + + Args: + start_date (_type_): the start date from the data needed + end_date (_type_): the end date till the date needed + """ + + holiday_dates = get_holiday_dates() + + # appending company/holiday leaves + # Note: Duplicate entry may exist + company_leave_dates = ( + list( + set( + get_company_leave_dates(start_date.year) + + get_company_leave_dates(end_date.year) + ) + ) + + holiday_dates + ) + + date_range = get_date_range(start_date, end_date) + + # making unique list of company/holiday leave dates then filtering + # the leave dates only between the start and end date + company_leave_dates = [ + date + for date in list(set(company_leave_dates)) + if start_date <= date <= end_date + ] + + working_days_between_ranges = list(set(date_range) - set(company_leave_dates)) + total_working_days = len(working_days_between_ranges) + + return { + # Total working days on that period + "total_working_days": total_working_days, + # All the working dates between the start and end date + "working_days_on": working_days_between_ranges, + # All the company/holiday leave dates between the range + "company_leave_dates": company_leave_dates, + } + + +def get_leaves(employee, start_date, end_date): + """ + This method is used to return all the leaves taken by the employee + between the period. + + Args: + employee (obj): Employee model instance + start_date (obj): the start date from the data needed + end_date (obj): the end date till the date needed + """ + approved_leaves = employee.leaverequest_set.filter(status="approved") + paid_leave = 0 + unpaid_leave = 0 + paid_half = 0 + unpaid_half = 0 + paid_leave_dates = [] + unpaid_leave_dates = [] + company_leave_dates = get_working_days(start_date, end_date)["company_leave_dates"] + + if approved_leaves.exists(): + for instance in approved_leaves: + if instance.leave_type_id.payment == "paid": + # if the taken leave is paid + # for the start date + all_the_paid_leave_taken_dates = instance.requested_dates() + paid_leave_dates = paid_leave_dates + [ + date + for date in all_the_paid_leave_taken_dates + if start_date <= date <= end_date + ] + else: + # if the taken leave is unpaid + # for the start date + all_unpaid_leave_taken_dates = instance.requested_dates() + unpaid_leave_dates = unpaid_leave_dates + [ + date + for date in all_unpaid_leave_taken_dates + if start_date <= date <= end_date + ] + + half_day_data = find_half_day_leaves() + + unpaid_half = half_day_data["half_unpaid_leaves"] + paid_half = half_day_data["half_paid_leaves"] + + paid_leave_dates = list(set(paid_leave_dates) - set(company_leave_dates)) + unpaid_leave_dates = list(set(unpaid_leave_dates) - set(company_leave_dates)) + paid_leave = len(paid_leave_dates) - paid_half + unpaid_leave = len(unpaid_leave_dates) - unpaid_half + + return { + "paid_leave": paid_leave, + "unpaid_leaves": unpaid_leave, + "total_leaves": paid_leave + unpaid_leave, + # List of paid leave date between range + "paid_leave_dates": paid_leave_dates, + # List of un paid date between range + "unpaid_leave_dates": unpaid_leave_dates, + "leave_dates": unpaid_leave_dates + paid_leave_dates, + } + + +def get_attendance(employee, start_date, end_date): + """ + This method is used to render attendance details between the range + + Args: + employee (obj): Employee user instance + start_date (obj): start date of the period + end_date (obj): end date of the period + """ + + attendances_on_period = Attendance.objects.filter( + employee_id=employee, + attendance_date__range=(start_date, end_date), + attendance_validated=True, + ) + present_on = [attendance.attendance_date for attendance in attendances_on_period] + working_days_between_range = get_working_days(start_date, end_date)[ + "working_days_on" + ] + leave_dates = get_leaves(employee, start_date, end_date)["leave_dates"] + conflict_dates = list( + set(working_days_between_range) - set(attendances_on_period) - set(leave_dates) + ) + conflict_dates = conflict_dates + [ + date + for date in present_on + if date in get_holiday_dates() + or date + in list( + set( + get_company_leave_dates(start_date.year) + + get_company_leave_dates(end_date.year) + ) + ) + ] + + return { + "attendances_on_period": attendances_on_period, + "present_on": present_on, + "conflict_dates": conflict_dates, + } + + +def hourly_computation(employee, wage, start_date, end_date): + """ + Hourly salary computation for period. + + Args: + employee (obj): Employee instance + wage (float): wage of the employee + start_date (obj): start of the pay period + end_date (obj): end date of the period + """ + attendance_data = get_attendance(employee, start_date, end_date) + attendances_on_period = attendance_data["attendances_on_period"] + total_worked_hour_in_second = 0 + for attendance in attendances_on_period: + total_worked_hour_in_second = total_worked_hour_in_second + ( + attendance.at_work_second - attendance.overtime_second + ) + + # to find wage per second + # wage_per_second = wage_per_hour / total_seconds_in_hour + wage_in_second = wage / 3600 + basic_pay = float(f"{(wage_in_second * total_worked_hour_in_second):.2f}") + + return { + "basic_pay": basic_pay, + "loss_of_pay": 0, + } + + +def find_half_day_leaves(): + """ + This method is used to return the half day leave details + + Args: + employee (obj): Employee model instance + start_date (obj): start date of the period + end_date (obj): end date of the period + """ + paid_queryset = [] + unpaid_queryset = [] + + paid_leaves = list(filter(None, list(set(paid_queryset)))) + unpaid_leaves = list(filter(None, list(set(unpaid_queryset)))) + + paid_half = len(paid_leaves) * 0.5 + unpaid_half = len(unpaid_leaves) * 0.5 + queryset = paid_leaves + unpaid_leaves + total_leaves = len(queryset) * 0.50 + return { + "half_day_query_set": queryset, + "half_day_leaves": total_leaves, + "half_paid_leaves": paid_half, + "half_unpaid_leaves": unpaid_half, + } + + +def daily_computation(employee, wage, start_date, end_date): + """ + Hourly salary computation for period. + + Args: + employee (obj): Employee instance + wage (float): wage of the employee + start_date (obj): start of the pay period + end_date (obj): end date of the period + """ + working_day_data = get_working_days(start_date, end_date) + total_working_days = working_day_data["total_working_days"] + + leave_data = get_leaves(employee, start_date, end_date) + + contract = employee.contract_set.filter(contract_status="active").first() + basic_pay = wage * total_working_days + loss_of_pay = 0 + + date_range = get_date_range(start_date, end_date) + half_day_leaves_between_period_on_start_date = ( + employee.leaverequest_set.filter( + leave_type_id__payment="unpaid", + start_date__in=date_range, + status="approved", + ) + .exclude(start_date_breakdown="full_day") + .count() + ) + + half_day_leaves_between_period_on_end_date = ( + employee.leaverequest_set.filter( + leave_type_id__payment="unpaid", end_date__in=date_range, status="approved" + ) + .exclude(end_date_breakdown="full_day") + .exclude(start_date=F("end_date")) + .count() + ) + unpaid_half_leaves = ( + half_day_leaves_between_period_on_start_date + + half_day_leaves_between_period_on_end_date + ) * 0.5 + + contract = employee.contract_set.filter( + is_active=True, contract_status="active" + ).first() + + unpaid_leaves = leave_data["unpaid_leaves"] - unpaid_half_leaves + if contract.calculate_daily_leave_amount: + loss_of_pay = (unpaid_leaves) * wage + else: + fixed_penalty = contract.deduction_for_one_leave_amount + loss_of_pay = (unpaid_leaves) * fixed_penalty + if contract.deduct_leave_from_basic_pay: + basic_pay = basic_pay - loss_of_pay + + return { + "basic_pay": basic_pay, + "loss_of_pay": loss_of_pay, + } + + +def get_daily_salary(wage, wage_date) -> dict: + """ + This method is used to calculate daily salary for the date + """ + last_day = calendar.monthrange(wage_date.year, wage_date.month)[1] + end_date = date(wage_date.year, wage_date.month, last_day) + start_date = date(wage_date.year, wage_date.month, 1) + working_days = get_working_days(start_date, end_date)["total_working_days"] + day_wage = wage / working_days + + return { + "day_wage": day_wage, + } + + +def months_between_range(wage, start_date, end_date): + """ + This method is used to find the months between range + """ + months_data = [] + + for current_date in ( + start_date + relativedelta(months=i) + for i in range( + (end_date.year - start_date.year) * 12 + + end_date.month + - start_date.month + + 1 + ) + ): + month = current_date.month + year = current_date.year + + days_in_month = ( + current_date + relativedelta(day=1, months=1) - relativedelta(days=1) + ).day + + # Calculate the end date for the current month + current_end_date = current_date + relativedelta(day=days_in_month) + current_end_date = min(current_end_date, end_date) + working_days_on_month = get_working_days( + current_date.replace(day=1), current_date.replace(day=days_in_month) + )["total_working_days"] + + month_start_date = ( + date(year=year, month=month, day=1) + if start_date < date(year=year, month=month, day=1) + else start_date + ) + total_working_days_on_period = get_working_days( + month_start_date, current_end_date + )["total_working_days"] + + month_info = { + "month": month, + "year": year, + "days": days_in_month, + "start_date": month_start_date.strftime("%Y-%m-%d"), + "end_date": current_end_date.strftime("%Y-%m-%d"), + # month period + "working_days_on_period": total_working_days_on_period, + "working_days_on_month": working_days_on_month, + "per_day_amount": wage / working_days_on_month, + } + + months_data.append(month_info) + # Set the start date for the next month as the first day of the next month + current_date = (current_date + relativedelta(day=1, months=1)).replace(day=1) + + return months_data + + +def monthly_computation(employee, wage, start_date, end_date): + """ + Hourly salary computation for period. + + Args: + employee (obj): Employee instance + wage (float): wage of the employee + start_date (obj): start of the pay period + end_date (obj): end date of the period + """ + basic_pay = 0 + month_data = months_between_range(wage, start_date, end_date) + + leave_data = get_leaves(employee, start_date, end_date) + + for data in month_data: + basic_pay = basic_pay + ( + data["working_days_on_period"] * data["per_day_amount"] + ) + + contract = employee.contract_set.filter(contract_status="active").first() + loss_of_pay = 0 + date_range = get_date_range(start_date, end_date) + half_day_leaves_between_period_on_start_date = ( + employee.leaverequest_set.filter( + leave_type_id__payment="unpaid", + start_date__in=date_range, + status="approved", + ) + .exclude(start_date_breakdown="full_day") + .count() + ) + + half_day_leaves_between_period_on_end_date = ( + employee.leaverequest_set.filter( + leave_type_id__payment="unpaid", end_date__in=date_range, status="approved" + ) + .exclude(end_date_breakdown="full_day") + .exclude(start_date=F("end_date")) + .count() + ) + unpaid_half_leaves = ( + half_day_leaves_between_period_on_start_date + + half_day_leaves_between_period_on_end_date + ) * 0.5 + + contract = employee.contract_set.filter( + is_active=True, contract_status="active" + ).first() + unpaid_leaves = abs(leave_data["unpaid_leaves"] - unpaid_half_leaves) + daily_computed_salary = get_daily_salary(wage=wage, wage_date=start_date)[ + "day_wage" + ] + if contract.calculate_daily_leave_amount: + loss_of_pay = (unpaid_leaves) * daily_computed_salary + else: + fixed_penalty = contract.deduction_for_one_leave_amount + loss_of_pay = (unpaid_leaves) * fixed_penalty + + if contract.deduct_leave_from_basic_pay: + basic_pay = basic_pay - loss_of_pay + return { + "basic_pay": basic_pay, + "loss_of_pay": loss_of_pay, + "month_data": month_data, + } + + +def compute_salary_on_period(employee, start_date, end_date, wage=None): + """ + This method is used to compute salary on the start to end date period + + Args: + employee (obj): Employee instance + start_date (obj): start date of the period + end_date (obj): end date of the period + """ + contract = Contract.objects.filter( + employee_id=employee, contract_status="active" + ).first() + if contract is None: + return contract + + wage = contract.wage if wage is None else wage + wage_type = contract.wage_type + data = None + if wage_type == "hourly": + data = hourly_computation(employee, wage, start_date, end_date) + month_data = months_between_range(wage, start_date, end_date) + data["month_data"] = month_data + elif wage_type == "daily": + data = daily_computation(employee, wage, start_date, end_date) + month_data = months_between_range(wage, start_date, end_date) + data["month_data"] = month_data + + else: + data = monthly_computation(employee, wage, start_date, end_date) + data["contract_wage"] = wage + data["contract"] = contract + return data + + +def paginator_qry(qryset, page_number): + """ + This method is used to paginate queryset + """ + paginator = Paginator(qryset, 50) + qryset = paginator.get_page(page_number) + return qryset diff --git a/payroll/methods/payslip_calc.py b/payroll/methods/payslip_calc.py new file mode 100644 index 000000000..c55bc21ad --- /dev/null +++ b/payroll/methods/payslip_calc.py @@ -0,0 +1,822 @@ +""" +This module contains various functions for calculating payroll-related information for employees. +It includes functions for calculating gross pay, taxable gross pay, allowances, tax deductions, +pre-tax deductions, and post-tax deductions. + +""" +import operator +import contextlib +from attendance.models import Attendance +from payroll.models import models +from payroll.models.models import Contract, Allowance +from payroll.methods.limits import compute_limit + +operator_mapping = { + "equal": operator.eq, + "notequal": operator.ne, + "lt": operator.lt, + "gt": operator.gt, + "le": operator.le, + "ge": operator.ge, + "icontains": operator.contains, +} +filter_mapping = { + "work_type_id": { + "filter": lambda employee, allowance, start_date, end_date: { + "employee_id": employee, + "work_type_id__id": allowance.work_type_id.id, + "attendance_date__range": (start_date, end_date), + "attendance_validated": True, + } + }, + "shift_id": { + "filter": lambda employee, allowance, start_date, end_date: { + "employee_id": employee, + "shift_id__id": allowance.shift_id.id, + "attendance_date__range": (start_date, end_date), + "attendance_validated": True, + } + }, + "overtime": { + "filter": lambda employee, allowance, start_date, end_date: { + "employee_id": employee, + "attendance_date__range": (start_date, end_date), + "attendance_overtime_approve": True, + "attendance_validated": True, + } + }, + "attendance": { + "filter": lambda employee, allowance, start_date, end_date: { + "employee_id": employee, + "attendance_date__range": (start_date, end_date), + "attendance_validated": True, + } + }, +} + + +def dynamic_attr(obj, attribute_path): + """ + Retrieves the value of a nested attribute from a related object dynamically. + + Args: + obj: The base object from which to start accessing attributes. + attribute_path (str): The path of the nested attribute to retrieve, using + double underscores ('__') to indicate relationship traversal. + + Returns: + The value of the nested attribute if it exists, or None if it doesn't exist. + """ + attributes = attribute_path.split("__") + + for attr in attributes: + with contextlib.suppress(Exception): + if isinstance(obj.first(), Contract): + obj = obj.filter(is_active=True).first() + + obj = getattr(obj, attr, None) + if obj is None: + break + return obj + + +def calculate_gross_pay(*_args, **kwargs): + """ + Calculate the gross pay for an employee within a given date range. + + Args: + employee: The employee object for whom to calculate the gross pay. + start_date: The start date of the period for which to calculate the gross pay. + end_date: The end date of the period for which to calculate the gross pay. + + Returns: + A dictionary containing the gross pay as the "gross_pay" key. + + """ + basic_pay = kwargs["basic_pay"] + total_allowance = kwargs["total_allowance"] + # basic_pay = compute_salary_on_period(employee, start_date, end_date)["basic_pay"] + gross_pay = total_allowance + basic_pay + return { + "gross_pay": gross_pay, + "basic_pay": basic_pay, + } + + +def calculate_taxable_gross_pay(*_args, **kwargs): + """ + Calculate the taxable gross pay for an employee within a given date range. + + Args: + employee: The employee object for whom to calculate the taxable gross pay. + start_date: The start date of the period for which to calculate the taxable gross pay. + end_date: The end date of the period for which to calculate the taxable gross pay. + + Returns: + A dictionary containing the taxable gross pay as the "taxable_gross_pay" key. + + """ + allowances = kwargs["allowances"] + gross_pay = calculate_gross_pay(**kwargs) + gross_pay = gross_pay["gross_pay"] + pre_tax_deductions = calculate_pre_tax_deduction(**kwargs) + non_taxable_allowance_total = sum( + allowance["amount"] + for allowance in allowances["allowances"] + if not allowance["is_taxable"] + ) + pretax_deduction_total = sum( + deduction["amount"] + for deduction in pre_tax_deductions["pretax_deductions"] + if deduction["is_pretax"] + ) + taxable_gross_pay = gross_pay - non_taxable_allowance_total - pretax_deduction_total + return { + "taxable_gross_pay": taxable_gross_pay, + } + + +def calculate_allowance(**kwargs): + """ + Calculate the allowances for an employee within the specified payroll period. + + Args: + employee (Employee): The employee object for which to calculate the allowances. + start_date (datetime.date): The start date of the payroll period. + end_date (datetime.date): The end date of the payroll period. + + """ + employee = kwargs["employee"] + start_date = kwargs["start_date"] + end_date = kwargs["end_date"] + basic_pay = kwargs["basic_pay"] + day_dict = kwargs["day_dict"] + specific_allowances = Allowance.objects.filter(specific_employees=employee) + conditional_allowances = Allowance.objects.filter(is_condition_based=True).exclude( + exclude_employees=employee + ) + active_employees = Allowance.objects.filter(include_active_employees=True).exclude( + exclude_employees=employee + ) + + allowances = specific_allowances | conditional_allowances | active_employees + + allowances = allowances.exclude(one_time_date__lt=start_date).exclude( + one_time_date__gt=end_date + ) + + employee_allowances = [] + tax_allowances = [] + no_tax_allowances = [] + tax_allowances_amt = [] + no_tax_allowances_amt = [] + # Append allowances based on condition, or unconditionally to employee + for allowance in allowances: + if allowance.is_condition_based: + condition_field = allowance.field + condition_operator = allowance.condition + condition_value = allowance.value + employee_value = dynamic_attr(employee, condition_field) + operator_func = operator_mapping.get(condition_operator) + if employee_value is not None: + condition_value = type(employee_value)(condition_value) + if operator_func(employee_value, condition_value): + employee_allowances.append(allowance) + else: + if allowance.based_on in filter_mapping: + filter_params = filter_mapping[allowance.based_on]["filter"]( + employee, allowance, start_date, end_date + ) + if Attendance.objects.filter(**filter_params): + employee_allowances.append(allowance) + else: + employee_allowances.append(allowance) + # Filter and append taxable allowance and not taxable allowance + for allowance in employee_allowances: + if allowance.is_taxable: + tax_allowances.append(allowance) + else: + no_tax_allowances.append(allowance) + # Find and append the amount of tax_allowances + for allowance in tax_allowances: + if allowance.is_fixed: + amount = allowance.amount + kwargs["amount"] = amount + kwargs["component"] = allowance + + amount = if_condition_on(**kwargs) + tax_allowances_amt.append(amount) + else: + calculation_function = calculation_mapping.get(allowance.based_on) + amount = calculation_function( + **{ + "employee": employee, + "start_date": start_date, + "end_date": end_date, + "component": allowance, + "allowances": None, + "total_allowance": None, + "basic_pay": basic_pay, + "day_dict": day_dict, + }, + ) + kwargs["amount"] = amount + kwargs["component"] = allowance + amount = if_condition_on(**kwargs) + tax_allowances_amt.append(amount) + # Find and append the amount of not tax_allowances + for allowance in no_tax_allowances: + if allowance.is_fixed: + amount = allowance.amount + kwargs["amount"] = amount + kwargs["component"] = allowance + amount = if_condition_on(**kwargs) + no_tax_allowances_amt.append(amount) + + else: + calculation_function = calculation_mapping.get(allowance.based_on) + amount = calculation_function( + **{ + "employee": employee, + "start_date": start_date, + "end_date": end_date, + "component": allowance, + "day_dict": day_dict, + "basic_pay": basic_pay, + } + ) + kwargs["amount"] = amount + kwargs["component"] = allowance + amount = if_condition_on(**kwargs) + no_tax_allowances_amt.append(amount) + serialized_allowances = [] + + # Serialize taxable allowances + for allowance, amount in zip(tax_allowances, tax_allowances_amt): + serialized_allowance = { + "title": allowance.title, + "is_taxable": allowance.is_taxable, + "amount": amount, + } + serialized_allowances.append(serialized_allowance) + + # Serialize no-taxable allowances + for allowance, amount in zip(no_tax_allowances, no_tax_allowances_amt): + serialized_allowance = { + "title": allowance.title, + "is_taxable": allowance.is_taxable, + "amount": amount, + } + serialized_allowances.append(serialized_allowance) + return {"allowances": serialized_allowances} + + +def calculate_tax_deduction(*_args, **kwargs): + """ + Calculates the tax deductions for the specified employee within the given date range. + + Args: + employee (Employee): The employee for whom the tax deductions are being calculated. + start_date (date): The start date of the tax deduction period. + end_date (date): The end date of the tax deduction period. + allowances (dict): Dictionary containing the calculated allowances. + total_allowance (float): The total amount of allowances. + basic_pay (float): The basic pay amount. + day_dict (dict): Dictionary containing working day details. + + Returns: + dict: A dictionary containing the serialized tax deductions. + """ + employee = kwargs["employee"] + start_date = kwargs["start_date"] + end_date = kwargs["end_date"] + specific_deductions = models.Deduction.objects.filter( + specific_employees=employee, is_pretax=False, is_tax=True + ) + active_employee_deduction = models.Deduction.objects.filter( + include_active_employees=True, is_pretax=False, is_tax=True + ).exclude(exclude_employees=employee) + deductions = specific_deductions | active_employee_deduction + deductions = ( + deductions.exclude(one_time_date__lt=start_date) + .exclude(one_time_date__gt=end_date) + .exclude(update_compensation__isnull=False) + ) + deductions_amt = [] + serialized_deductions = [] + for deduction in deductions: + calculation_function = calculation_mapping.get(deduction.based_on) + amount = calculation_function( + **{ + "employee": employee, + "start_date": start_date, + "end_date": end_date, + "component": deduction, + "allowances": kwargs["allowances"], + "total_allowance": kwargs["total_allowance"], + "basic_pay": kwargs["basic_pay"], + "day_dict": kwargs["day_dict"], + } + ) + kwargs["amount"] = amount + kwargs["component"] = deduction + amount = if_condition_on(**kwargs) + deductions_amt.append(amount) + for deduction, amount in zip(deductions, deductions_amt): + serialized_deduction = { + "title": deduction.title, + "is_tax": deduction.is_tax, + "amount": amount, + } + serialized_deductions.append(serialized_deduction) + return {"tax_deductions": serialized_deductions} + + +def calculate_pre_tax_deduction(*_args, **kwargs): + """ + This function retrieves pre-tax deductions applicable to the employee and calculates + their amounts + + Args: + employee: The employee object for whom to calculate the pre-tax deductions. + start_date: The start date of the period for which to calculate the pre-tax deductions. + end_date: The end date of the period for which to calculate the pre-tax deductions. + + Returns: + A dictionary containing the pre-tax deductions as the "pretax_deductions" key. + + """ + employee = kwargs["employee"] + start_date = kwargs["start_date"] + end_date = kwargs["end_date"] + + specific_deductions = models.Deduction.objects.filter( + specific_employees=employee, is_pretax=True, is_tax=False + ) + conditional_deduction = models.Deduction.objects.filter( + is_condition_based=True, is_pretax=True, is_tax=False + ).exclude(exclude_employees=employee) + active_employee_deduction = models.Deduction.objects.filter( + include_active_employees=True, is_pretax=True, is_tax=False + ).exclude(exclude_employees=employee) + + deductions = specific_deductions | conditional_deduction | active_employee_deduction + deductions = ( + deductions.exclude(one_time_date__lt=start_date) + .exclude(one_time_date__gt=end_date) + .exclude(update_compensation__isnull=False) + ) + pre_tax_deductions = [] + pre_tax_deductions_amt = [] + serialized_deductions = [] + + for deduction in deductions: + if deduction.is_condition_based: + condition_field = deduction.field + condition_operator = deduction.condition + condition_value = deduction.value + employee_value = dynamic_attr(employee, condition_field) + operator_func = operator_mapping.get(condition_operator) + + if employee_value is not None: + condition_value = type(employee_value)(condition_value) + if operator_func(employee_value, condition_value): + pre_tax_deductions.append(deduction) + else: + pre_tax_deductions.append(deduction) + + for deduction in pre_tax_deductions: + if deduction.is_fixed: + kwargs["amount"] = deduction.amount + kwargs["component"] = deduction + pre_tax_deductions_amt.append(if_condition_on(**kwargs)) + else: + calculation_function = calculation_mapping.get(deduction.based_on) + amount = calculation_function( + **{ + "employee": employee, + "start_date": start_date, + "end_date": end_date, + "component": deduction, + "allowances": kwargs["allowances"], + "total_allowance": kwargs["total_allowance"], + "basic_pay": kwargs["basic_pay"], + "day_dic": kwargs["day_dict"], + } + ) + kwargs["amount"] = amount + kwargs["component"] = deduction + pre_tax_deductions_amt.append(if_condition_on(**kwargs)) + for deduction, amount in zip(pre_tax_deductions, pre_tax_deductions_amt): + serialized_deduction = { + "title": deduction.title, + "is_pretax": deduction.is_pretax, + "amount": amount, + } + serialized_deductions.append(serialized_deduction) + return {"pretax_deductions": serialized_deductions} + + +def calculate_post_tax_deduction(*_args, **kwargs): + """ + This function retrieves post-tax deductions applicable to the employee and calculates + their amounts + + Args: + employee: The employee object for whom to calculate the pre-tax deductions. + start_date: The start date of the period for which to calculate the pre-tax deductions. + end_date: The end date of the period for which to calculate the pre-tax deductions. + + Returns: + A dictionary containing the pre-tax deductions as the "post_tax_deductions" key. + + """ + employee = kwargs["employee"] + start_date = kwargs["start_date"] + end_date = kwargs["end_date"] + allowances = kwargs["allowances"] + total_allowance = kwargs["total_allowance"] + basic_pay = kwargs["basic_pay"] + day_dict = kwargs["day_dict"] + specific_deductions = models.Deduction.objects.filter( + specific_employees=employee, is_pretax=False, is_tax=False + ) + conditional_deduction = models.Deduction.objects.filter( + is_condition_based=True, is_pretax=False, is_tax=False + ).exclude(exclude_employees=employee) + active_employee_deduction = models.Deduction.objects.filter( + include_active_employees=True, is_pretax=False, is_tax=False + ).exclude(exclude_employees=employee) + deductions = specific_deductions | conditional_deduction | active_employee_deduction + deductions = ( + deductions.exclude(one_time_date__lt=start_date) + .exclude(one_time_date__gt=end_date) + .exclude(update_compensation__isnull=False) + ) + post_tax_deductions = [] + post_tax_deductions_amt = [] + serialized_deductions = [] + serialized_net_pay_deductions = [] + + for deduction in deductions: + if deduction.is_condition_based: + condition_field = deduction.field + condition_operator = deduction.condition + condition_value = deduction.value + employee_value = dynamic_attr(employee, condition_field) + operator_func = operator_mapping.get(condition_operator) + if employee_value is not None: + condition_value = type(employee_value)(condition_value) + if operator_func(employee_value, condition_value): + post_tax_deductions.append(deduction) + else: + post_tax_deductions.append(deduction) + for deduction in post_tax_deductions: + if deduction.is_fixed: + amount = deduction.amount + kwargs["amount"] = amount + kwargs["component"] = deduction + amount = if_condition_on(**kwargs) + post_tax_deductions_amt.append(amount) + else: + if deduction.based_on != "net_pay": + calculation_function = calculation_mapping.get(deduction.based_on) + amount = calculation_function( + **{ + "employee": employee, + "start_date": start_date, + "end_date": end_date, + "component": deduction, + "allowances": allowances, + "total_allowance": total_allowance, + "basic_pay": basic_pay, + "day_dict": day_dict, + } + ) + kwargs["amount"] = amount + kwargs["component"] = deduction + amount = if_condition_on(**kwargs) + post_tax_deductions_amt.append(amount) + + for deduction, amount in zip(post_tax_deductions, post_tax_deductions_amt): + serialized_deduction = { + "title": deduction.title, + "is_pretax": deduction.is_pretax, + "amount": amount, + } + serialized_deductions.append(serialized_deduction) + for deduction in post_tax_deductions: + if deduction.based_on == "net_pay": + serialized_net_pay_deduction = {"deduction": deduction} + serialized_net_pay_deductions.append(serialized_net_pay_deduction) + return { + "post_tax_deductions": serialized_deductions, + "net_pay_deduction": serialized_net_pay_deductions, + } + + +def calculate_net_pay_deduction(net_pay, net_pay_deductions, day_dict): + """ + Calculates the deductions based on the net pay amount. + + Args: + net_pay (float): The net pay amount. + net_pay_deductions (list): List of net pay deductions. + day_dict (dict): Dictionary containing working day details. + + Returns: + dict: A dictionary containing the serialized deductions and deduction amount. + """ + serialized_net_pay_deductions = [] + deductions = [item["deduction"] for item in net_pay_deductions] + deduction_amt = [] + for deduction in deductions: + amount = calculate_based_on_net_pay(deduction, net_pay, day_dict) + deduction_amt.append(amount) + net_deduction = 0 + for deduction, amount in zip(deductions, deduction_amt): + serialized_deduction = { + "title": deduction.title, + "is_pretax": deduction.is_pretax, + "amount": amount, + } + net_deduction = amount + net_deduction + serialized_net_pay_deductions.append(serialized_deduction) + return { + "net_pay_deductions": serialized_net_pay_deductions, + "net_deduction": net_deduction, + } + + +def if_condition_on(*_args, **kwargs): + """ + This method is used to check the allowance or deduction through the the conditions + + Args: + employee (obj): Employee instance + amount (float): calculated amount of the component + component (obj): Allowance or Deduction instance + start_date (obj): Start date of the period + end_date (obj): End date of the period + + Returns: + _type_: _description_ + """ + component = kwargs["component"] + basic_pay = kwargs["basic_pay"] + amount = kwargs["amount"] + gross_pay = 0 + amount = float(amount) + if not isinstance(component, Allowance): + gross_pay = calculate_gross_pay( + **kwargs, + )["gross_pay"] + operator_func = operator_mapping.get(component.if_condition) + condition_value = basic_pay if component.if_choice == "basic_pay" else gross_pay + if not operator_func(condition_value, component.if_amount): + amount = 0 + return amount + + +def calculate_based_on_basic_pay(*_args, **kwargs): + """ + Calculate the amount of an allowance or deduction based on the employee's + basic pay with rate provided in the allowance or deduction object + + Args: + employee (Employee): The employee object for whom to calculate the amount. + start_date (datetime.date): The start date of the period for which to calculate the amount. + end_date (datetime.date): The end date of the period for which to calculate the amount. + component (Component): The allowance or deduction object that defines the rate or percentage + to apply. + + Returns: + The calculated allowance or deduction amount based on the employee's basic pay. + + """ + component = kwargs["component"] + basic_pay = kwargs["basic_pay"] + day_dict = kwargs["day_dict"] + rate = component.rate + amount = basic_pay * rate / 100 + amount = compute_limit(component, amount, day_dict) + + return amount + + +def calculate_based_on_gross_pay(*_args, **kwargs): + """ + Calculate the amount of an allowance or deduction based on the employee's gross pay with rate + provided in the allowance or deduction object + + Args: + employee (Employee): The employee object for whom to calculate the amount. + start_date (datetime.date): The start date of the period for which to calculate the amount. + end_date (datetime.date): The end date of the period for which to calculate the amount. + component (Component): The allowance or deduction object that defines the rate or percentage + to apply. + + Returns:+- + The calculated allowance or deduction amount based on the employee's gross pay. + + """ + + component = kwargs["component"] + gross_pay = calculate_gross_pay(**kwargs) + rate = component.rate + amount = gross_pay["gross_pay"] * rate / 100 + return amount + + +def calculate_based_on_taxable_gross_pay(*_args, **kwargs): + """ + Calculate the amount of an allowance or deduction based on the employee's taxable gross pay with + rate provided in the allowance or deduction object + + Args: + employee (Employee): The employee object for whom to calculate the amount. + start_date (datetime.date): The start date of the period for which to calculate the amount. + end_date (datetime.date): The end date of the period for which to calculate the amount. + component (Component): The allowance or deduction object that defines the rate or percentage + to apply. + + Returns: + The calculated component amount based on the employee's taxable gross pay. + + """ + component = kwargs["component"] + taxable_gross_pay = calculate_taxable_gross_pay(**kwargs) + taxable_gross_pay = taxable_gross_pay["taxable_gross_pay"] + rate = component.rate + amount = taxable_gross_pay * rate / 100 + return amount + + +def calculate_based_on_net_pay(component, net_pay, day_dict): + """ + Calculates the amount of an allowance or deduction based on the net pay of an employee. + + Args: + component (Allowance or Deduction): The allowance or deduction object. + net_pay (float): The net pay of the employee. + day_dict (dict): Dictionary containing working day details. + + Returns: + float: The calculated amount of the component based on the net pay. + """ + rate = float(component.rate) + amount = net_pay * rate / 100 + amount = compute_limit(component, amount, day_dict) + + amount = compute_limit(component, amount, day_dict) + return amount + + +def calculate_based_on_attendance(*_args, **kwargs): + """ + Calculates the amount of an allowance or deduction based on the attendance of an employee. + + Args: + employee (Employee): The employee for whom the attendance is being calculated. + start_date (date): The start date of the attendance period. + end_date (date): The end date of the attendance period. + component (Allowance or Deduction): The allowance or deduction object. + day_dict (dict): Dictionary containing working day details. + + Returns: + float: The calculated amount of the component based on the attendance. + """ + employee = kwargs["employee"] + start_date = kwargs["start_date"] + end_date = kwargs["end_date"] + component = kwargs["component"] + day_dict = kwargs["day_dict"] + + count = Attendance.objects.filter( + employee_id=employee, + attendance_date__range=(start_date, end_date), + attendance_validated=True, + ).count() + amount = count * component.per_attendance_fixed_amount + + amount = compute_limit(component, amount, day_dict) + + return amount + + +def calculate_based_on_shift(*_args, **kwargs): + """ + Calculates the amount of an allowance or deduction based on the employee's shift attendance. + + Args: + employee (Employee): The employee for whom the shift attendance is being calculated. + start_date (date): The start date of the attendance period. + end_date (date): The end date of the attendance period. + component (Allowance or Deduction): The allowance or deduction object. + day_dict (dict): Dictionary containing working day details. + + Returns: + float: The calculated amount of the component based on the shift attendance. + """ + employee = kwargs["employee"] + start_date = kwargs["start_date"] + end_date = kwargs["end_date"] + component = kwargs["component"] + day_dict = kwargs["day_dict"] + + shift_id = component.shift_id.id + count = Attendance.objects.filter( + employee_id=employee, + shift_id=shift_id, + attendance_date__range=(start_date, end_date), + attendance_validated=True, + ).count() + amount = count * component.shift_per_attendance_amount + + amount = compute_limit(component, amount, day_dict) + return amount + + +def calculate_based_on_overtime(*_args, **kwargs): + """ + Calculates the amount of an allowance or deduction based on employee's overtime hours. + + Args: + employee (Employee): The employee for whom the overtime is being calculated. + start_date (date): The start date of the overtime period. + end_date (date): The end date of the overtime period. + component (Allowance or Deduction): The allowance or deduction object. + day_dict (dict): Dictionary containing working day details. + + Returns: + float: The calculated amount of the allowance or deduction based on the overtime hours. + """ + + employee = kwargs["employee"] + start_date = kwargs["start_date"] + end_date = kwargs["end_date"] + component = kwargs["component"] + day_dict = kwargs["day_dict"] + + attendances = Attendance.objects.filter( + employee_id=employee, + attendance_date__range=(start_date, end_date), + attendance_overtime_approve=True, + ) + overtime = sum(attendance.overtime_second for attendance in attendances) + amount_per_hour = component.amount_per_one_hr + amount_per_second = amount_per_hour / (60 * 60) + amount = overtime * amount_per_second + amount = round(amount, 2) + + amount = compute_limit(component, amount, day_dict) + + return amount + + +def calculate_based_on_work_type(*_args, **kwargs): + """ + Calculates the amount of an allowance or deduction based on the employee's + attendance with a specific work type. + + Args: + employee (Employee): The employee for whom the attendance is being considered. + start_date (date): The start date of the attendance period. + end_date (date): The end date of the attendance period. + component (Allowance or Deduction): The allowance or deduction object. + day_dict (dict): Dictionary containing working day details. + + Returns: + float: The calculated amount of the allowance or deduction based on the + attendance with the specified work type. + """ + employee = kwargs["employee"] + start_date = kwargs["start_date"] + end_date = kwargs["end_date"] + component = kwargs["component"] + day_dict = kwargs["day_dict"] + + work_type_id = component.work_type_id.id + count = Attendance.objects.filter( + employee_id=employee, + work_type_id=work_type_id, + attendance_date__range=(start_date, end_date), + attendance_validated=True, + ).count() + amount = count * component.work_type_per_attendance_amount + + amount = compute_limit(component, amount, day_dict) + + return amount + + +calculation_mapping = { + "basic_pay": calculate_based_on_basic_pay, + "gross_pay": calculate_based_on_gross_pay, + "taxable_gross_pay": calculate_based_on_taxable_gross_pay, + "net_pay": calculate_based_on_net_pay, + "attendance": calculate_based_on_attendance, + "shift_id": calculate_based_on_shift, + "overtime": calculate_based_on_overtime, + "work_type_id": calculate_based_on_work_type, +} diff --git a/payroll/methods/tax_calc.py b/payroll/methods/tax_calc.py new file mode 100644 index 000000000..43114533c --- /dev/null +++ b/payroll/methods/tax_calc.py @@ -0,0 +1,78 @@ +""" +Module: payroll.tax_calc + +This module contains a function for calculating the taxable amount for an employee +based on their contract details and income information. +""" +import datetime +from payroll.methods.payslip_calc import ( + calculate_taxable_gross_pay, + calculate_gross_pay, +) +from payroll.models.models import Contract +from payroll.models.tax_models import TaxBracket + + +def calculate_taxable_amount(**kwargs): + """Calculate the taxable amount for a given employee within a specific period. + + Args: + employee (int): The ID of the employee. + start_date (datetime.date): The start date of the period. + end_date (datetime.date): The end date of the period. + allowances (int): The number of allowances claimed by the employee. + total_allowance (float): The total allowance amount. + basic_pay (float): The basic pay amount. + day_dict (dict): A dictionary containing specific day-related information. + + Returns: + float: The federal tax amount for the specified period. + """ + employee = kwargs["employee"] + start_date = kwargs["start_date"] + end_date = kwargs["end_date"] + basic_pay = kwargs["basic_pay"] + contract = Contract.objects.filter(employee_id=employee,contract_status='active').first() + filing = contract.filing_status + federal_tax_for_period = 0 + if filing is not None: + based = filing.based_on + num_days = (end_date - start_date).days + 1 + calculation_functions = { + "taxable_gross_pay": calculate_taxable_gross_pay, + "gross_pay": calculate_gross_pay, + } + if based in calculation_functions: + calculation_function = calculation_functions[based] + income = calculation_function(**kwargs) + income = float(income[based]) + else: + income = float(basic_pay) + year = end_date.year + check_start_date = datetime.date(year, 1, 1) + check_end_date = datetime.date(year, 12, 31) + total_days = (check_end_date - check_start_date).days + 1 + yearly_income = income / num_days * total_days + yearly_income = round(yearly_income, 2) + tax_brackets = TaxBracket.objects.filter(filing_status_id=filing).order_by( + "min_income" + ) + federal_tax = 0 + remaining_income = yearly_income + if tax_brackets.exists(): + if tax_brackets.first().min_income <= yearly_income: + for tax_bracket in tax_brackets: + min_income = tax_bracket.min_income + max_income = tax_bracket.max_income + tax_rate = tax_bracket.tax_rate + if remaining_income <= 0: + break + taxable_amount = min(remaining_income, max_income - min_income) + tax_amount = taxable_amount * tax_rate / 100 + federal_tax += tax_amount + remaining_income -= taxable_amount + daily_federal_tax = federal_tax / total_days + federal_tax_for_period = daily_federal_tax * num_days + else: + federal_tax_for_period = 0 + return federal_tax_for_period diff --git a/payroll/migrations/__init__.py b/payroll/migrations/__init__.py new file mode 100644 index 000000000..990750e89 --- /dev/null +++ b/payroll/migrations/__init__.py @@ -0,0 +1,3 @@ +from payroll import settings +from payroll import context_processors +from payroll import scheduler diff --git a/payroll/models/__init__.py b/payroll/models/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/payroll/models/models.py b/payroll/models/models.py new file mode 100644 index 000000000..51b80f3d5 --- /dev/null +++ b/payroll/models/models.py @@ -0,0 +1,1183 @@ +""" +models.py +Used to register models +""" +from datetime import date, datetime, timedelta +from django import forms +from django.db import models +from django.dispatch import receiver +from django.core.exceptions import ValidationError +from django.utils.translation import gettext_lazy as _ +from django.db.models.signals import pre_save, pre_delete +from django.http import QueryDict +from employee.models import EmployeeWorkInformation +from employee.models import Employee, Department, JobPosition +from base.models import EmployeeShift, WorkType, JobRole +from attendance.models import ( + validate_time_format, +) +from attendance.models import ( + Attendance, + strtime_seconds, +) + +from leave.models import LeaveRequest + + +# Create your models here. + + +def get_date_range(start_date, end_date): + """ + Returns a list of all dates within a given date range. + + Args: + start_date (date): The start date of the range. + end_date (date): The end date of the range. + + Returns: + list: A list of date objects representing all dates within the range. + + Example: + start_date = date(2023, 1, 1) + end_date = date(2023, 1, 10) + date_range = get_date_range(start_date, end_date) + for date_obj in date_range: + print(date_obj) + """ + date_list = [] + delta = end_date - start_date + + for i in range(delta.days + 1): + current_date = start_date + timedelta(days=i) + date_list.append(current_date) + + return date_list + + +class FilingStatus(models.Model): + """ + FilingStatus model + """ + + based_on_choice = [ + ("basic_pay", _("Basic Pay")), + ("gross_pay", _("Gross Pay")), + ("taxable_gross_pay", _("Taxable Gross Pay")), + ] + filing_status = models.CharField( + max_length=30, + blank=False, + verbose_name=_("Filing status"), + ) + based_on = models.CharField( + max_length=255, + choices=based_on_choice, + null=False, + blank=False, + default="taxable_gross_pay", + verbose_name=_("Based on"), + ) + description = models.TextField( + blank=True, + verbose_name=_("Description"), + ) + + objects = models.Manager() + + def __str__(self) -> str: + return str(self.filing_status) + + +class Contract(models.Model): + """ + Contract Model + """ + + COMPENSATION_CHOICES = ( + ("salary", _("Salary")), + ("hourly", _("Hourly")), + ("commission", _("Commission")), + ) + + PAY_FREQUENCY_CHOICES = ( + ("weekly", _("Weekly")), + ("monthly", _("Monthly")), + ("semi_monthly", _("Semi-Monthly")), + ) + WAGE_CHOICES = ( + ("hourly", _("Hourly")), + ("daily", _("Daily")), + ("monthly", _("Monthly")), + ) + CONTRACT_STATUS_CHOICES = ( + ("draft", _("Draft")), + ("active", _("Active")), + ("expired", _("Expired")), + ("terminated", _("Terminated")), + ) + + contract_name = models.CharField(max_length=250, help_text=_("Contract Title")) + employee_id = models.ForeignKey( + Employee, + on_delete=models.CASCADE, + related_name="contract_set", + verbose_name=_("Employee"), + ) + contract_start_date = models.DateField() + contract_end_date = models.DateField(null=True, blank=True) + wage_type = models.CharField( + choices=WAGE_CHOICES, max_length=250, default="monthly" + ) + wage = models.FloatField(verbose_name=_("Basic Salary"), null=True, default=0) + calculate_daily_leave_amount = models.BooleanField(default=True) + deduction_for_one_leave_amount = models.FloatField(null=True, blank=True, default=0) + deduct_leave_from_basic_pay = models.BooleanField(default=True) + department = models.ForeignKey( + Department, + on_delete=models.DO_NOTHING, + null=True, + blank=True, + related_name="contracts", + ) + job_position = models.ForeignKey( + JobPosition, + on_delete=models.DO_NOTHING, + null=True, + blank=True, + related_name="contracts", + ) + job_role = models.ForeignKey( + JobRole, + on_delete=models.DO_NOTHING, + null=True, + blank=True, + related_name="contracts", + ) + shift = models.ForeignKey( + EmployeeShift, + on_delete=models.DO_NOTHING, + null=True, + blank=True, + related_name="contracts", + ) + work_type = models.ForeignKey( + WorkType, + on_delete=models.DO_NOTHING, + null=True, + blank=True, + related_name="contracts", + ) + filing_status = models.ForeignKey( + FilingStatus, + on_delete=models.DO_NOTHING, + related_name="contracts", + null=True, + blank=True, + ) + pay_frequency = models.CharField( + max_length=20, null=True, choices=PAY_FREQUENCY_CHOICES, default="monthly" + ) + contract_document = models.FileField(upload_to="uploads/", null=True, blank=True) + is_active = models.BooleanField(default=True) + contract_status = models.CharField( + choices=CONTRACT_STATUS_CHOICES, max_length=250, default="draft" + ) + note = models.TextField(null=True, blank=True) + + objects = models.Manager() + + def __str__(self) -> str: + return f"{self.contract_name} -{self.contract_start_date} - {self.contract_end_date}" + + def clean(self): + if self.contract_end_date is not None: + if self.contract_end_date < self.contract_start_date: + raise ValidationError( + {"contract_end_date": _("End date must be greater than start date")} + ) + if ( + self.contract_status == "active" + and Contract.objects.filter( + employee_id=self.employee_id, contract_status="active" + ) + .exclude(id=self.id) + .count() + >= 1 + ): + raise forms.ValidationError( + _("An active contract already exists for this employee.") + ) + if ( + self.contract_status == "draft" + and Contract.objects.filter( + employee_id=self.employee_id, contract_status="draft" + ) + .exclude(id=self.id) + .count() + >= 1 + ): + raise forms.ValidationError( + _("A draft contract already exists for this employee.") + ) + + if self.wage_type in ["daily", "monthly"]: + if not self.calculate_daily_leave_amount: + if self.deduction_for_one_leave_amount is None: + raise ValidationError( + {"deduction_for_one_leave_amount": _("This field is required")} + ) + + def save(self, *args, **kwargs): + if EmployeeWorkInformation.objects.filter( + employee_id=self.employee_id + ).exists(): + if self.department is None: + self.department = self.employee_id.employee_work_info.department_id + + if self.job_position is None: + self.job_position = self.employee_id.employee_work_info.job_position_id + + if self.job_role is None: + self.job_role = self.employee_id.employee_work_info.job_role_id + + if self.work_type is None: + self.work_type = self.employee_id.employee_work_info.work_type_id + + if self.shift is None: + self.shift = self.employee_id.employee_work_info.shift_id + if self.contract_end_date is not None and self.contract_end_date < date.today(): + self.contract_status = "expired" + if ( + self.contract_status == "active" + and Contract.objects.filter( + employee_id=self.employee_id, contract_status="active" + ) + .exclude(id=self.id) + .count() + >= 1 + ): + raise forms.ValidationError( + _("An active contract already exists for this employee.") + ) + + if ( + self.contract_status == "draft" + and Contract.objects.filter( + employee_id=self.employee_id, contract_status="draft" + ) + .exclude(id=self.id) + .count() + >= 1 + ): + raise forms.ValidationError( + _("A draft contract already exists for this employee.") + ) + + super().save(*args, **kwargs) + return self + + class Meta: + """ + Meta class to add additional options + """ + + unique_together = ["employee_id", "contract_start_date", "contract_end_date"] + + +class WorkRecord(models.Model): + """ + WorkRecord Model + """ + + choices = [ + ("FDP", _("Present")), + ("HDP", _("Half Day Present")), + ("ABS", _("Absent")), + ("HD", _("Holiday/Company Leave")), + ("CONF", _("Conflict")), + ("DFT", _("Draft")), + ] + + record_name = models.CharField(max_length=250, null=True, blank=True) + work_record_type = models.CharField(max_length=5, null=True, choices=choices) + employee_id = models.ForeignKey( + Employee, on_delete=models.CASCADE, verbose_name=_("Employee") + ) + date = models.DateField(null=True, blank=True) + at_work = models.CharField( + null=True, + blank=True, + validators=[ + validate_time_format, + ], + default="00:00", + max_length=5, + ) + min_hour = models.CharField( + null=True, + blank=True, + validators=[ + validate_time_format, + ], + default="00:00", + max_length=5, + ) + at_work_second = models.IntegerField(null=True, blank=True, default=0) + min_hour_second = models.IntegerField(null=True, blank=True, default=0) + note = models.TextField() + color = models.CharField( + max_length=10, + null=True, + blank=True, + ) + message = models.CharField(max_length=30, null=True, blank=True) + is_attendance_record = models.BooleanField(default=False) + is_leave_record = models.BooleanField(default=False) + day_percentage = models.FloatField(default=0) + objects = models.Manager() + + def save(self, *args, **kwargs): + super().save(*args, **kwargs) + + def clean(self): + super().clean() + if not 0.0 <= self.day_percentage <= 1.0: + raise ValidationError(_("Day percentage must be between 0.0 and 1.0")) + + def __str__(self): + return ( + self.record_name + if self.record_name is not None + else f"{self.work_record_type}-{self.date}" + ) + + +class OverrideAttendance(Attendance): + """ + Class to override Attendance model save method + """ + + # Additional fields and methods specific to AnotherModel + @receiver(pre_save, sender=Attendance) + def attendance_pre_save(sender, instance, **_kwargs): + """ + Overriding Attendance model save method + """ + min_hour_second = strtime_seconds(instance.minimum_hour) + at_work_second = strtime_seconds(instance.attendance_worked_hour) + status = "FDP" if instance.at_work_second >= min_hour_second else "HDP" + status = ( + "CONF" + if WorkRecord.objects.filter( + date=instance.attendance_date, + is_attendance_record=False, + employee_id=instance.employee_id, + ).exists() + or instance.attendance_validated is False + else status + ) + message = _("Validate the attendance") if status == "CONF" else _("Validated") + message = ( + _("Incomplete minimum hour") + if status == "HDP" and min_hour_second / 2 > at_work_second + else message + ) + work_record = ( + WorkRecord() + if not WorkRecord.objects.filter( + is_attendance_record=True, + date=instance.attendance_date, + employee_id=instance.employee_id, + ).exists() + else WorkRecord.objects.filter( + is_attendance_record=True, + date=instance.attendance_date, + employee_id=instance.employee_id, + ).first() + ) + work_record.employee_id = instance.employee_id + work_record.date = instance.attendance_date + work_record.at_work = instance.attendance_worked_hour + work_record.min_hour = instance.minimum_hour + work_record.min_hour_second = min_hour_second + work_record.at_work_second = at_work_second + work_record.work_record_type = status + work_record.message = message + work_record.is_attendance_record = True + if instance.attendance_validated: + work_record.day_percentage = ( + 1.00 if at_work_second > min_hour_second / 2 else 0.50 + ) + work_record.save() + + @receiver(pre_delete, sender=Attendance) + def attendance_pre_delete(sender, instance, **_kwargs): + """ + Overriding Attendance model delete method + """ + # Perform any actions before deleting the instance + # ... + WorkRecord.objects.filter( + employee_id=instance.employee_id, + is_attendance_record=True, + date=instance.attendance_date, + ).delete() + + +class OverrideLeaveRequest(LeaveRequest): + """ + Class to override Attendance model save method + """ + + # Additional fields and methods specific to AnotherModel + @receiver(pre_save, sender=LeaveRequest) + def leaverequest_pre_save(sender, instance, **_kwargs): + """ + Overriding LeaveRequest model save method + """ + if ( + instance.start_date == instance.end_date + and instance.end_date_breakdown != "full_day" + ): + instance.end_date_breakdown = "full_day" + + super(LeaveRequest, instance).save() + + period_dates = get_date_range(instance.start_date, instance.end_date) + if instance.status == "approved": + for date in period_dates: + work_entry = ( + WorkRecord.objects.filter( + is_leave_record=True, + date=date, + employee_id=instance.employee_id, + ) + if WorkRecord.objects.filter( + is_leave_record=True, + date=date, + employee_id=instance.employee_id, + ).exists() + else WorkRecord() + ) + work_entry.employee_id = instance.employee_id + work_entry.is_leave_record = True + work_entry.day_percentage = ( + 0.50 + if instance.start_date == date + and instance.start_date_breakdown == "first_half" + or instance.end_date == date + and instance.end_date_breakdown == "second_half" + else 0.00 + ) + # scheduler task to validate the conflict entry for half day if they + # take half day leave is when they mark the attendance. + status = ( + "CONF" + if instance.start_date == date + and instance.start_date_breakdown == "first_half" + or instance.end_date == date + and instance.end_date_breakdown == "second_half" + else "ABS" + ) + work_entry.work_record_type = status + work_entry.date = date + work_entry.message = ( + "Validated" if status == "ABS" else _("Half day need to validate") + ) + work_entry.save() + + else: + for date in period_dates: + WorkRecord.objects.filter( + is_leave_record=True, date=date, employee_id=instance.employee_id + ).delete() + + +class OverrideWorkInfo(EmployeeWorkInformation): + """ + This class is to override the Model default methods + """ + + @receiver(pre_save, sender=EmployeeWorkInformation) + def employeeworkinformation_pre_save(sender, instance, **_kwargs): + """ + This method is used to override the save method for EmployeeWorkInformation Model + """ + active_employee = ( + instance.employee_id if instance.employee_id.is_active == True else None + ) + if active_employee is not None: + contract_exists = active_employee.contract_set.exists() + if not contract_exists: + contract = Contract() + contract.contract_name = f"{active_employee}'s Contract" + contract.employee_id = active_employee + contract.contract_start_date = datetime.today() + contract.wage = ( + instance.basic_salary if instance.basic_salary is not None else 0 + ) + contract.save() + + +# Create your models here. +def rate_validator(value): + """ + Percentage validator + """ + if value < 0: + raise ValidationError(_("Rate must be greater than 0")) + if value > 100: + raise ValidationError(_("Rate must be less than 100")) + + +def min_zero(value): + """ + The minimum value zero validation method + """ + if value < 0: + raise ValidationError(_("Value must be greater than zero")) + + +CONDITION_CHOICE = [ + ("equal", _("Equal (==)")), + ("notequal", _("Not Equal (!=)")), + ("lt", _("Less Than (<)")), + ("gt", _("Greater Than (>)")), + ("le", _("Less Than or Equal To (<=)")), + ("ge", _("Greater Than or Equal To (>=)")), + ("icontains", _("Contains")), +] +IF_CONDITION_CHOICE = [ + ("equal", _("Equal (==)")), + ("notequal", _("Not Equal (!=)")), + ("lt", _("Less Than (<)")), + ("gt", _("Greater Than (>)")), + ("le", _("Less Than or Equal To (<=)")), + ("ge", _("Greater Than or Equal To (>=)")), +] +FIELD_CHOICE = [ + ("children", _("Children")), + ("marital_status", _("Marital Status")), + ("experience", _("Experience")), + ("employee_work_info__experience", _("Company Experience")), + ("gender", _("Gender")), + ("country", _("Country")), + ("state", _("State")), + ("contract_set__pay_frequency", _("Pay Frequency")), + ("contract_set__wage_type", _("Wage Type")), + ("contract_set__department__department", _("Department on Contract")), +] + + +class Allowance(models.Model): + """ + Allowance model + """ + + exceed_choice = [ + ("ignore", _("Exclude the allowance")), + ("max_amount", _("Provide max amount")), + ] + + based_on_choice = [ + ("basic_pay", _("Basic Pay")), + ("attendance", _("Attendance")), + ("shift_id", _("Shift")), + ("overtime", _("Overtime")), + ("work_type_id", _("Work Type")), + ] + + if_condition_choice = [ + ("basic_pay", _("Basic Pay")), + ] + title = models.CharField( + max_length=255, null=False, blank=False, help_text=_("Title of the allowance") + ) + one_time_date = models.DateField( + null=True, + blank=True, + help_text=_( + "The one-time allowance in which the allowance will apply to the payslips \ + if the date between the payslip period" + ), + ) + include_active_employees = models.BooleanField( + default=False, + verbose_name=_("Include all active employees"), + help_text=_("Target allowance to all active employees in the company"), + ) + specific_employees = models.ManyToManyField( + Employee, + verbose_name=_("Employees Specific"), + blank=True, + related_name="allowance_specific", + help_text=_("Target allowance to the specific employees"), + ) + exclude_employees = models.ManyToManyField( + Employee, + verbose_name=_("Exclude Employees"), + related_name="allowance_excluded", + blank=True, + help_text=_( + "To ignore the allowance to the employees when target them by all employees \ + or through condition-based" + ), + ) + is_taxable = models.BooleanField( + default=True, + help_text=_("This field is used to calculate the taxable allowances"), + ) + is_condition_based = models.BooleanField( + default=False, + help_text=_( + "This field is used to target allowance \ + to the specific employees when the condition satisfies with the employee's information" + ), + ) + # If condition based + field = models.CharField( + max_length=255, + choices=FIELD_CHOICE, + null=True, + blank=True, + help_text=_("The related field of the employees"), + ) + condition = models.CharField( + max_length=255, choices=CONDITION_CHOICE, null=True, blank=True + ) + value = models.CharField( + max_length=255, + null=True, + blank=True, + help_text=_("The value must be like the data stored in the database"), + ) + + is_fixed = models.BooleanField( + default=True, help_text=_("To specify, the allowance is fixed or not") + ) + amount = models.FloatField( + null=True, + blank=True, + validators=[min_zero], + help_text=_("Fixed amount for this allowance"), + ) + # If is fixed is false + based_on = models.CharField( + max_length=255, + default="basic_pay", + choices=based_on_choice, + null=True, + blank=True, + help_text=_( + "If the allowance is not fixed then specifies how the allowance provided" + ), + ) + rate = models.FloatField( + null=True, + blank=True, + validators=[ + rate_validator, + ], + help_text=_("The percentage of based on"), + ) + # If based on attendance + per_attendance_fixed_amount = models.FloatField( + null=True, + blank=True, + default=0.00, + validators=[min_zero], + help_text=_("The attendance fixed amount for one validated attendance"), + ) + # If based on shift + shift_id = models.ForeignKey( + EmployeeShift, + on_delete=models.DO_NOTHING, + null=True, + blank=True, + verbose_name=_("Shift"), + ) + shift_per_attendance_amount = models.FloatField( + null=True, + default=0.00, + blank=True, + validators=[min_zero], + help_text=_("The fixed amount for one validated attendance with that shift"), + ) + amount_per_one_hr = models.FloatField( + null=True, + default=0.00, + blank=True, + validators=[min_zero], + help_text=_( + "The fixed amount for one hour overtime that are validated \ + and approved the overtime attendance" + ), + ) + work_type_id = models.ForeignKey( + WorkType, + on_delete=models.DO_NOTHING, + null=True, + blank=True, + verbose_name=_("Work Type"), + ) + work_type_per_attendance_amount = models.FloatField( + null=True, + default=0.00, + blank=True, + validators=[min_zero], + help_text=_( + "The fixed amount for one validated attendance with that work type" + ), + ) + # for apply only + has_max_limit = models.BooleanField( + default=False, + verbose_name=_("Has max limit for allowance"), + help_text=_("Limit the allowance amount"), + ) + maximum_amount = models.FloatField( + null=True, + blank=True, + validators=[min_zero], + help_text=_("The maximum amount for the allowance"), + ) + maximum_unit = models.CharField( + max_length=20, + null=True, + default="month_working_days", + choices=[ + ( + "month_working_days", + _("For working days on month"), + ), + # ("monthly_working_days", "For working days on month"), + ], + help_text="The maximum amount for ?", + ) + if_choice = models.CharField( + max_length=10, + choices=if_condition_choice, + default="basic_pay", + help_text=_("The pay head for the if condition"), + ) + if_condition = models.CharField( + max_length=10, + choices=IF_CONDITION_CHOICE, + default="gt", + help_text=_("Apply for those, if the pay-head conditions satisfy"), + ) + if_amount = models.FloatField( + default=0.00, help_text=_("The amount of the pay-head") + ) + objects = models.Manager() + + class Meta: + """ + Meta class for additional options + """ + + unique_together = [ + "title", + "is_taxable", + "is_condition_based", + "field", + "condition", + "value", + "is_fixed", + "amount", + "based_on", + "rate", + "per_attendance_fixed_amount", + "shift_id", + "shift_per_attendance_amount", + "amount_per_one_hr", + "work_type_id", + "work_type_per_attendance_amount", + ] + verbose_name = _("Allowance") + + def reset_based_on(self): + """Reset the this fields when is_fixed attribute is true""" + attributes_to_reset = [ + "based_on", + "rate", + "per_attendance_fixed_amount", + "shift_id", + "shift_per_attendance_amount", + "amount_per_one_hr", + "work_type_id", + "work_type_per_attendance_amount", + "maximum_amount", + ] + for attribute in attributes_to_reset: + setattr(self, attribute, None) + self.has_max_limit = False + + def clean(self): + super().clean() + self.clean_fixed_attributes() + if not self.is_condition_based: + self.field = None + self.condition = None + self.value = None + if self.is_condition_based: + if not self.field or not self.value or not self.condition: + raise ValidationError( + _( + "If condition based, all fields (field, value, condition) must be filled." + ) + ) + if self.based_on == "attendance" and not self.per_attendance_fixed_amount: + raise ValidationError( + { + "based_on": _( + "If based on is attendance, \ + then per attendance fixed amount must be filled." + ) + } + ) + if self.based_on == "shift_id" and not self.shift_id: + raise ValidationError(_("If based on is shift, then shift must be filled.")) + if self.based_on == "work_type_id" and not self.work_type_id: + raise ValidationError( + _("If based on is work type, then work type must be filled.") + ) + + if self.is_fixed and self.amount < 0: + raise ValidationError({"amount": _("Amount should be greater than zero.")}) + + if self.has_max_limit and self.maximum_amount is None: + raise ValidationError({"maximum_amount": _("This field is required")}) + + if not self.has_max_limit: + self.maximum_amount = None + + def clean_fixed_attributes(self): + """Clean the amount field and trigger the reset_based_on function based on the condition""" + if not self.is_fixed: + self.amount = None + if self.is_fixed: + if self.amount is None: + raise ValidationError({"amount": _("This field is required")}) + self.reset_based_on() + + def __str__(self) -> str: + return str(self.title) + + +class Deduction(models.Model): + """ + Deduction model + """ + + if_condition_choice = [ + ("basic_pay", _("Basic Pay")), + ("gross_pay", _("Gross Pay")), + ] + + based_on_choice = [ + ("basic_pay", _("Basic Pay")), + ("gross_pay", _("Gross Pay")), + ("taxable_gross_pay", _("Taxable Gross Pay")), + ("net_pay", _("Net Pay")), + ] + + exceed_choice = [ + ("ignore", _("Exclude the deduction")), + ("max_amount", _("Provide max amount")), + ] + + title = models.CharField(max_length=255, help_text=_("Title of the deduction")) + one_time_date = models.DateField( + null=True, + blank=True, + help_text=_( + "The one-time deduction in which the deduction will apply to the payslips \ + if the date between the payslip period" + ), + ) + include_active_employees = models.BooleanField( + default=False, + verbose_name=_("Include all active employees"), + help_text=_("Target deduction to all active employees in the company"), + ) + specific_employees = models.ManyToManyField( + Employee, + verbose_name=_("Employees Specific"), + related_name="deduction_specific", + help_text=_("Target deduction to the specific employees"), + blank=True, + ) + exclude_employees = models.ManyToManyField( + Employee, + verbose_name=_("Exclude Employees"), + related_name="deduction_exclude", + blank=True, + help_text=_( + "To ignore the deduction to the employees when target them by all employees \ + or through condition-based" + ), + ) + + is_tax = models.BooleanField( + default=False, + help_text=_("To specify the deduction is tax or normal deduction"), + ) + + is_pretax = models.BooleanField( + default=True, + help_text=_( + "To find taxable gross, \ + taxable_gross = (basic_pay + taxable_deduction)-pre_tax_deductions " + ), + ) + + is_condition_based = models.BooleanField( + default=False, + help_text=_( + "This field is used to target deduction \ + to the specific employees when the condition satisfies with the employee's information" + ), + ) + # If condition based then must fill field, value, and condition, + field = models.CharField( + max_length=255, + choices=FIELD_CHOICE, + null=True, + blank=True, + help_text=_("The related field of the employees"), + ) + condition = models.CharField( + max_length=255, choices=CONDITION_CHOICE, null=True, blank=True + ) + value = models.CharField( + max_length=255, + null=True, + blank=True, + help_text=_("The value must be like the data stored in the database"), + ) + update_compensation = models.CharField( + null=True, + blank=True, + max_length=10, + choices=[ + ( + "basic_pay", + _("Basic pay"), + ), + ("gross_pay", _("Gross Pay")), + ("net_pay", _("Net Pay")), + ], + help_text=_( + "Update compensation is used to update \ + pay-head before any other deduction calculation starts" + ), + ) + is_fixed = models.BooleanField( + default=True, + help_text=_("To specify, the deduction is fixed or not"), + ) + # If fixed amount then fill amount + amount = models.FloatField( + null=True, + blank=True, + validators=[min_zero], + help_text=_("Fixed amount for this deduction"), + ) + based_on = models.CharField( + max_length=255, + choices=based_on_choice, + null=True, + blank=True, + help_text=_( + "If the deduction is not fixed then specifies how the deduction provided" + ), + ) + rate = models.FloatField( + null=True, + blank=True, + default=0.00, + validators=[ + rate_validator, + ], + verbose_name=_("Employee rate"), + help_text=_("The percentage of based on"), + ) + + employer_rate = models.FloatField( + default=0.00, + validators=[ + rate_validator, + ], + ) + has_max_limit = models.BooleanField( + default=False, + verbose_name=_("Has max limit for deduction"), + help_text=_("Limit the deduction"), + ) + maximum_amount = models.FloatField( + null=True, + blank=True, + validators=[min_zero], + help_text=_("The maximum amount for the deduction"), + ) + + maximum_unit = models.CharField( + max_length=20, + null=True, + default="month_working_days", + choices=[ + ("month_working_days", _("For working days on month")), + # ("monthly_working_days", "For working days on month"), + ], + help_text=_("The maximum amount for ?"), + ) + if_choice = models.CharField( + max_length=10, + choices=if_condition_choice, + default="basic_pay", + help_text=_("The pay head for the if condition"), + ) + if_condition = models.CharField( + max_length=10, + choices=IF_CONDITION_CHOICE, + default="gt", + help_text=_("Apply for those, if the pay-head conditions satisfy"), + ) + if_amount = models.FloatField( + default=0.00, help_text=_("The amount of the pay-head") + ) + + objects = models.Manager() + + def clean(self): + super().clean() + + if self.is_tax: + self.is_pretax = False + if self.is_pretax and self.based_on in ["taxable_gross_pay"]: + raise ValidationError( + { + "based_on": _( + " Don't choose taxable gross pay when pretax is enabled." + ) + } + ) + if self.is_pretax and self.based_on in ["net_pay"]: + raise ValidationError( + {"based_on": _(" Don't choose net pay when pretax is enabled.")} + ) + if self.is_tax and self.based_on in ["net_pay"]: + raise ValidationError( + {"based_on": _(" Don't choose net pay when the tax is enabled.")} + ) + if not self.is_fixed: + self.amount = None + else: + self.based_on = None + self.rate = None + self.clean_condition_based_on() + if self.has_max_limit: + if self.maximum_amount is None: + raise ValidationError({"maximum_amount": _("This fields required")}) + + if self.is_condition_based: + if not self.field or not self.value or not self.condition: + raise ValidationError( + { + "is_condition_based": _( + "If condition based, all fields \ + (field, value, condition) must be filled." + ) + } + ) + if self.update_compensation is None: + if self.is_fixed: + if self.amount is None: + raise ValidationError({"amount": _("This field is required")}) + + def clean_condition_based_on(self): + """ + Clean the field, condition, and value attributes when not condition-based. + """ + if not self.is_condition_based: + self.field = None + self.condition = None + self.value = None + + def __str__(self) -> str: + return str(self.title) + + +class Payslip(models.Model): + """ + Payslip model + """ + + status_choices = [ + ("draft", _("Draft")), + ("review_ongoing", _("Review Ongoing")), + ("confirmed", _("Confirmed")), + ("paid", _("Paid")), + ] + reference = models.CharField(max_length=255, unique=False) + employee_id = models.ForeignKey( + Employee, on_delete=models.CASCADE, verbose_name=_("Employee") + ) + start_date = models.DateField() + end_date = models.DateField() + pay_head_data = models.JSONField() + contract_wage = models.FloatField(null=True, default=0) + basic_pay = models.FloatField(null=True, default=0) + gross_pay = models.FloatField(null=True, default=0) + deduction = models.FloatField(null=True, default=0) + net_pay = models.FloatField(null=True, default=0) + status = models.CharField( + max_length=20, null=True, default="draft", choices=status_choices + ) + objects = models.Manager() + + def __str__(self) -> str: + return f"Payslip for {self.employee_id} - Period: {self.start_date} to {self.end_date}" + + def clean(self): + super().clean() + today = date.today() + if self.end_date <= self.start_date: + raise ValidationError( + { + "end_date": _( + "The end date must be greater than or equal to the start date" + ) + } + ) + if self.end_date > today: + raise ValidationError(_("The end date cannot be in the future.")) + if self.start_date > today: + raise ValidationError(_("The start date cannot be in the future.")) + + def save(self, *args, **kwargs): + if ( + Payslip.objects.filter( + employee_id=self.employee_id, + start_date=self.start_date, + end_date=self.end_date, + ).count() + > 1 + ): + raise ValidationError(_("Employee ,start and end date must be unique")) + + if not isinstance(self.pay_head_data, (QueryDict, dict)): + raise ValidationError(_("The data must be in dictionary or querydict type")) + + super().save(*args, **kwargs) + + class Meta: + """ + Meta class for additional options + """ + + ordering = [ + "-end_date", + ] diff --git a/payroll/models/tax_models.py b/payroll/models/tax_models.py new file mode 100644 index 000000000..b6776fb94 --- /dev/null +++ b/payroll/models/tax_models.py @@ -0,0 +1,119 @@ +""" +tax_models.py + +This module contains the models for the tax calculation of taxable income. +""" + + +import math +from django.core.exceptions import ValidationError +from django.db import models +from django.utils.text import format_lazy +from django.utils.translation import gettext_lazy as _ + +from payroll.models.models import FilingStatus + + +class PayrollSettings(models.Model): + """ + Payroll settings model""" + + currency_symbol = models.CharField(null=True, default="$", max_length=5) + objects = models.Manager() + + def __str__(self): + return f"Payroll Settings {self.currency_symbol}" + + def save(self, *args, **kwargs): + if 1 < PayrollSettings.objects.count(): + raise ValidationError("You cannot add more conditions.") + + return super().save(*args, **kwargs) + + +class TaxBracket(models.Model): + """ + TaxBracket model + """ + + filing_status_id = models.ForeignKey( + FilingStatus, + on_delete=models.CASCADE, + verbose_name=_("Filing status"), + ) + min_income = models.FloatField(null=False, blank=False,verbose_name=_("Min. Income")) + max_income = models.FloatField(null=True, blank=True,verbose_name=_("Max. Income")) + tax_rate = models.FloatField(null=False, blank=False, default=0.0,verbose_name=_("Tax Rate")) + + objects = models.Manager() + + def __str__(self): + if self.max_income != math.inf: + return ( + f"{self.filing_status_id}" + f"{self.tax_rate}% tax rate on " + f"{self.min_income} and {self.max_income}" + ) + return ( + f"{self.tax_rate}% tax rate on taxable income equal or above " + f"{self.min_income} for {self.filing_status_id}" + ) + + def get_display_max_income(self): + """ + Retrieves the maximum income. + Returns: + float or None: The maximum income if it is a finite value, otherwise None. + """ + if self.max_income != math.inf: + return self.max_income + return None + + def clean(self): + super().clean() + + existing_bracket = TaxBracket.objects.filter( + filing_status_id=self.filing_status_id, + min_income=self.min_income, + max_income=self.max_income, + tax_rate=self.tax_rate, + ).exclude(pk=self.pk) + if existing_bracket.exists(): + raise ValidationError("This tax bracket already exists") + + if self.max_income is None: + self.max_income = math.inf + + if self.min_income >= self.max_income: + raise ValidationError( + {"max_income": "Maximum income must be greater than minimum income."} + ) + + existing_brackets = TaxBracket.objects.filter( + filing_status_id=self.filing_status_id + ).exclude(pk=self.pk) + if existing_brackets.filter(max_income__gte=self.min_income).exists(): + tax_bracket = existing_brackets.filter( + max_income__gte=self.min_income + ).first() + if tax_bracket.min_income <= self.max_income: + raise ValidationError( + { + "min_income": format_lazy( + "The minimum income of this tax bracket must be \ + greater than the maximum income of {}.", + tax_bracket, + ) + } + ) + + +class FederalTax(models.Model): + """ + FederalTax models + """ + + filing_status_id = models.ForeignKey( + FilingStatus, models.CASCADE, verbose_name=_("Filing Status") + ) + taxable_gross = models.IntegerField(null=False, blank=False) diff --git a/payroll/scheduler.py b/payroll/scheduler.py new file mode 100644 index 000000000..8ad38928d --- /dev/null +++ b/payroll/scheduler.py @@ -0,0 +1,32 @@ +""" +scheduler.py + +This module is used to register scheduled tasks +""" +from datetime import date +from apscheduler.schedulers.background import BackgroundScheduler +from .models.models import Contract + + +def generate_work_entry(): + """ + This is a automated task on time + """ + return + + +def expire_contract(): + """ + Finds all active contracts whose end date is earlier than the current date + and updates their status to "expired". + """ + Contract.objects.filter( + contract_status="active", contract_end_date__lt=date.today() + ).update(contract_status="expired") + return + + +scheduler = BackgroundScheduler() +scheduler.add_job(generate_work_entry, "interval", seconds=10) +scheduler.add_job(expire_contract, "interval", seconds=5) +scheduler.start() diff --git a/payroll/settings.py b/payroll/settings.py new file mode 100644 index 000000000..5161b9ea8 --- /dev/null +++ b/payroll/settings.py @@ -0,0 +1,11 @@ +""" +payroll/settings.py + +This module is used to write settings contents related to payroll app +""" + +from horilla.settings import TEMPLATES + +TEMPLATES[0]["OPTIONS"]["context_processors"].append( + "payroll.context_processors.default_currency", +) diff --git a/payroll/templates/common_form.html b/payroll/templates/common_form.html new file mode 100644 index 000000000..4e44f2cf1 --- /dev/null +++ b/payroll/templates/common_form.html @@ -0,0 +1,48 @@ +{% load widget_tweaks %} +{% load i18n %} +
| {% trans "Allowances" %} | +{% trans "Amount" %} | +|
|---|---|---|
| Basic Pay | ++ {{currency}}{{basic_pay|floatformat:2}} + | +|
| {{allowance.title}} | ++ {{currency}}{{allowance.amount|floatformat:2}} + | +|
| {% trans "Total Gross Pay" %} | ++ {{currency}}{{gross_pay|floatformat:2}} + | +|
| {% trans "Deductions" %} | +{% trans "Amount" %} | +
|---|---|
| {% trans "Loss of Pay" %} | ++ {{currency}}{{loss_of_pay|floatformat:2}} + | +
| {{deduction.title}} | ++ {{currency}}{{deduction.amount|floatformat:2}} + | +
| {{deduction.title}} | ++ {{currency}}{{deduction.amount|floatformat:2}} + | +
| {{deduction.title}} | ++ {{currency}}{{deduction.amount|floatformat:2}} + | +
| {{deduction.title}} | ++ {{currency}}{{deduction.amount|floatformat:2}} + | +
| {% trans "Federal Tax" %} | ++ {{currency}}{{federal_tax|floatformat:2}} + | +
| {{deduction.title}} | ++ {{currency}}{{deduction.amount|floatformat:2}} + | +
| {{deduction.title}} | ++ {{currency}}{{deduction.amount|floatformat:2}} + | +
| Total Deductions | ++ {{currency}}{{total_deductions|floatformat:2}} + | +
+ {% trans "Gross Earnings - Total Deductions" %} +
+| {% trans "Work Records" %} | + {% for day in current_month_dates_list %} +{{ day.day }} | + {% endfor %} + +
|---|---|
| {{ employee }} | + {% for date in current_month_dates_list %} +
+ {% for work_record in employee.workrecord_set.all|current_month_record %}
+ {% if work_record.start_datetime.date == date %}
+ {% if work_record.work_record_type.is_timeoff %}
+ {% trans "A" %}
+ {% else %}
+ {% trans "P" %}
+ {% endif %}
+ {% endif %}
+ {% endfor %}
+ |
+ {% endfor %}
+