diff --git a/l10n_us_hr_payroll_401k/__init__.py b/l10n_us_hr_payroll_401k/__init__.py new file mode 100644 index 00000000..09434554 --- /dev/null +++ b/l10n_us_hr_payroll_401k/__init__.py @@ -0,0 +1,3 @@ +# Part of Hibou Suite Professional. See LICENSE_PROFESSIONAL file for full copyright and licensing details. + +from . import models diff --git a/l10n_us_hr_payroll_401k/__manifest__.py b/l10n_us_hr_payroll_401k/__manifest__.py new file mode 100644 index 00000000..7e77a6bb --- /dev/null +++ b/l10n_us_hr_payroll_401k/__manifest__.py @@ -0,0 +1,24 @@ +# Part of Hibou Suite Professional. See LICENSE_PROFESSIONAL file for full copyright and licensing details. + +{ + 'name': 'USA - 401K Payroll', + 'author': 'Hibou Corp. ', + 'version': '13.0.1.0.0', + 'category': 'Payroll', + 'depends': [ + 'l10n_us_hr_payroll', + ], + 'description': """ +* Adds fields to HR Contract for amount or percentage to withhold for retirement savings. +* Adds rules to withhold and have a company match. + """, + + 'data': [ + 'data/payroll.xml', + 'views/contract_views.xml', + ], + 'demo': [ + ], + 'auto_install': False, + 'license': 'OPL-1', +} diff --git a/l10n_us_hr_payroll_401k/data/payroll.xml b/l10n_us_hr_payroll_401k/data/payroll.xml new file mode 100644 index 00000000..78a3771e --- /dev/null +++ b/l10n_us_hr_payroll_401k/data/payroll.xml @@ -0,0 +1,119 @@ + + + + + IRA Provider + 1 + + + + + Employee 401K Contribution Limit + ee_401k_contribution_limit + + + + 19500.0 + + + + + + Employee 401K Catch-up + ee_401k_catchup + + + + 6500.0 + + + + + + Employer 401K Contribution Limit + er_401k_contribution_limit + + + + 37500.0 + + + + + + Employer 401K Match (%) + er_401k_match_percent + + + + + 0.0 + + + + + + + + EE: 401K Traditional + EE_IRA + + + + EE: 401K Roth + EE_IRA_ROTH + + + + + ER: 401K Contribution + ER_IRA + + + + + + + + + EE: 401K + EE_IRA + python + result = ee_401k(contract.ira_amount, contract.ira_rate, payslip, categories, worked_days, inputs) + code + result = ee_401k(contract.ira_amount, contract.ira_rate, payslip, categories, worked_days, inputs) + + + + + + + + + EE: 401K Roth + EE_IRA_ROTH + python + result = ee_401k(contract.ira_roth_amount, contract.ira_roth_rate, payslip, categories, worked_days, inputs) + code + result = ee_401k(contract.ira_roth_amount, contract.ira_roth_rate, payslip, categories, worked_days, inputs) + + + + + + + + + ER: 401K Match + ER_IRA_MATCH + python + result = er_401k_match(categories.BASIC, payslip, categories, worked_days, inputs) + code + result = er_401k_match(categories.BASIC, payslip, categories, worked_days, inputs) + + + + + + \ No newline at end of file diff --git a/l10n_us_hr_payroll_401k/models/__init__.py b/l10n_us_hr_payroll_401k/models/__init__.py new file mode 100644 index 00000000..9b8578e7 --- /dev/null +++ b/l10n_us_hr_payroll_401k/models/__init__.py @@ -0,0 +1,4 @@ +# Part of Hibou Suite Professional. See LICENSE_PROFESSIONAL file for full copyright and licensing details. + +from . import contract +from . import payslip diff --git a/l10n_us_hr_payroll_401k/models/contract.py b/l10n_us_hr_payroll_401k/models/contract.py new file mode 100644 index 00000000..5ce008a9 --- /dev/null +++ b/l10n_us_hr_payroll_401k/models/contract.py @@ -0,0 +1,21 @@ +# Part of Hibou Suite Professional. See LICENSE_PROFESSIONAL file for full copyright and licensing details. + +from odoo import fields, models + + +class HRContract(models.Model): + _inherit = 'hr.contract' + + ira_amount = fields.Float(string="401K Contribution Amount", + help="Pre-Tax (traditional) Contribution Amount") + ira_rate = fields.Float(string="401K Contribution (%)", + help="Pre-Tax (traditional) Contribution Percentage") + ira_roth_amount = fields.Float(string="Roth 401K Contribution Amount", + help="Post-Tax Contribution Amount") + ira_roth_rate = fields.Float(string="Roth 401K Contribution (%)", + help="Post-Tax Contribution Percentage") + + def company_401k_match_percent(self, payslip): + # payslip is payslip rule's current payslip browse object + # Override if you have employee, payslip, or contract differences. + return payslip.rule_parameter('er_401k_match_percent') diff --git a/l10n_us_hr_payroll_401k/models/payslip.py b/l10n_us_hr_payroll_401k/models/payslip.py new file mode 100644 index 00000000..5725ad3a --- /dev/null +++ b/l10n_us_hr_payroll_401k/models/payslip.py @@ -0,0 +1,83 @@ +# Part of Hibou Suite Professional. See LICENSE_PROFESSIONAL file for full copyright and licensing details. + +from datetime import date +from odoo import fields, models + + +def ee_401k(amount, rate, payslip, categories, worked_days, inputs): + MAX = payslip.rule_parameter('ee_401k_contribution_limit') + if payslip.dict.ira_period_age() >= 50: + MAX += payslip.rule_parameter('ee_401k_catchup') + wages = categories.BASIC + year = payslip.date_to.year + next_year = str(year + 1) + from_ = str(year) + '-01-01' + to = next_year + '-01-01' + ytd = payslip.sum_category('EE_IRA', from_, to) + ytd += payslip.sum_category('EE_IRA_ROTH', from_, to) + remaining = MAX + ytd + if remaining <= 0.0: + result = 0 + else: + result = -amount + result -= (wages * rate) / 100.0 + if remaining + result <= 0.0: + result = -remaining + return result + + +def er_401k_match(wages, payslip, categories, worked_days, inputs): + MAX = payslip.rule_parameter('er_401k_contribution_limit') + employee_contrib = -(categories.EE_IRA + categories.EE_IRA_ROTH) + + year = payslip.date_to.year + next_year = str(year + 1) + from_ = str(year) + '-01-01' + to = next_year + '-01-01' + ytd = payslip.sum_category('ER_IRA', from_, to) + + rate = payslip.contract_id.company_401k_match_percent(payslip) + wages_match = (wages * rate) / 100.0 + if employee_contrib <= wages_match: + result = employee_contrib + else: + result = wages_match + remaining = MAX - ytd + if remaining <= 0.0: + result = 0 + else: + if remaining - result < 0.0: + result = remaining + return result + + +class HRPayslip(models.Model): + _inherit = 'hr.payslip' + + def _age_on_date(self, birthday, cutoff): + if isinstance(cutoff, str): + try: + cutoff = fields.Date.from_string(cutoff) + except: + cutoff = None + if cutoff is None: + # Dec. 31st in calendar year + cutoff = date(self.date_to.year, 12, 31) + if not birthday: + return -1 + years = cutoff.year - birthday.year + if birthday.month > cutoff.month or (birthday.month == cutoff.month and birthday.day > cutoff.day): + years -= 1 + return years + + def ira_period_age(self, cutoff=None): + birthday = self.employee_id.birthday + return self._age_on_date(birthday, cutoff) + + def _get_base_local_dict(self): + res = super()._get_base_local_dict() + res.update({ + 'ee_401k': ee_401k, + 'er_401k_match': er_401k_match, + }) + return res diff --git a/l10n_us_hr_payroll_401k/tests/__init__.py b/l10n_us_hr_payroll_401k/tests/__init__.py new file mode 100644 index 00000000..cf880a90 --- /dev/null +++ b/l10n_us_hr_payroll_401k/tests/__init__.py @@ -0,0 +1,3 @@ +# Part of Hibou Suite Professional. See LICENSE_PROFESSIONAL file for full copyright and licensing details. + +from . import test_payroll diff --git a/l10n_us_hr_payroll_401k/tests/test_payroll.py b/l10n_us_hr_payroll_401k/tests/test_payroll.py new file mode 100644 index 00000000..87e5fca4 --- /dev/null +++ b/l10n_us_hr_payroll_401k/tests/test_payroll.py @@ -0,0 +1,132 @@ +# Part of Hibou Suite Professional. See LICENSE_PROFESSIONAL file for full copyright and licensing details. + +from odoo import fields +from odoo.addons.l10n_us_hr_payroll.tests import common +from datetime import timedelta + + +class TestUsPayslip(common.TestUsPayslip): + EE_LIMIT = 19500.0 + EE_LIMIT_CATCHUP = 6500.0 + ER_LIMIT = 37500.0 + + def setUp(self): + super().setUp() + self.schedule_pay_salary = 'bi-weekly' + self.payslip_date_start = fields.Date.from_string('2020-01-01') + self.payslip_date_end = self.payslip_date_start + timedelta(days=14) + self.er_match_parameter = self.env.ref('l10n_us_hr_payroll_401k.rule_parameter_er_401k_match_percent_2020') + self.er_match_parameter.parameter_value = '4.0' # 4% match up to salary + + def test_01_payslip_traditional(self): + wage = 2000.0 + employee = self._createEmployee() + contract = self._createContract(employee, + wage=wage, + ira_rate=5.0, + schedule_pay=self.schedule_pay_salary) + payslip = self._createPayslip(employee, self.payslip_date_start, self.payslip_date_end) + payslip.compute_sheet() + ira_line = payslip.line_ids.filtered(lambda l: l.code == 'EE_IRA') + self.assertTrue(ira_line) + self.assertPayrollEqual(ira_line.amount, -100.0) + + er_ira_line = payslip.line_ids.filtered(lambda l: l.code == 'ER_IRA_MATCH') + self.assertTrue(er_ira_line) + self.assertPayrollEqual(er_ira_line.amount, 80.0) # 4% of wage up to their contribution + + contract.ira_rate = 0.0 + contract.ira_amount = 25.0 + payslip.compute_sheet() + ira_line = payslip.line_ids.filtered(lambda l: l.code == 'EE_IRA') + self.assertTrue(ira_line) + self.assertPayrollEqual(ira_line.amount, -25.0) + + er_ira_line = payslip.line_ids.filtered(lambda l: l.code == 'ER_IRA_MATCH') + self.assertTrue(er_ira_line) + self.assertPayrollEqual(er_ira_line.amount, 25.0) # 4% of wage up to their contribution + + def test_02_payslip_roth(self): + wage = 2000.0 + employee = self._createEmployee() + contract = self._createContract(employee, + wage=wage, + ira_roth_rate=5.0, + schedule_pay=self.schedule_pay_salary) + payslip = self._createPayslip(employee, self.payslip_date_start, self.payslip_date_end) + payslip.compute_sheet() + ira_line = payslip.line_ids.filtered(lambda l: l.code == 'EE_IRA_ROTH') + self.assertTrue(ira_line) + self.assertPayrollEqual(ira_line.amount, -100.0) + + er_ira_line = payslip.line_ids.filtered(lambda l: l.code == 'ER_IRA_MATCH') + self.assertTrue(er_ira_line) + self.assertPayrollEqual(er_ira_line.amount, 80.0) # 4% of wage up to their contribution + + contract.ira_roth_rate = 0.0 + contract.ira_roth_amount = 25.0 + payslip.compute_sheet() + ira_line = payslip.line_ids.filtered(lambda l: l.code == 'EE_IRA_ROTH') + self.assertTrue(ira_line) + self.assertPayrollEqual(ira_line.amount, -25.0) + + er_ira_line = payslip.line_ids.filtered(lambda l: l.code == 'ER_IRA_MATCH') + self.assertTrue(er_ira_line) + self.assertPayrollEqual(er_ira_line.amount, 25.0) # 4% of wage up to their contribution + + def test_10_payslip_limits(self): + self.er_match_parameter.parameter_value = '20.0' # 20% match up to salary + wage = 80000.0 + rate = 20.0 + employee = self._createEmployee() + contract = self._createContract(employee, + wage=wage, + ira_rate=rate, + schedule_pay=self.schedule_pay_salary) + + # Payslip 1 - 16k + payslip = self._createPayslip(employee, self.payslip_date_start, self.payslip_date_end) + payslip.compute_sheet() + ira_line = payslip.line_ids.filtered(lambda l: l.code == 'EE_IRA') + self.assertTrue(ira_line) + self.assertPayrollEqual(ira_line.amount, -(wage * rate / 100.0)) + er_ira_line = payslip.line_ids.filtered(lambda l: l.code == 'ER_IRA_MATCH') + self.assertTrue(er_ira_line) + self.assertPayrollEqual(er_ira_line.amount, -ira_line.amount) + common.process_payslip(payslip) + + # Payslip 2 - 3.5k + payslip = self._createPayslip(employee, self.payslip_date_start + timedelta(days=14), + self.payslip_date_end + timedelta(days=14)) + payslip.compute_sheet() + ira_line = payslip.line_ids.filtered(lambda l: l.code == 'EE_IRA') + self.assertTrue(ira_line) + self.assertPayrollEqual(ira_line.amount, -(self.EE_LIMIT-(wage * rate / 100.0))) + er_ira_line = payslip.line_ids.filtered(lambda l: l.code == 'ER_IRA_MATCH') + self.assertTrue(er_ira_line) + self.assertPayrollEqual(er_ira_line.amount, -ira_line.amount) + common.process_payslip(payslip) + + # Payslip 3 - 0 (over limit) + payslip = self._createPayslip(employee, self.payslip_date_start + timedelta(days=28), + self.payslip_date_end + timedelta(days=28)) + payslip.compute_sheet() + ira_line = payslip.line_ids.filtered(lambda l: l.code == 'EE_IRA') + self.assertFalse(ira_line) + er_ira_line = payslip.line_ids.filtered(lambda l: l.code == 'ER_IRA_MATCH') + self.assertFalse(er_ira_line) + + # Payslip 3 - Catch-up + employee.birthday = '1960-01-01' + payslip.compute_sheet() + ira_line = payslip.line_ids.filtered(lambda l: l.code == 'EE_IRA') + self.assertTrue(ira_line) + self.assertPayrollEqual(ira_line.amount, -self.EE_LIMIT_CATCHUP) + er_ira_line = payslip.line_ids.filtered(lambda l: l.code == 'ER_IRA_MATCH') + self.assertTrue(er_ira_line) + self.assertPayrollEqual(er_ira_line.amount, -ira_line.amount) + common.process_payslip(payslip) + + # Note that the company limit is higher than what is possible by 'match' + # because even with 100% (or more) you would never be able to out-pace + # the employee's own contributions. diff --git a/l10n_us_hr_payroll_401k/views/contract_views.xml b/l10n_us_hr_payroll_401k/views/contract_views.xml new file mode 100644 index 00000000..16c20352 --- /dev/null +++ b/l10n_us_hr_payroll_401k/views/contract_views.xml @@ -0,0 +1,20 @@ + + + + + hr.contract.form.inherit + hr.contract + + + + + + + + + + + + + + \ No newline at end of file