diff --git a/hr_payroll_hibou/models/res_config_settings.py b/hr_payroll_hibou/models/res_config_settings.py
index 98faeeac..9d47b99e 100644
--- a/hr_payroll_hibou/models/res_config_settings.py
+++ b/hr_payroll_hibou/models/res_config_settings.py
@@ -8,6 +8,7 @@ class ResConfigSettings(models.TransientModel):
# TODO We need MORE here...
module_l10n_us_hr_payroll = fields.Boolean(string='USA Payroll')
+ module_l10n_us_hr_payroll_401k = fields.Boolean(string='USA Payroll 401k')
payslip_sum_type = fields.Selection([
('date_from', 'Date From'),
diff --git a/hr_payroll_hibou/views/res_config_settings_views.xml b/hr_payroll_hibou/views/res_config_settings_views.xml
index a4dc1854..65c3bd51 100644
--- a/hr_payroll_hibou/views/res_config_settings_views.xml
+++ b/hr_payroll_hibou/views/res_config_settings_views.xml
@@ -24,6 +24,23 @@
+
+
+
+
+
+
+
+ Provide retirement plans with optional company matching.
+
+
+
+
+
+
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..5ffd1e06
--- /dev/null
+++ b/l10n_us_hr_payroll_401k/__manifest__.py
@@ -0,0 +1,25 @@
+# Part of Hibou Suite Professional. See LICENSE_PROFESSIONAL file for full copyright and licensing details.
+
+{
+ 'name': 'USA - 401K Payroll',
+ 'author': 'Hibou Corp. ',
+ 'version': '14.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',
+ 'views/payroll_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/migrations/13.0.0.0.1/pre-migration.py b/l10n_us_hr_payroll_401k/migrations/13.0.0.0.1/pre-migration.py
new file mode 100644
index 00000000..0d6b8ea7
--- /dev/null
+++ b/l10n_us_hr_payroll_401k/migrations/13.0.0.0.1/pre-migration.py
@@ -0,0 +1,22 @@
+# Part of Hibou Suite Professional. See LICENSE_PROFESSIONAL file for full copyright and licensing details.
+
+import odoo
+
+
+def migrate(cr, version):
+ """
+ Salary Rules can be archived by Odoo S.A. during migration.
+ This leaves them archived after the migration, and even un-archiving them
+ is not enough because they will then be pointed to a "migrated" structure.
+ """
+ env = odoo.api.Environment(cr, odoo.SUPERUSER_ID, {})
+ xml_refs = env['ir.model.data'].search([
+ ('module', '=', 'l10n_us_hr_payroll_401k'),
+ ('model', '=', 'hr.salary.rule'),
+ ])
+ # I don't know why Odoo makes these non-updatable...
+ xml_refs.write({'noupdate': False})
+
+ rule_ids = xml_refs.mapped('res_id')
+ rules = env['hr.salary.rule'].browse(rule_ids)
+ rules.write({'active': True})
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
diff --git a/l10n_us_hr_payroll_401k/views/payroll_views.xml b/l10n_us_hr_payroll_401k/views/payroll_views.xml
new file mode 100644
index 00000000..d89d89f9
--- /dev/null
+++ b/l10n_us_hr_payroll_401k/views/payroll_views.xml
@@ -0,0 +1,17 @@
+
+
+
+
+ hr.rule.parameter.search.inherit
+ hr.rule.parameter
+
+
+
+
+
+
+
+
+
\ No newline at end of file