# Part of Odoo. See LICENSE file for full copyright and licensing details. from pytz import timezone from dateutil.relativedelta import relativedelta, MO, SU from dateutil import rrule from calendar import monthrange from collections import defaultdict, Counter from datetime import date, timedelta from itertools import chain from odoo import api, Command, models, fields, _ from odoo.tools import SQL, float_round, float_is_zero, ormcache class HrPayslip(models.Model): _inherit = 'hr.payslip' meal_voucher_count = fields.Integer( compute='_compute_work_entry_dependent_benefits') # Overrides compute method # YTI NOTE: Changed nature of field (from missing dats to granting days) private_car_missing_days = fields.Integer( string='Days Granting Private Car Reimbursement', compute='_compute_work_entry_dependent_benefits') representation_fees_missing_days = fields.Integer( string='Days Not Granting Representation Fees', compute='_compute_work_entry_dependent_benefits') l10n_be_is_double_pay = fields.Boolean(compute='_compute_l10n_be_is_double_pay') l10n_be_max_seizable_amount = fields.Float(compute='_compute_l10n_be_max_seizable_amount') l10n_be_max_seizable_warning = fields.Char(compute='_compute_l10n_be_max_seizable_amount') l10n_be_is_december = fields.Boolean(compute='_compute_l10n_be_is_december') l10n_be_has_eco_vouchers = fields.Boolean(compute='_compute_l10n_be_has_eco_vouchers', search='_search_l10n_be_has_eco_vouchers') @api.depends('employee_id', 'version_id', 'struct_id', 'date_from', 'date_to') def _compute_input_line_ids(self): res = super()._compute_input_line_ids() balance_by_employee = self._get_salary_advance_balances() for slip in self: if not slip.employee_id or not slip.date_from or not slip.date_to or slip.country_code != 'BE': continue # If a double holiday pay should be recovered if slip.struct_id.code == 'CP200DOUBLE': to_recover = slip._get_sum_european_time_off_days() if to_recover: european_type = self.env.ref('l10n_be_hr_payroll.input_double_holiday_european_leave_deduction') lines_to_remove = slip.input_line_ids.filtered(lambda x: x.input_type_id == european_type) to_remove_vals = [(3, line.id, False) for line in lines_to_remove] to_add_vals = [(0, 0, { 'name': _('European Leaves Deduction'), 'amount': to_recover, 'input_type_id': european_type.id, })] slip.write({'input_line_ids': to_remove_vals + to_add_vals}) elif slip.struct_id.code == 'CP200SALARYADV': sal_adv_type = self.env.ref('l10n_be_hr_payroll.input_salary_advance') sal_adv_rec_type = self.env.ref('l10n_be_hr_payroll.cp200_input_advance') lines_to_remove = slip.input_line_ids.filtered(lambda x: x.input_type_id in (sal_adv_type, sal_adv_rec_type)) to_remove_vals = [(3, line.id, False) for line in lines_to_remove] to_add_vals = [(0, 0, { 'name': _('Salary Advance'), 'amount': 0, 'input_type_id': sal_adv_type.id, })] slip.write({'input_line_ids': to_remove_vals + to_add_vals}) elif slip.struct_id.code == 'CP200MONTHLY': balance = balance_by_employee[slip.employee_id] if balance <= 0: continue sal_adv_type = self.env.ref('l10n_be_hr_payroll.input_salary_advance') sal_adv_rec_type = self.env.ref('l10n_be_hr_payroll.cp200_input_advance') lines_to_remove = slip.input_line_ids.filtered( lambda x: x.input_type_id in (sal_adv_rec_type, sal_adv_type) ) to_remove_vals = [(3, line.id, False) for line in lines_to_remove] to_add_vals = [(0, 0, { 'name': _('Salary Advance Recovery'), 'amount': balance, 'input_type_id': sal_adv_rec_type.id, })] slip.write({'input_line_ids': to_remove_vals + to_add_vals}) elif slip.struct_id.code == 'CP200CCT90': cct90_input_type = self.env.ref('l10n_be_hr_payroll.input_cct90_bonus_plan') lines_to_remove = slip.input_line_ids.filtered(lambda x: x.input_type_id == cct90_input_type) to_remove_vals = [Command.unlink(line.id) for line in lines_to_remove] to_add_vals = [Command.create({ 'name': _('CCT90 Bonus Plan'), 'amount': 0, 'input_type_id': cct90_input_type.id, })] slip.write({'input_line_ids': to_remove_vals + to_add_vals}) return res @ormcache('self.employee_id', 'self.date_from', 'self.date_to') def _get_period_contracts(self): # Returns all the employee contracts over the same payslip period, to avoid # double remunerations for some line codes self.ensure_one() if self.env.context.get('salary_simulation'): return self.env.context['origin_version_id'] contracts = self.employee_id._get_versions_with_contract_overlap_with_period( self.date_from, self.date_to, ).sorted('date_start') return contracts.ids def _get_max_basic_salary_contract(self, contracts): self.ensure_one() if len(contracts) == 1: return contracts credit_time_work_entry_type_ids = [ self.env['ir.model.data']._xmlid_to_res_model_res_id('hr_work_entry.l10n_be_work_entry_type_credit_time')[1], self.env['ir.model.data']._xmlid_to_res_model_res_id('hr_work_entry.l10n_be_work_entry_type_parental_time_off')[1], ] unpaid_work_entry_types = self.struct_id.unpaid_work_entry_type_ids.ids + credit_time_work_entry_type_ids for contract in contracts: date_end = min([contract.date_end, self.date_to]) if contract.date_end else self.date_to all_work_hours = contract.get_work_hours(self.date_from, date_end) work_hours = sum( hours for work_entry_type_id, hours in all_work_hours.items() \ if work_entry_type_id not in unpaid_work_entry_types) if not float_is_zero(work_hours, precision_digits=2): return contract return contracts[0] @api.depends('worked_days_line_ids.number_of_hours', 'worked_days_line_ids.is_paid', 'worked_days_line_ids.work_entry_type_id.l10n_be_is_time_credit') def _compute_worked_hours(self): super()._compute_worked_hours() for payslip in self: payslip.sum_worked_hours -= sum( line.number_of_hours for line in payslip.worked_days_line_ids if line.work_entry_type_id.l10n_be_is_time_credit ) @api.depends('struct_id', 'date_from') def _compute_l10n_be_is_december(self): for payslip in self: payslip.l10n_be_is_december = payslip.struct_id.code == "CP200MONTHLY" and payslip.date_from and payslip.date_from.month == 12 def _compute_work_entry_dependent_benefits(self): if self.env.context.get('salary_simulation'): for payslip in self: payslip.meal_voucher_count = 20 payslip.private_car_missing_days = 20 payslip.representation_fees_missing_days = 0 else: all_benefits = self.env['hr.work.entry.type'].get_work_entry_type_benefits() query = self.env['l10n_be.work.entry.daily.benefit.report']._search([ ('employee_id', 'in', self.mapped('employee_id').ids), ('day', '<=', max(self.mapped('date_to'))), ('day', '>=', min(self.mapped('date_from'))), ]) work_entries_benefits_rights = self.env.execute_query_dict( query.select('day', 'benefit_name', 'employee_id')) work_entries_benefits_rights_by_employee = defaultdict(list) for work_entries_benefits_right in work_entries_benefits_rights: employee_id = work_entries_benefits_right['employee_id'] work_entries_benefits_rights_by_employee[employee_id].append(work_entries_benefits_right) # {(calendar, date_from, date_to): resources} mapped_resources = defaultdict(lambda: self.env['resource.resource']) for payslip in self: contract = payslip.version_id calendar = contract.resource_calendar_id if not contract.l10n_be_time_credit else contract.standard_calendar_id mapped_resources[(calendar, payslip.date_from, payslip.date_to)] |= contract.employee_id.resource_id # {(calendar, date_from, date_to): intervals}} mapped_intervals = {} for (calendar, date_from, date_to), resources in mapped_resources.items(): tz = timezone(calendar.tz) mapped_intervals[(calendar, date_from, date_to)] = calendar._attendance_intervals_batch( tz.localize(fields.Datetime.to_datetime(date_from)), tz.localize(fields.Datetime.to_datetime(date_to) + timedelta(days=1, seconds=-1)), resources=resources, tz=tz) for payslip in self: contract = payslip.version_id benefits = dict.fromkeys(all_benefits, 0) date_from = max(payslip.date_from, contract.date_start) date_to = min(payslip.date_to, contract.date_end or payslip.date_to) for work_entries_benefits_right in ( work_entries_benefits_right for work_entries_benefits_right in work_entries_benefits_rights_by_employee[payslip.employee_id.id] if date_from <= work_entries_benefits_right['day'] <= date_to ): if work_entries_benefits_right['benefit_name'] not in benefits: benefits[work_entries_benefits_right['benefit_name']] = 1 else: benefits[work_entries_benefits_right['benefit_name']] += 1 contract = payslip.version_id resource = contract.employee_id.resource_id calendar = contract.resource_calendar_id if not contract.l10n_be_time_credit else contract.standard_calendar_id intervals = mapped_intervals[(calendar, payslip.date_from, payslip.date_to)][resource.id] nb_of_days_to_work = len({dt_from.date(): True for (dt_from, dt_to, attendance) in intervals}) payslip.private_car_missing_days = benefits['private_car'] if 'private_car' in benefits else 0 payslip.representation_fees_missing_days = nb_of_days_to_work - (benefits['representation_fees'] if 'representation_fees' in benefits else 0) payslip.meal_voucher_count = benefits['meal_voucher'] @api.depends('struct_id') def _compute_l10n_be_is_double_pay(self): for payslip in self: payslip.l10n_be_is_double_pay = payslip.struct_id.code == "CP200DOUBLE" @api.depends('input_line_ids') def _compute_l10n_be_has_eco_vouchers(self): for slip in self: slip.l10n_be_has_eco_vouchers = any(input_line.code == 'ECOVOUCHERS' for input_line in slip.input_line_ids) def _search_l10n_be_has_eco_vouchers(self, operator, value): if operator != 'in': return NotImplemented rows = self.env.execute_query(SQL(""" SELECT id FROM hr_payslip payslip WHERE EXISTS (SELECT 1 FROM hr_payslip_input hpi JOIN hr_payslip_input_type hpit ON hpi.input_type_id = hpit.id AND hpit.code = 'ECOVOUCHERS' WHERE hpi.payslip_id = payslip.id LIMIT 1) """)) return [('id', 'in', [r[0] for r in rows])] @api.depends('date_to', 'line_ids.total', 'input_line_ids.code') def _compute_l10n_be_max_seizable_amount(self): # Source: https://emploi.belgique.be/fr/themes/remuneration/protection-de-la-remuneration/saisie-et-cession-sur-salaires all_payslips = self.env['hr.payslip'].search([ ('employee_id', 'in', self.employee_id.ids), ('state', '!=', 'cancel')]) payslip_values = all_payslips._get_line_values(['NET']) for payslip in self: if payslip.struct_id.country_id.code != 'BE': payslip.l10n_be_max_seizable_amount = 0 payslip.l10n_be_max_seizable_warning = False continue rates = self.env['hr.rule.parameter']._get_parameter_from_code('cp200_seizable_percentages', payslip.date_to, raise_if_not_found=False) child_increase = self.env['hr.rule.parameter']._get_parameter_from_code('cp200_seizable_amount_child', payslip.date_to, raise_if_not_found=False) if not rates or not child_increase: payslip.l10n_be_max_seizable_amount = 0 payslip.l10n_be_max_seizable_warning = False continue # Note: the ceiling amounts are based on the net revenues period_payslips = all_payslips.filtered( lambda p: p.employee_id == payslip.employee_id and p.date_from == payslip.date_from and p.date_to == payslip.date_to) net_amount = sum([payslip_values['NET'][p.id]['total'] for p in period_payslips]) seized_amount = sum([period_payslips._get_input_line_amount(code) for code in ['ATTACH_SALARY', 'ASSIG_SALARY', 'CHILD_SUPPORT']]) net_amount += seized_amount # Note: The reduction for dependant children is not applied most of the time because # the process is too complex. # To benefit from this increase in the elusive or non-transferable quotas, the worker # whose remuneration is subject to seizure or transfer, must declare it using a form, # the model of which has been published in the Belgian Official Gazette. of 30 November # 2006. # He must attach to this form the documents establishing the reality of the # charge invoked. # Source: Opinion on the indexation of the amounts set in Article 1, paragraph 4, of # the Royal Decree of 27 December 2004 implementing Articles 1409, § 1, paragraph 4, # and 1409, § 1 bis, paragraph 4 , of the Judicial Code relating to the limitation of # seizure when there are dependent children, MB, December 13, 2019. dependent_children = payslip.employee_id.l10n_be_dependent_children_attachment max_seizable_amount = 0 for left, right, rate in rates: if dependent_children: left += dependent_children * child_increase right += dependent_children * child_increase if left <= net_amount: max_seizable_amount += (min(net_amount, right) - left) * rate payslip.l10n_be_max_seizable_amount = max_seizable_amount if max_seizable_amount and seized_amount > max_seizable_amount: payslip.l10n_be_max_seizable_warning = _( 'The seized amount (%(seized_amount)s€) is above the belgian ceilings. Given a global net salary of %(net_amount)s€ for the pay period and %(dependent_children)s dependent children, the maximum seizable amount is equal to %(max_seizable_amount)s€', seized_amount=round(seized_amount, 2), net_amount=round(net_amount, 2), dependent_children=round(dependent_children, 2), max_seizable_amount=round(max_seizable_amount, 2), ) else: payslip.l10n_be_max_seizable_warning = False def _get_salary_advance_balances(self): balance_by_employee = super()._get_salary_advance_balances() payslips_by_employee = self._read_group( domain=[ ('struct_id.country_id', '=', 'BE'), ('state', 'in', ('validated', 'paid')), ('employee_id', 'in', self.employee_id.ids), ('input_line_ids.code', 'in', ('SALARYADVREC', 'SALARYADV')), ], groupby=['employee_id'], aggregates=['id:recordset'] ) for employee_id, payslips in payslips_by_employee: for input_line in chain.from_iterable(payslip.input_line_ids for payslip in payslips): if input_line.code == 'SALARYADV': balance_by_employee[employee_id] += input_line.amount elif input_line.code == 'SALARYADVREC': balance_by_employee[employee_id] -= input_line.amount return balance_by_employee def _get_worked_day_lines_hours_per_day(self): self.ensure_one() if self.version_id.l10n_be_time_credit: return self.version_id.standard_calendar_id.hours_per_day return super()._get_worked_day_lines_hours_per_day() def _get_out_of_contract_calendar(self): self.ensure_one() if self.version_id.l10n_be_time_credit: return self.version_id.standard_calendar_id return super()._get_out_of_contract_calendar() def _get_worked_day_lines_values(self, domain=None): self.ensure_one() res = [] if self.struct_id.country_id.code != 'BE': return super()._get_worked_day_lines_values(domain=domain) # If a belgian payslip has half-day attendances/time off, it the worked days lines should # be separated work_hours = self.version_id._get_work_hours_split_half(self.date_from, self.date_to, domain=domain) work_hours_ordered = sorted(work_hours.items(), key=lambda x: x[1]) for worked_days_data, duration_data in work_hours_ordered: duration_type, work_entry_type_id = worked_days_data number_of_days, number_of_hours = duration_data work_entry_type = self.env['hr.work.entry.type'].browse(work_entry_type_id) attendance_line = { 'sequence': work_entry_type.sequence, 'work_entry_type_id': work_entry_type_id, 'number_of_days': number_of_days, 'number_of_hours': number_of_hours, 'name': self.env._('%(name)s%(duration_type)s', name=work_entry_type.name, duration_type=self.env._(' (Half-Day)') if duration_type == 'half' else ""), } res.append(attendance_line) # If there is a public holiday less than 30 days after the end of the contract # this public holiday should be taken into account in the worked days lines if self.version_id.date_end and self.date_from <= self.version_id.date_end <= self.date_to: # If the contract is followed by another one (eg. after an appraisal) if not self.version_id.employee_id.version_ids.filtered(lambda v: v.date_start > self.version_id.date_end): public_holiday_type = self.env.ref('hr_work_entry.l10n_be_work_entry_type_bank_holiday') public_leaves = self.version_id.resource_calendar_id.global_leave_ids.filtered( lambda l: l.work_entry_type_id == public_holiday_type) # If less than 15 days under contract, the public holidays is not reimbursed public_leaves = public_leaves.filtered( lambda l: (l.date_from.date() - self.employee_id.contract_date_start).days >= 15) # If less than 15 days of occupation -> no payment of the time off after contract # If less than 1 month of occupation -> payment of the time off occurring within 15 days after contract. # Occupation = duration since the start of the contract, from date to date public_leaves = public_leaves.filtered( lambda l: 0 < (l.date_from.date() - self.version_id.date_end).days <= (30 if self.employee_id.contract_date_start + relativedelta(months=1) <= self.version_id.date_end else 15)) if public_leaves: input_type_id = self.env.ref('l10n_be_hr_payroll.cp200_other_input_after_contract_public_holidays').id if input_type_id not in self.input_line_ids.mapped('input_type_id').ids: self.write({'input_line_ids': [(0, 0, { 'name': _('After Contract Public Holidays'), 'amount': 0.0, 'input_type_id': self.env.ref('l10n_be_hr_payroll.cp200_other_input_after_contract_public_holidays').id, })]}) # Handle loss on commissions if self.version_id.commission_on_target and self._get_last_year_average_variable_revenues(): we_types_ids = ( self.env.ref('hr_work_entry.l10n_be_work_entry_type_bank_holiday') + self.env.ref('hr_work_entry.l10n_be_work_entry_type_small_unemployment') ).ids # if self.worked_days_line_ids.filtered(lambda wd: wd.code in ['LEAVE205', 'LEAVE500']): if any(line_vals['work_entry_type_id'] in we_types_ids for line_vals in res): we_type = self.env.ref('hr_work_entry.l10n_be_work_entry_type_simple_holiday_pay_variable_salary') res.append({ 'sequence': we_type.sequence, 'work_entry_type_id': we_type.id, 'number_of_days': 0, 'number_of_hours': 0, }) return res def _get_last_year_average_variable_revenues(self): date_from = self.env.context.get('variable_revenue_date_from', self.date_from) first_version_date = self.employee_id._get_first_version_date() if not first_version_date: return 0 start = first_version_date end = date_from + relativedelta(day=31, months=-1) number_of_month = (end.year - start.year) * 12 + (end.month - start.month) + 1 number_of_month = min(12, number_of_month) if number_of_month <= 0: return 0 payslips = self.env['hr.payslip'].search([ ('employee_id', '=', self.employee_id.id), ('state', 'in', ['validated', 'paid']), ('date_from', '>=', date_from + relativedelta(months=-12, day=1)), ('date_from', '<=', date_from), ], order="date_from asc") total_amount = payslips._get_line_values(['COMMISSION'], compute_sum=True)['COMMISSION']['sum']['total'] return total_amount / number_of_month if number_of_month else 0 def _get_last_year_average_warrant_revenues(self): warrant_payslips = self.env['hr.payslip'].search([ ('employee_id', '=', self.employee_id.id), ('state', 'in', ['validated', 'paid']), ('struct_id.code', '=', 'CP200WARRANT'), ('date_from', '>=', self.date_from + relativedelta(months=-12, day=1)), ('date_from', '<', self.date_from), ], order="date_from asc") total_amount = warrant_payslips._get_line_values(['BASIC'], compute_sum=True)['BASIC']['sum']['total'] first_version_date = self.employee_id._get_first_version_date() if not first_version_date: return 0 # Only complete months count if first_version_date.day != 1: start = first_version_date + relativedelta(day=1, months=1) else: start = first_version_date end = self.date_from + relativedelta(day=31, months=-1) number_of_month = (end.year - start.year) * 12 + (end.month - start.month) + 1 number_of_month = min(12, number_of_month) return total_amount / number_of_month if number_of_month else 0 def _compute_number_complete_months_of_work(self, date_from, date_to, contracts, use_work_rate=False): days_by_contract_by_year = defaultdict(lambda: defaultdict(dict)) for day in rrule.rrule(rrule.DAILY, dtstart=date_from + relativedelta(day=1), until=date_to + relativedelta(day=31)): days_by_contract_by_year[day.year][day.month][day.date()] = None public_holidays = [(leave.date_from.date(), leave.date_to.date()) for leave in self.employee_id._get_public_holidays(date_from, date_to)] for contract in contracts: work_days = {int(d) for d in contract.resource_calendar_id._get_global_attendances().mapped('dayofweek')} previous_week_start = max(contract.date_start + relativedelta(weeks=-1, weekday=MO(-1)), date_from + relativedelta(day=1)) next_week_end = min(contract.date_end + relativedelta(weeks=+1, weekday=SU(+1)) if contract.date_end else date.max, date_to) days_to_check = rrule.rrule(rrule.DAILY, dtstart=previous_week_start, until=next_week_end) for day in days_to_check: day = day.date() # Full time credit time doesn't count if contract.l10n_be_time_credit and not contract.work_time_rate: continue if contract.date_start <= day <= (contract.date_end or date.max): days_by_contract_by_year[day.year][day.month][day] = contract elif (day.weekday() not in work_days or any(date_from <= day <= date_to for date_from, date_to in public_holidays)): days_by_contract_by_year[day.year][day.month][day] = 'holiday' months = 0 for invalid_days_by_months in days_by_contract_by_year.values(): for days in invalid_days_by_months.values(): counter = Counter(days.values()) if None in counter: continue if use_work_rate: under_contract_days = sum(counter.values()) - counter.pop('holiday', 0) for contract, n_days in counter.items(): months += n_days / under_contract_days * contract.work_time_rate else: months += 1 return months def _compute_presence_prorated_fixed_wage(self, date_from, date_to, versions): self.ensure_one() def _get_calendar_days(leave, date_min, date_max): date_from = max(date_min, leave.date_from.date()) date_to = min(date_max, leave.date_to.date()) days = (date_to - date_from).days + 1 if leave.request_unit_half: if leave.request_date_from_period == leave.request_date_to_period: return days - 0.5 elif leave.request_date_from_period == 'pm' and leave.request_date_to_period == 'am': return days - 1 return days def round_half_days(duration): return round(duration * 2) / 2 work_entries = self.env['hr.work.entry'].search([ ('state', 'in', ['validated', 'draft']), ('employee_id', '=', self.employee_id.id), ('date', '>=', date_from), ('date', '<=', date_to), ], order="date asc") work_entries_by_date = work_entries.grouped('date') unpaid_work_entry_types = self.env['hr.work.entry.type'] unpaid_leave_type = self.env.ref('hr_work_entry.work_entry_type_unpaid_leave', raise_if_not_found=False) if unpaid_leave_type: unpaid_work_entry_types += unpaid_leave_type unjustified_reason_type = self.env.ref('hr_work_entry.l10n_be_work_entry_type_unjustified_reason', raise_if_not_found=False) if unjustified_reason_type: unpaid_work_entry_types += unjustified_reason_type is_PFA = self.struct_id.code == 'CP200THIRTEEN' fte_basic = self.version_id._get_contract_wage() * 1 / self.version_id.work_time_rate if self.version_id.work_time_rate else 0 company_avg_hours_per_day = self.company_id.resource_calendar_id.hours_per_day version_by_date = defaultdict(lambda: defaultdict(dict)) for day in rrule.rrule(rrule.DAILY, dtstart=date_from + relativedelta(day=1), until=date_to + relativedelta(day=31)): version_by_date[day.year][day.month][day.date()] = None full_unpaid_months = set() for version in versions: working_days = version.resource_calendar_id._get_working_hours() # As version changes can go from friday to monday, we need to extend the search to the previous monday/next sunday # We only check for next sunday for the last version otherwise we would write the wrong version previous_week_start = max(version.date_start + relativedelta(weeks=-1, weekday=MO(-1)), date_from + relativedelta(day=1)) if version == versions[-1]: date_end = min(version.date_end + relativedelta(weeks=+1, weekday=SU(+1)) if version.date_end else date.max, date_to) else: date_end = min(version.date_end or date.max, date_to) days_to_check = rrule.rrule(rrule.DAILY, dtstart=previous_week_start, until=date_end) for day in days_to_check: day = day.date() work_entries_date = work_entries_by_date.get(day) if version_by_date[day.year][day.month][day] or day.month in full_unpaid_months: continue dayofweek = str(day.weekday()) if version.resource_calendar_id.two_weeks_calendar: weektype = str(self.env['resource.calendar.attendance'].get_week_type(day)) is_calendar_day = working_days[weektype][dayofweek] else: is_calendar_day = working_days[False][dayofweek] if is_calendar_day and (not work_entries_date or (is_PFA and all(we.work_entry_type_id in unpaid_work_entry_types for we in work_entries_date))): full_unpaid_months.add(day.month) else: version_by_date[day.year][day.month][day] = version full_time_months = 0 payslip_amount = 0 ### PFA STUFF ### sick_work_entry_type_codes = [ 'LEAVE110', # Sick Time Off 'LEAVE280', # Long Term Sick 'LEAVE214', # Sick Time Off (Without Guaranteed Salary) 'LEAVE281', # Partial Incapacity ] covered_time_offs = self.env['hr.leave'] pfa_calendar_sick_days_remaining = 60 pfa_sick_calendar_days_to_defer = 0 # used to report time off to deduct to next month pfa_unpredictable_days_remaining = 10 sick_work_entries = work_entries.filtered_domain([ ('work_entry_type_id.code', 'in', sick_work_entry_type_codes) ]) unpredictable_work_entries = work_entries.filtered_domain([ ('work_entry_type_id.code', '=', 'LEAVE250') ]) if is_PFA: # If the first work entry of the year is a sick work entry, check for last year work entries # If already sick in december last year, we should deduct these days, except if there is a period of 14 calendar days of work if work_entries and work_entries[0].work_entry_type_id.code in sick_work_entry_type_codes: last_year_work_entries = self.env['hr.work.entry'].search([ ('state', 'in', ['draft', 'validated']), ('employee_id', '=', self.employee_id.id), ('date', '>=', date_from - relativedelta(years=1)), ('date', '<=', date_to - relativedelta(years=1)), ], order="date asc") if last_year_work_entries and last_year_work_entries[-1].work_entry_type_id.code in sick_work_entry_type_codes: last_year_sick_work_entries = last_year_work_entries.filtered_domain([ ('work_entry_type_id.code', 'in', sick_work_entry_type_codes) ]) # Check if has worked for 14 consecutive days. If yes, then do not deduct last year sick days # We ignore half days of sickness, as sick time off are not taken in half days in Belgium has_worked_consecutive_14_days = False consecutive_work_days_counter = 0 current_date = date_from while current_date < date_to: work_entries = work_entries_by_date.get(current_date) if not work_entries or any(we.work_entry_type_id.code not in sick_work_entry_type_codes for we in work_entries): consecutive_work_days_counter += 1 else: consecutive_work_days_counter = 0 if leave_work_entries := work_entries.filtered('leave_id'): current_date = max(leave_work_entries.leave_id.mapped('date_to')).date() + relativedelta(days=1) continue # If worked 14 calendar consecutive days, counter is set to 60 days for the year if consecutive_work_days_counter >= 14: has_worked_consecutive_14_days = True break current_date += relativedelta(days=1) if not has_worked_consecutive_14_days: last_year_sick_calendar_days = 0 last_year_covered_time_offs = self.env['hr.leave'] for sick_wes in last_year_sick_work_entries.grouped('date').values(): for sick_we in sick_wes: if sick_we.leave_id and not sick_we.leave_id in last_year_covered_time_offs: last_year_sick_calendar_days += _get_calendar_days( sick_we.leave_id, date_from - relativedelta(years=1), date_to - relativedelta(years=1) ) last_year_covered_time_offs |= sick_we.leave_id elif not sick_we.leave_id: last_year_sick_calendar_days += round_half_days(sick_we.duration / company_avg_hours_per_day) pfa_calendar_sick_days_remaining = max(0, pfa_calendar_sick_days_remaining - last_year_sick_calendar_days) for year, versions_by_month in version_by_date.items(): for month, version_by_day in versions_by_month.items(): days_by_version_counter = Counter(version_by_day.values()) if None in days_by_version_counter or month in full_unpaid_months: continue full_time_months += 1 days_in_month = monthrange(year, month)[1] monthly_calendar_sick_time_off_days = 0 monthly_covered_time_offs = self.env['hr.leave'] for version, n_days in days_by_version_counter.items(): if is_PFA: month_sick_work_entries = sick_work_entries.filtered( lambda we: we.date.month == month and we.version_id == version ) calendar_sick_time_off_days = 0 for sick_wes in month_sick_work_entries.grouped('date').values(): for sick_we in sick_wes: if sick_we.leave_id and not sick_we.leave_id in covered_time_offs: calendar_sick_time_off_days += _get_calendar_days( sick_we.leave_id, date_from, date_to ) covered_time_offs |= sick_we.leave_id elif not sick_we.leave_id: calendar_sick_time_off_days += round_half_days(sick_we.duration / company_avg_hours_per_day) # Used to know how many days worked in month if sick_we.leave_id and not sick_we.leave_id in monthly_covered_time_offs: monthly_calendar_sick_time_off_days += _get_calendar_days( sick_we.leave_id, date(year, month, 1), date(year, month, days_in_month) ) monthly_covered_time_offs |= sick_we.leave_id elif not sick_we.leave_id: monthly_calendar_sick_time_off_days += round_half_days(sick_we.duration / company_avg_hours_per_day) month_unpredictable_work_entries = unpredictable_work_entries.filtered(lambda we: we.date.month == month) unpredictable_days = len(month_unpredictable_work_entries.mapped('date')) max_sick_days_to_remove = min(monthly_calendar_sick_time_off_days, calendar_sick_time_off_days) sick_days_to_remove = min(n_days, pfa_sick_calendar_days_to_defer + max_sick_days_to_remove) if calendar_sick_time_off_days > sick_days_to_remove: pfa_sick_calendar_days_to_defer += calendar_sick_time_off_days - sick_days_to_remove else: pfa_sick_calendar_days_to_defer -= max(0, sick_days_to_remove - max_sick_days_to_remove) # Remove sick days non_assimilated_days = max(0, sick_days_to_remove - pfa_calendar_sick_days_remaining) pfa_calendar_sick_days_remaining = max(0, pfa_calendar_sick_days_remaining - sick_days_to_remove) # Remove unpredictable days non_assimilated_days += max(0, unpredictable_days - pfa_unpredictable_days_remaining) pfa_unpredictable_days_remaining = max(0, pfa_unpredictable_days_remaining - unpredictable_days) n_days -= non_assimilated_days work_time_rate = version.work_time_rate # Compute the real work time rate if partial incapacity. Partial incapacity should count in the work time rate in the PFA if is_PFA and version.l10n_be_time_credit: global_attendances = version.resource_calendar_id._get_global_attendances() partial_incapacity_attendances = version.resource_calendar_id.attendance_ids.filtered_domain([ ('display_type', '=', False), ('day_period', '!=', 'lunch'), ('work_entry_type_id.code', '=', 'LEAVE281') ]) hours_per_week_ref = version.resource_calendar_id.full_time_required_hours hours_per_week = sum(att.hour_to - att.hour_from for att in global_attendances + partial_incapacity_attendances) work_time_rate = hours_per_week / hours_per_week_ref if hours_per_week_ref else 1 payslip_amount += ((n_days / days_in_month) / 12) * (fte_basic * work_time_rate) if is_PFA: first_version_date = self.employee_id._get_first_version_date() if first_version_date.year == self.date_from.year and first_version_date.month >= 7: return 0, full_time_months return payslip_amount, full_time_months def _get_paid_amount_13th_month(self): versions = self.employee_id.version_ids.filtered(lambda v: v.structure_type_id == self.struct_id.type_id) first_version_date = self.employee_id._get_first_version_date(no_gap=False) if not versions or not first_version_date: return 0.0 if first_version_date.year == self.date_from.year and first_version_date.month > 6: return 0.0 date_from = max(first_version_date, self.date_from + relativedelta(day=1, month=1)) date_to = self.date_to + relativedelta(day=31) force_months = self.input_line_ids.filtered(lambda l: l.code == 'MONTHS') if force_months: n_months = force_months[0].amount if n_months < 6: return 0.0 fixed_salary = self.version_id._get_contract_wage() * n_months / 12 else: fixed_salary, _ = self._compute_presence_prorated_fixed_wage(date_from, date_to, versions) force_avg_variable_revenues = self.input_line_ids.filtered(lambda l: l.code == 'VARIABLE') if force_avg_variable_revenues: avg_variable_revenues = force_avg_variable_revenues[0].amount else: avg_variable_revenues = self.with_context( variable_revenue_date_from=self.date_from )._get_last_year_average_variable_revenues() return fixed_salary + avg_variable_revenues def _get_paid_amount_warrant(self): self.ensure_one() warrant_input_type = self.env.ref('l10n_be_hr_payroll.cp200_other_input_warrant') return sum(self.input_line_ids.filtered(lambda a: a.input_type_id == warrant_input_type).mapped('amount')) def _get_paid_double_holiday(self): self.ensure_one() versions = self.employee_id.version_ids.filtered(lambda v: v.structure_type_id == self.struct_id.type_id) if not versions: return 0.0 basic = self.version_id._get_contract_wage() force_months = self.input_line_ids.filtered(lambda l: l.code == 'MONTHS') year = self.date_from.year - 1 date_from = date(year, 1, 1) date_to = date(year, 12, 31) if force_months: n_months = force_months[0].amount fixed_salary = basic * n_months / 12 else: fixed_salary, n_months = self._compute_presence_prorated_fixed_wage(date_from, date_to, versions) if year == int(self.employee_id.first_contract_year_n1): for line in self.employee_id.double_pay_line_n1_ids: fixed_salary += basic * line.months_count * line.occupation_rate / 100 / 12 n_months += line.months_count elif year == int(self.employee_id.first_contract_year_n): for line in self.employee_id.double_pay_line_n_ids: fixed_salary += basic * line.months_count * line.occupation_rate / 100 / 12 n_months += line.months_count force_avg_variable_revenues = self.input_line_ids.filtered(lambda l: l.code == 'VARIABLE') if force_avg_variable_revenues: avg_variable_revenues = force_avg_variable_revenues[0].amount else: if not n_months: avg_variable_revenues = 0 else: avg_variable_revenues = self.with_context( variable_revenue_date_from=self.date_from )._get_last_year_average_variable_revenues() return fixed_salary + avg_variable_revenues def _get_paid_amount_cct90_bonus_plan(self): self.ensure_one() return self._get_input_line_amount('CCT90BONUSPLAN') def _get_paid_amount(self): self.ensure_one() belgian_payslip = self.struct_id.country_id.code == "BE" if belgian_payslip: if self.struct_id.code == 'CP200THIRTEEN': return self._get_paid_amount_13th_month() if self.struct_id.code == 'CP200WARRANT': return self._get_paid_amount_warrant() if self.struct_id.code == 'CP200DOUBLE': return self._get_paid_double_holiday() if self.struct_id.code == 'CP200CCT90': return self._get_paid_amount_cct90_bonus_plan() return super()._get_paid_amount() def _is_active_belgian_languages(self): active_langs = self.env['res.lang'].with_context(active_test=True).search([]).mapped('code') return any(l in active_langs for l in ["fr_BE", "fr_FR", "nl_BE", "nl_NL", "de_BE", "de_DE"]) def _get_sum_european_time_off_days(self, check=False): self.ensure_one() two_years_payslips = self.env['hr.payslip'].search([ ('employee_id', '=', self.employee_id.id), ('date_to', '<=', date(self.date_from.year, 12, 31)), ('date_from', '>=', date(self.date_from.year - 2, 1, 1)), ('state', 'in', ['validated', 'paid']), ]) european_time_off_amount = two_years_payslips.filtered(lambda p: p.date_from.year < self.date_from.year)._get_worked_days_line_values(['LEAVE216'], ['amount'], True)['LEAVE216']['sum']['amount'] already_recovered_amount = two_years_payslips._get_line_values(['EU.LEAVE.DEDUC'], compute_sum=True)['EU.LEAVE.DEDUC']['sum']['total'] return european_time_off_amount + already_recovered_amount def _is_invalid(self): invalid = super()._is_invalid() if not invalid and self._is_active_belgian_languages(): country = self.struct_id.country_id if country.code == 'BE' and self.employee_id.lang not in ["fr_BE", "fr_FR", "nl_BE", "nl_NL", "de_BE", "de_DE"]: return _('This document is a translation. This is not a legal document.') return invalid def _get_negative_net_input_type(self): self.ensure_one() if self.struct_id.code == 'CP200MONTHLY': return self.env.ref('l10n_be_hr_payroll.input_negative_net') return super()._get_negative_net_input_type() def action_payslip_done(self): if self._is_active_belgian_languages(): bad_language_slips = self.filtered( lambda p: p.struct_id.country_id.code == "BE" and p.employee_id.lang not in ["fr_BE", "fr_FR", "nl_BE", "nl_NL", "de_BE", "de_DE"]) if bad_language_slips: action = self.env['ir.actions.act_window'].\ _for_xml_id('l10n_be_hr_payroll.l10n_be_hr_payroll_employee_lang_wizard_action') ctx = dict(self.env.context) ctx.update({ 'employee_ids': bad_language_slips.employee_id.ids, 'default_slip_ids': self.ids, }) action['context'] = ctx return action return super().action_payslip_done() def _get_pdf_reports(self): res = super()._get_pdf_reports() report_n = self.env.ref('l10n_be_hr_payroll.action_report_termination_holidays_n') report_n1 = self.env.ref('l10n_be_hr_payroll.action_report_termination_holidays_n1') for payslip in self: if payslip.struct_id.code == 'CP200HOLN1': res[report_n1] |= payslip elif payslip.struct_id.code == 'CP200HOLN': res[report_n] |= payslip return res def _get_data_files_to_update(self): # Note: file order should be maintained return super()._get_data_files_to_update() + [( 'l10n_be_hr_payroll', [ 'data/hr_rule_parameters_data.xml', ])] def _get_ffe_contribution_rate(self, worker_count): # Fond de fermeture d'entreprise # https://www.socialsecurity.be/employer/instructions/dmfa/fr/latest/instructions/special_contributions/other_specialcontributions/basiccontributions_closingcompanyfunds.html self.ensure_one() if self.company_id.l10n_be_ffe_employer_type == 'commercial': if worker_count < 20: rate = self.env['hr.rule.parameter']._get_parameter_from_code('l10n_be_ffe_commercial_rate_low', self.date_to) else: rate = self.env['hr.rule.parameter']._get_parameter_from_code('l10n_be_ffe_commercial_rate_high', self.date_to) else: rate = self.env['hr.rule.parameter']._get_parameter_from_code('l10n_be_ffe_noncommercial_rate', self.date_to) return rate def _get_be_termination_withholding_rate(self, localdict): # See: https://www.securex.eu/lex-go.nsf/vwReferencesByCategory_fr/52DA120D5DCDAE78C12584E000721081?OpenDocument self.ensure_one() def find_rates(x, rates): low_bound, high_bound = rates[0][0], rates[-1][1] x = min(max(low_bound, x), high_bound) for low, high, rate in rates: if low <= x <= high: return rate inputs = localdict['inputs'] if 'ANNUAL_TAXABLE' not in inputs: return 0 annual_taxable = inputs['ANNUAL_TAXABLE'].amount # Note: Exoneration for children in charge is managed on the salary.rule for the amount rates = self._rule_parameter('holiday_pay_pp_rates') pp_rate = find_rates(annual_taxable, rates) # Rate Reduction for children in charge children = self.employee_id.dependent_children children_reduction = self._rule_parameter('holiday_pay_pp_rate_reduction') if children and annual_taxable <= children_reduction.get(children, children_reduction[5])[1]: pp_rate *= (1 - children_reduction.get(children, children_reduction[5])[0] / 100.0) return pp_rate def _get_be_withholding_taxes(self, localdict): self.ensure_one() categories = localdict['categories'] def compute_basic_bareme(value): rates = self._rule_parameter('basic_bareme_rates') rates = [(limit or float('inf'), rate) for limit, rate in rates] # float('inf') because limit equals None for last level rates = sorted(rates) basic_bareme = 0 previous_limit = 0 for limit, rate in rates: basic_bareme += max(min(value, limit) - previous_limit, 0) * rate previous_limit = limit return float_round(basic_bareme, precision_rounding=0.01) def convert_to_month(value): return float_round(value / 12.0, precision_rounding=0.01, rounding_method='DOWN') version = self.version_id # PART 1: Withholding tax amount computation withholding_tax_amount = 0.0 taxable_amount = categories['GROSS'] # Base imposable if self.date_from.year < 2023: lower_bound = taxable_amount - taxable_amount % 15 else: lower_bound = taxable_amount # yearly_gross_revenue = Revenu Annuel Brut yearly_gross_revenue = lower_bound * 12.0 # yearly_net_taxable_amount = Revenu Annuel Net Imposable if yearly_gross_revenue <= self._rule_parameter('yearly_gross_revenue_bound_expense'): yearly_net_taxable_revenue = yearly_gross_revenue * (1.0 - 0.3) else: yearly_net_taxable_revenue = yearly_gross_revenue - self._rule_parameter('expense_deduction') # BAREME III: Non resident if version.is_non_resident: basic_bareme = compute_basic_bareme(yearly_net_taxable_revenue) withholding_tax_amount = convert_to_month(basic_bareme) else: # BAREME I: Isolated or spouse with income if version._l10n_be_is_isolated() or (version._l10n_be_is_coupled() and version.spouse_fiscal_status != 'without_income'): basic_bareme = max(compute_basic_bareme(yearly_net_taxable_revenue) - self._rule_parameter('deduct_single_with_income'), 0.0) withholding_tax_amount = convert_to_month(basic_bareme) # BAREME II: spouse without income if version._l10n_be_is_coupled() and version.spouse_fiscal_status == 'without_income': yearly_net_taxable_revenue_for_spouse = min(yearly_net_taxable_revenue * 0.3, self._rule_parameter('max_spouse_income')) basic_bareme_1 = compute_basic_bareme(yearly_net_taxable_revenue_for_spouse) basic_bareme_2 = compute_basic_bareme(yearly_net_taxable_revenue - yearly_net_taxable_revenue_for_spouse) withholding_tax_amount = convert_to_month(max(basic_bareme_1 + basic_bareme_2 - 2 * self._rule_parameter('deduct_single_with_income'), 0)) # Reduction for other family charges if (version.children and version.dependent_children) or (version.other_dependent_people and (version.dependent_seniors or version.dependent_juniors)): if version._l10n_be_is_isolated() or (version._l10n_be_is_coupled() and version.spouse_fiscal_status != 'without_income'): if version._l10n_be_is_isolated() and version.dependent_children: withholding_tax_amount -= self._rule_parameter('disabled_dependent_deduction') if version.disabled: withholding_tax_amount -= self._rule_parameter('disabled_dependent_deduction') if version.other_dependent_people and version.dependent_seniors: withholding_tax_amount -= self._rule_parameter('dependent_senior_deduction') * version.dependent_seniors if version.other_dependent_people and version.dependent_juniors: withholding_tax_amount -= self._rule_parameter('disabled_dependent_deduction') * version.dependent_juniors if version._l10n_be_is_coupled() and version.spouse_fiscal_status == 'low_income': withholding_tax_amount -= self._rule_parameter('spouse_low_income_deduction') if version._l10n_be_is_coupled() and version.spouse_fiscal_status == 'low_pension': withholding_tax_amount -= self._rule_parameter('spouse_other_income_deduction') if version._l10n_be_is_coupled() and version.spouse_fiscal_status == 'without_income': if version.disabled: withholding_tax_amount -= self._rule_parameter('disabled_dependent_deduction') if version.disabled_spouse_bool: withholding_tax_amount -= self._rule_parameter('disabled_dependent_deduction') if version.other_dependent_people and version.dependent_seniors: withholding_tax_amount -= self._rule_parameter('dependent_senior_deduction') * version.dependent_seniors if version.other_dependent_people and version.dependent_juniors: withholding_tax_amount -= self._rule_parameter('disabled_dependent_deduction') * version.dependent_juniors # Child Allowances n_children = version.dependent_children if n_children > 0: children_deduction = self._rule_parameter('dependent_basic_children_deduction') if n_children <= 8: withholding_tax_amount -= children_deduction.get(n_children, 0.0) if n_children > 8: withholding_tax_amount -= children_deduction.get(8, 0.0) + (n_children - 8) * self._rule_parameter('dependent_children_deduction') return - max(withholding_tax_amount, 0.0) def _get_be_special_social_cotisations(self, localdict): self.ensure_one() def find_rate(x, rates): for low, high, rate, basis, min_amount, max_amount in rates: if low <= x <= high: return low, high, rate, basis, min_amount, max_amount return 0, 0, 0, 0, 0, 0 categories = localdict['categories'] version = self.version_id employee = version.employee_id wage = categories['BASIC'] if not wage or version.is_non_resident: return 0.0 if version._l10n_be_is_isolated() or (version._l10n_be_is_coupled() and version.spouse_fiscal_status == 'without_income'): rates = self._rule_parameter('cp200_monss_isolated') if not rates: rates = [ (0.00, 1945.38, 0.00, 0.00, 0.00, 0.00), (1945.39, 2190.18, 0.076, 0.00, 0.00, 18.60), (2190.19, 6038.82, 0.011, 18.60, 0.00, 60.94), (6038.83, 999999999.00, 1.000, 60.94, 0.00, 60.94), ] low, dummy, rate, basis, min_amount, max_amount = find_rate(wage, rates) return -min(max(basis + (wage - low + 0.01) * rate, min_amount), max_amount) if version._l10n_be_is_coupled() and version.spouse_fiscal_status != 'without_income': rates = self._rule_parameter('cp200_monss_couple') if not rates: rates = [ (0.00, 1095.09, 0.00, 0.00, 0.00, 0.00), (1095.10, 1945.38, 0.00, 9.30, 9.30, 9.30), (1945.39, 2190.18, 0.076, 0.00, 9.30, 18.60), (2190.19, 6038.82, 0.011, 18.60, 0.00, 51.64), (6038.83, 999999999.00, 1.000, 51.64, 51.64, 51.64), ] low, dummy, rate, basis, min_amount, max_amount = find_rate(wage, rates) if isinstance(max_amount, tuple): if version.spouse_fiscal_status in ['high_income', 'low_income']: # conjoint avec revenus professionnels max_amount = max_amount[0] else: # conjoint sans revenus professionnels max_amount = max_amount[1] return -min(max(basis + (wage - low + 0.01) * rate, min_amount), max_amount) return 0.0 def _get_be_ip(self, localdict): self.ensure_one() contract = self.version_id if not contract.ip: return 0.0 return self._get_paid_amount() * contract.ip_wage_rate / 100.0 def _get_be_ip_deduction(self, localdict): self.ensure_one() tax_rate = 0.15 ip_amount = self._get_be_ip(localdict) if not ip_amount: return 0.0 ip_deduction_bracket_1 = self._rule_parameter('ip_deduction_bracket_1') ip_deduction_bracket_2 = self._rule_parameter('ip_deduction_bracket_2') if 0.0 <= ip_amount <= ip_deduction_bracket_1: tax_rate = tax_rate / 2.0 elif ip_deduction_bracket_1 < ip_amount <= ip_deduction_bracket_2: tax_rate = tax_rate * 3.0 / 4.0 return - min(ip_amount * tax_rate, 11745) def _get_employment_bonus_employees_volet_A(self, localdict): categories = localdict['categories'] result_rules = localdict['result_rules'] if not self.worked_days_line_ids and not self.env.context.get('salary_simulation'): return 0 # S = (W / H) * U # W = salaire brut # H = le nombre d'heures de travail déclarées avec un code prestations 1, 3, 4, 5 et 20; # U = le nombre maximum d'heures de prestations pour le mois concerné dans le régime de travail concerné if self.env.context.get('salary_simulation'): paid_hours = 1 total_hours = 1 else: worked_days = self.worked_days_line_ids.filtered(lambda wd: wd.code not in ['LEAVE300', 'LEAVE301', 'MEDIC01']) paid_hours = sum(worked_days.filtered(lambda wd: wd.amount).mapped('number_of_hours')) # H total_hours = sum(worked_days.mapped('number_of_hours')) # U # 1. - Détermination du salaire mensuel de référence (S) basic = categories['BRUT'] - result_rules['HolPayRecN']['total'] - result_rules['HolPayRecN1']['total'] salary = float_round(basic / paid_hours, precision_digits=2) * total_hours # S = (W/H) x U # 2. - Détermination du montant de base de la réduction (R) bonus_basic_amount_volet_A = self._rule_parameter('work_bonus_basic_amount_volet_A') wage_lower_bound = self._rule_parameter('work_bonus_reference_wage_low') wage_middle_bound = self._rule_parameter('l10n_be_work_bonus_reference_wage_middle') wage_higher_bound = self._rule_parameter('work_bonus_reference_wage_high') if salary <= wage_lower_bound: result = bonus_basic_amount_volet_A elif salary <= wage_middle_bound: result = bonus_basic_amount_volet_A elif salary <= wage_higher_bound: coeff = self._rule_parameter('work_bonus_coeff') result = bonus_basic_amount_volet_A - (coeff * (salary - wage_middle_bound)) else: result = 0 # 3. - Détermination du montant de la réduction (P) result = float_round(paid_hours / total_hours, precision_digits=2) * result # P = (H/U) x R return result def _get_employment_bonus_employees_volet_B(self, localdict): categories = localdict['categories'] result_rules = localdict['result_rules'] if not self.worked_days_line_ids and not self.env.context.get('salary_simulation'): return 0 # S = (W / H) * U # W = salaire brut # H = le nombre d'heures de travail déclarées avec un code prestations 1, 3, 4, 5 et 20; # U = le nombre maximum d'heures de prestations pour le mois concerné dans le régime de travail concerné if self.env.context.get('salary_simulation'): paid_hours = 1 total_hours = 1 else: worked_days = self.worked_days_line_ids.filtered(lambda wd: wd.code not in ['LEAVE300', 'LEAVE301', 'MEDIC01']) paid_hours = sum(worked_days.filtered(lambda wd: wd.amount).mapped('number_of_hours')) # H total_hours = sum(worked_days.mapped('number_of_hours')) # U # 1. - Détermination du salaire mensuel de référence (S) basic = categories['BRUT'] - result_rules['HolPayRecN']['total'] - result_rules['HolPayRecN1']['total'] salary = float_round(basic / paid_hours, precision_digits=2) * total_hours # S = (W/H) x U # 2. - Détermination du montant de base de la réduction (R) bonus_basic_amount = self._rule_parameter('work_bonus_basic_amount') wage_lower_bound = self._rule_parameter('work_bonus_reference_wage_low') wage_middle_bound = self._rule_parameter('l10n_be_work_bonus_reference_wage_middle') wage_higher_bound = self._rule_parameter('work_bonus_reference_wage_high') if salary <= wage_lower_bound: result = bonus_basic_amount elif salary <= wage_middle_bound: coeff = self._rule_parameter('l10n_be_work_bonus_coeff_low') result = bonus_basic_amount - (coeff * (salary - wage_lower_bound)) elif salary <= wage_higher_bound: result = 0 else: result = 0 # 3. - Détermination du montant de la réduction (P) result = float_round(paid_hours / total_hours, precision_digits=2) * result # P = (H/U) x R return result # ref: https://www.socialsecurity.be/employer/instructions/dmfa/fr/latest/instructions/deductions/workers_reductions/workbonus.html def _get_employment_bonus_employees(self, localdict): self.ensure_one() categories = localdict['categories'] result_rules = localdict['result_rules'] if self.date_from >= date(2024, 4, 1): bonus_volet_A = localdict['result_rules']['EmpBonus.A']['total'] bonus_volet_B = localdict['result_rules']['EmpBonus.B']['total'] result = bonus_volet_A + bonus_volet_B return min(result, -categories['ONSS']) bonus_basic_amount = self._rule_parameter('work_bonus_basic_amount') wage_lower_bound = self._rule_parameter('work_bonus_reference_wage_low') if not self.worked_days_line_ids and not self.env.context.get('salary_simulation'): return 0 # S = (W / H) * U # W = salaire brut # H = le nombre d'heures de travail déclarées avec un code prestations 1, 3, 4, 5 et 20; # U = le nombre maximum d'heures de prestations pour le mois concerné dans le régime de travail concerné if self.env.context.get('salary_simulation'): paid_hours = 1 total_hours = 1 else: worked_days = self.worked_days_line_ids.filtered(lambda wd: wd.code not in ['LEAVE300', 'LEAVE301', 'MEDIC01']) paid_hours = sum(worked_days.filtered(lambda wd: wd.amount).mapped('number_of_hours')) # H total_hours = sum(worked_days.mapped('number_of_hours')) # U # 1. - Détermination du salaire mensuel de référence (S) basic = categories['BRUT'] - result_rules['HolPayRecN']['total'] - result_rules['HolPayRecN1']['total'] salary = float_round(basic / paid_hours, precision_digits=2) * total_hours # S = (W/H) x U # 2. - Détermination du montant de base de la réduction (R) if self.date_from < date(2023, 7, 1): if salary <= wage_lower_bound: result = bonus_basic_amount elif salary <= self._rule_parameter('work_bonus_reference_wage_high'): coeff = self._rule_parameter('work_bonus_coeff') result = bonus_basic_amount - (coeff * (salary - wage_lower_bound)) else: result = 0 else: if salary <= wage_lower_bound: result = bonus_basic_amount elif salary <= self._rule_parameter('l10n_be_work_bonus_reference_wage_middle'): coeff = self._rule_parameter('l10n_be_work_bonus_coeff_low') result = bonus_basic_amount - (coeff * (salary - wage_lower_bound)) elif salary <= self._rule_parameter('work_bonus_reference_wage_high'): coeff = self._rule_parameter('work_bonus_coeff') result = bonus_basic_amount - (coeff * (salary - wage_lower_bound)) else: result = 0 # 3. - Détermination du montant de la réduction (P) result = float_round(paid_hours / total_hours, precision_digits=2) * result # P = (H/U) x R return min(result, -categories['ONSS']) def _get_withholding_taxes_after_child_allowances(self, rates, gross, apply_reduction=True): def find_rates(x, rates): low_bound, high_bound = rates[0][0], rates[-1][1] x = min(max(low_bound, x), high_bound) for low, high, rate in rates: if low <= x <= high: return rate / 100.0 children_exoneration = self._rule_parameter('holiday_pay_pp_exoneration') children_reduction = self._rule_parameter('holiday_pay_pp_rate_reduction') version = self.version_id monthly_revenue = version._get_contract_wage() # Count ANT in yearly remuneration if version.internet: monthly_revenue += 5.0 if version.mobile and not version.internet: monthly_revenue += 4.0 + 5.0 if version.mobile and version.internet: monthly_revenue += 4.0 if version.has_laptop: monthly_revenue += 7.0 yearly_revenue = monthly_revenue * (1 - 0.1307) * 12.0 if version.transport_mode_car: if 'vehicle_id' in self: yearly_revenue += self.vehicle_id._get_car_atn(date=self.date_from) * 12.0 else: yearly_revenue += version.car_atn * 12.0 # Exoneration children = version.dependent_children if children > 0 and yearly_revenue <= children_exoneration.get(children, children_exoneration[12]): yearly_revenue -= children_exoneration.get(children, children_exoneration[12]) - yearly_revenue yearly_revenue = max(yearly_revenue, 0) withholding_tax_amount = gross * find_rates(yearly_revenue, rates) # Reduction if (apply_reduction and children > 0 and yearly_revenue <= children_reduction.get(children, children_reduction[5])[1] ): withholding_tax_amount *= (1 - children_reduction.get(children, children_reduction[5])[0] / 100.0) return - withholding_tax_amount def _get_be_double_holiday_withholding_taxes(self, localdict): self.ensure_one() # See: https://www.securex.eu/lex-go.nsf/vwReferencesByCategory_fr/52DA120D5DCDAE78C12584E000721081?OpenDocument rates = self._rule_parameter('holiday_pay_pp_rates') categories = localdict['categories'] if self.struct_id.code == "CP200DOUBLE": gross = categories['GROSS'] elif self.struct_id.code == "CP200MONTHLY": gross = categories['DDPG'] return self._get_withholding_taxes_after_child_allowances(rates, gross) def _get_thirteen_month_withholding_taxes(self, localdict): self.ensure_one() # See: https://www.securex.eu/lex-go.nsf/vwReferencesByCategory_fr/52DA120D5DCDAE78C12584E000721081?OpenDocument rates = self._rule_parameter('exceptional_allowances_pp_rates') categories = localdict['categories'] gross = categories['GROSS'] return self._get_withholding_taxes_after_child_allowances(rates, gross) def _get_termination_fees_withholding_taxes(self, localdict): # See: https://www.securex.eu/lex-go.nsf/vwReferencesByCategory_fr/52DA120D5DCDAE78C12584E000721081?OpenDocument if self.date_from.year >= 2024: self.ensure_one() rates = self._rule_parameter('termination_fees_pp_rates') categories = localdict['categories'] gross = categories['GROSS'] return self._get_withholding_taxes_after_child_allowances(rates, gross, apply_reduction=False) else: return self._get_be_withholding_taxes(localdict) def _get_withholding_reduction(self, localdict): self.ensure_one() categories = localdict['categories'] if categories['EmpBonus']: bonus_volet_A_rate = self.env['hr.rule.parameter']._get_parameter_from_code('work_bonus_volet_A_rate', date=self.date_to, raise_if_not_found=False) or 0.3314 if self.date_from >= date(2024, 4, 1): bonus_volet_A = localdict['result_rules']['EmpBonus.A']['total'] bonus_volet_B = localdict['result_rules']['EmpBonus.B']['total'] bonus_volet_B_rate = self.env['hr.rule.parameter']._get_parameter_from_code('work_bonus_volet_B_rate', date=self.date_to, raise_if_not_found=False) or 0.5254 reduction = bonus_volet_A * bonus_volet_A_rate + bonus_volet_B * bonus_volet_B_rate else: reduction = categories['EmpBonus'] * bonus_volet_A_rate return min(abs(categories['PP']), reduction) return 0.0 def _get_impulsion_plan_amount(self, localdict): self.ensure_one() start = self.employee_id.contract_date_start end = self.date_to number_of_months = (end.year - start.year) * 12 + (end.month - start.month) numerator = sum(wd.number_of_hours for wd in self.worked_days_line_ids if wd.amount > 0) denominator = 4 * self.version_id.resource_calendar_id.hours_per_week coefficient = numerator / denominator if self.version_id.l10n_be_impulsion_plan == '25yo': if 0 <= number_of_months <= 23: theorical_amount = 500.0 elif 24 <= number_of_months <= 29: theorical_amount = 250.0 elif 30 <= number_of_months <= 35: theorical_amount = 125.0 else: theorical_amount = 0 return min(theorical_amount, theorical_amount * coefficient) if self.version_id.l10n_be_impulsion_plan == '12mo': if 0 <= number_of_months <= 11: theorical_amount = 500.0 elif 12 <= number_of_months <= 17: theorical_amount = 250.0 elif 18 <= number_of_months <= 23: theorical_amount = 125.0 else: theorical_amount = 0 return min(theorical_amount, theorical_amount * coefficient) return 0 def _get_onss_restructuring(self, localdict): self.ensure_one() # Source: https://www.onem.be/fr/documentation/feuille-info/t115 # 1. Grant condition # A worker who has been made redundant following a restructuring benefits from a reduction in his personal contributions under certain conditions: # - The engagement must take place during the validity period of the reduction card. The reduction card is valid for 6 months, calculated from date to date, following the termination of the employment contract. # - The gross monthly reference salary does not exceed # o 3.071.90: if the worker is under 30 years of age at the time of entry into service # o 4,504.93: if the worker is at least 30 years old at the time of entry into service # 2. Amount of reduction # Lump sum reduction of € 133.33 per month (full time - full month) in personal social security contributions. # If the worker does not work full time for a full month or if he works part time, this amount is reduced proportionally. # So the reduction is: # 1. Full-time worker: P = (J / D) x 133.33 # - Full time with full one month benefits: € 133.33 # Example the worker entered service on 02/01/2021 and worked the whole month # - Full time with incomplete services: P = (J / D) x 133.33 # Example: the worker entered service on February 15 -> (10/20) x 133.33 = € 66.665 # P = amount of reduction # J = the number of worker's days declared with a benefit code 1, 3, 4, 5 and 20 .; # D = the maximum number of days of benefits for the month concerned in the work scheme concerned. # 2. Part-time worker: P = (H / U) x 133.33 # Example: the worker starts 02/01/2021 and works 19 hours a week. # (76/152) x 133.33 = € 66.665 # Example: the worker starts 02/15/2021 and works 19 hours a week. # (38/155) x 133.33 = 33.335 € # P = amount of reduction # H = the number of working hours declared with a service code 1, 3, 4, 5 and 20; # U = the number of monthly hours corresponding to D. # 3. Duration of this reduction # The benefit applies to all periods of occupation that fall within the period that: # starts to run on the day you start your first occupation during the validity period of the restructuring reduction card; # and which ends on the last day of the second quarter following the start date of this first occupation. # 4. Formalities to be completed # The employer deducts the lump sum from the normal amount of personal contributions when paying the remuneration. # The ONEM communicates to the ONSS the data concerning the identification of the worker and the validity date of the card. # 5. Point of attention # If the worker also benefits from a reduction in his personal contributions for low wages, the cumulation between this reduction and that for restructuring cannot exceed the total amount of personal contributions due. # If this is the case, we must first reduce the restructuring reduction. # Example: # - personal contributions = 200 € # - restructuring reduction = € 133.33 # - low salary reduction = 100 € # The total amount of reductions exceeds the contributions due. We must therefore first reduce the restructuring reduction and then the balance of the low wage reduction. if not self.worked_days_line_ids: return 0 employee = self.version_id.employee_id contract_date_start = employee.contract_date_start birthdate = employee.birthday age = relativedelta(contract_date_start, birthdate).years if age < 30: threshold = self._rule_parameter('onss_restructuring_before_30') else: threshold = self._rule_parameter('onss_restructuring_after_30') salary = self.paid_amount if salary > threshold: return 0 amount = self._rule_parameter('onss_restructuring_amount') paid_hours = sum(self.worked_days_line_ids.filtered(lambda wd: wd.amount).mapped('number_of_hours')) total_hours = sum(self.worked_days_line_ids.mapped('number_of_hours')) ratio = paid_hours / total_hours if total_hours else 0 start = contract_date_start end = self.date_to number_of_months = (end.year - start.year) * 12 + (end.month - start.month) if 0 <= number_of_months <= 6: return amount * ratio return 0 def _get_representation_fees_threshold(self, localdict): return self._rule_parameter('cp200_representation_fees_threshold') def _get_representation_fees(self, localdict): self.ensure_one() categories = localdict['categories'] worked_days = localdict['worked_days'] # Representation fees aren't paid if there's no basic pay or no time worked if categories['BASIC'] and ( not all(day.work_entry_type_id.is_leave for day in worked_days.values()) or self.env.context.get('salary_simulation') ): version = self.version_id calendar = version.resource_calendar_id days_per_week = calendar._get_days_per_week() work_time_rate = version.resource_calendar_id.work_time_rate threshold = 0 if ('OUT' in worked_days and worked_days['OUT'].number_of_hours) else self._get_representation_fees_threshold(localdict) if days_per_week and self.env.context.get('salary_simulation_full_time'): result = version.representation_fees elif days_per_week and version.representation_fees > threshold: # Only part of the representation costs are pro-rated because certain costs are fully # covered for the company (teleworking costs, mobile phone, internet, etc., namely (for 2021): # - 144.31 € (Tax, since 2021 - coronavirus) # - 30 € (internet) # - 25 € (phone) # - 80 € (car management fees) # = Total € 279.31 # Legally, they are not prorated according to the occupancy fraction. # In summary, those who select amounts of for example 150 € and 250 €, have nothing pro-rated # because the amounts are covered in an irreducible way. # For those who have selected the maximum of 399 €, there is therefore only the share of # +-120 € of representation expenses which is then subject to prorating. # Credit time, but with only half days (otherwise it's taken into account) is_credit_time_only_half_days = version.l10n_be_time_credit and work_time_rate and \ work_time_rate < 100 and (days_per_week == 5 or not self.representation_fees_missing_days) # Contractual part time is_contractual_part_time = not version.l10n_be_time_credit and work_time_rate < 100 if is_credit_time_only_half_days or is_contractual_part_time: total_amount = threshold + (version.representation_fees - threshold) * work_time_rate / 100 else: total_amount = version.representation_fees if total_amount > threshold: daily_amount = (total_amount - threshold) * 3 / 13 / days_per_week result = max(0, total_amount - daily_amount * self.representation_fees_missing_days) elif days_per_week: result = version.representation_fees else: result = 0 else: result = 0 return float_round(result, precision_digits=2) def _get_serious_representation_fees(self, localdict): self.ensure_one() return min(self._get_representation_fees(localdict), self._get_representation_fees_threshold(localdict)) def _get_volatile_representation_fees(self, localdict): self.ensure_one() return max(self._get_representation_fees(localdict) - self._get_representation_fees_threshold(localdict), 0) def _get_holiday_pay_recovery(self, localdict, recovery_type): """ See: https://www.socialsecurity.be/employer/instructions/dmfa/fr/latest/intermediates#intermediate_row_196b32c7-9d98-4233-805d-ca9bf123ff48 When an employee changes employer, he receives the termination pay and a vacation certificate stating his vacation rights. When he subsequently takes vacation with his new employer, the latter must, when paying the simple vacation pay, take into account the termination pay that the former employer has already paid. From an exchange of letters with the SPF ETCS and the Inspectorate responsible for the control of social laws, it turned out that when calculating the simple vacation pay, the new employer must deduct the exit pay based on the number of vacation days taken. The rule in the ONSS instructions according to which the new employer must take into account the exit vacation pay only once when the employee takes his main vacation is abolished. When the salary of an employee with his new employer is higher than the salary he had with his previous employer, his new employer will have, each time he takes vacation days, to make a calculation to supplement the nest egg. exit from these days up to the amount of the simple vacation pay to which the worker is entitled. Concretely: 2020 vacation certificate (full year): - simple allowance 1,917.50 EUR - this amounts to 1917.50 / 20 EUR = 95.875 EUR per day of vacation - holidays 2021, for example when taking 5 days in April 2021 - monthly salary with the new employer: 3000.00 EUR / month - simple nest egg: - remuneration code 12: 5/20 x 1917.50 = 479.38 EUR - remuneration code 1: (5/22 x 3000.00) - 479.38 = 202.44 EUR - ordinary days for the month of April: - remuneration code 1: 17/22 x 3000.00 = 2318.18 EUR - The examples included in the ONSS instructions will be adapted in the next publication. """ self.ensure_one() worked_days = localdict['worked_days'] if 'LEAVE120' not in worked_days or not worked_days['LEAVE120'].amount: return 0 employee = self.employee_id number_of_days = employee['l10n_be_holiday_pay_number_of_days_' + recovery_type] all_payslips_during_civil_year = self.env['hr.payslip'].search([ ('employee_id', '=', employee.id), ('date_from', '>=', date(self.date_from.year, 1, 1)), ('date_to', '<=', date(self.date_from.year, 12, 31)), ('state', 'in', ['validated', 'paid']), ]) paid_leave_days = all_payslips_during_civil_year._get_worked_days_line_values(['LEAVE120'], ['number_of_days'], True)['LEAVE120']['sum']['number_of_days'] remaining_day = number_of_days - paid_leave_days if remaining_day <= 0: return 0 if self.wage_type == 'hourly': employee_hourly_cost = self.version_id.hourly_wage else: if self.date_from.year < 2024: employee_hourly_cost = self.version_id.contract_wage / self.sum_worked_hours else: employee_hourly_cost = self.version_id.contract_wage * 3 / 13 / self.version_id.resource_calendar_id.hours_per_week employee_hours_per_day = self.version_id.resource_calendar_id.hours_per_day remaining_day_amount = min(remaining_day, number_of_days) * employee_hourly_cost * employee_hours_per_day days_to_recover = employee['l10n_be_holiday_pay_to_recover_' + recovery_type] max_amount_to_recover = min(days_to_recover, employee_hourly_cost * number_of_days * employee_hours_per_day) paid_leave_data = self._get_worked_days_line_values(['LEAVE120'], ['amount', 'number_of_hours'], True)['LEAVE120']['sum'] holiday_amount = min(paid_leave_data['amount'], employee_hourly_cost * paid_leave_data['number_of_hours']) remaining_amount = max(0, max_amount_to_recover - employee['l10n_be_holiday_pay_recovered_' + recovery_type]) return - min(remaining_amount, remaining_day_amount, holiday_amount) def _get_holiday_pay_recovery_n(self, localdict): return self._get_holiday_pay_recovery(localdict, 'n') def _get_holiday_pay_recovery_n1(self, localdict): return self._get_holiday_pay_recovery(localdict, 'n1') def _get_termination_n_basic_double(self, localdict): self.ensure_one() inputs = localdict['inputs'] result_qty = 1 result_rate = 6.8 result = inputs['GROSS_REF'].amount if 'GROSS_REF' in inputs else 0 result_name = inputs['GROSS_REF'].name if 'GROSS_REF' in inputs else None date_from = self.date_from if self.struct_id.code == "CP200HOLN1": existing_double_pay = self.env['hr.payslip'].search([ ('employee_id', '=', self.employee_id.id), ('state', 'in', ['validated', 'paid']), ('struct_id', '=', self.env.ref('l10n_be_hr_payroll.hr_payroll_structure_cp200_double_holiday').id), ('date_from', '>=', date(date_from.year, 1, 1)), ('date_to', '<=', date(date_from.year, 12, 31)), ]) if existing_double_pay: result = 0 return (result_qty, result_rate, result, result_name)