mirror of
https://gitlab.com/hibou-io/hibou-odoo/suite.git
synced 2025-01-20 12:37:31 +02:00
[ADD] l10n_us_hr_payroll_401k: initial commit for Odoo 13.0
This commit is contained in:
3
l10n_us_hr_payroll_401k/__init__.py
Normal file
3
l10n_us_hr_payroll_401k/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
# Part of Hibou Suite Professional. See LICENSE_PROFESSIONAL file for full copyright and licensing details.
|
||||
|
||||
from . import models
|
||||
24
l10n_us_hr_payroll_401k/__manifest__.py
Normal file
24
l10n_us_hr_payroll_401k/__manifest__.py
Normal file
@@ -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. <hello@hibou.io>',
|
||||
'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',
|
||||
}
|
||||
119
l10n_us_hr_payroll_401k/data/payroll.xml
Normal file
119
l10n_us_hr_payroll_401k/data/payroll.xml
Normal file
@@ -0,0 +1,119 @@
|
||||
<?xml version="1.0" encoding="UTF-8" ?>
|
||||
<odoo>
|
||||
<!-- Partners and Contribution Registers -->
|
||||
<record id="res_partner_ira_provider" model="res.partner">
|
||||
<field name="name">IRA Provider</field>
|
||||
<field name="supplier_rank">1</field>
|
||||
</record>
|
||||
|
||||
<!-- Rule Parameters -->
|
||||
<record id="rule_parameter_ee_401k_contribution_limit" model="hr.rule.parameter">
|
||||
<field name="name">Employee 401K Contribution Limit</field>
|
||||
<field name="code">ee_401k_contribution_limit</field>
|
||||
<field name="country_id" ref="base.us"/>
|
||||
</record>
|
||||
<record id="rule_parameter_ee_401k_contribution_limit_2020" model="hr.rule.parameter.value">
|
||||
<field name="parameter_value">19500.0</field>
|
||||
<field name="rule_parameter_id" ref="rule_parameter_ee_401k_contribution_limit"/>
|
||||
<field name="date_from" eval="datetime(2020, 1, 1).date()"/>
|
||||
</record>
|
||||
|
||||
<record id="rule_parameter_ee_401k_catchup" model="hr.rule.parameter">
|
||||
<field name="name">Employee 401K Catch-up</field>
|
||||
<field name="code">ee_401k_catchup</field>
|
||||
<field name="country_id" ref="base.us"/>
|
||||
</record>
|
||||
<record id="rule_parameter_ee_401k_catchup_2020" model="hr.rule.parameter.value">
|
||||
<field name="parameter_value">6500.0</field>
|
||||
<field name="rule_parameter_id" ref="rule_parameter_ee_401k_catchup"/>
|
||||
<field name="date_from" eval="datetime(2020, 1, 1).date()"/>
|
||||
</record>
|
||||
|
||||
<record id="rule_parameter_er_401k_contribution_limit" model="hr.rule.parameter">
|
||||
<field name="name">Employer 401K Contribution Limit</field>
|
||||
<field name="code">er_401k_contribution_limit</field>
|
||||
<field name="country_id" ref="base.us"/>
|
||||
</record>
|
||||
<record id="rule_parameter_er_401k_contribution_limit_2020" model="hr.rule.parameter.value">
|
||||
<field name="parameter_value">37500.0</field>
|
||||
<field name="rule_parameter_id" ref="rule_parameter_er_401k_contribution_limit"/>
|
||||
<field name="date_from" eval="datetime(2020, 1, 1).date()"/>
|
||||
</record>
|
||||
|
||||
<record id="rule_parameter_er_401k_match_percent" model="hr.rule.parameter">
|
||||
<field name="name">Employer 401K Match (%)</field>
|
||||
<field name="code">er_401k_match_percent</field>
|
||||
<field name="country_id" ref="base.us"/>
|
||||
</record>
|
||||
<data noupdate="1">
|
||||
<record id="rule_parameter_er_401k_match_percent_2020" model="hr.rule.parameter.value">
|
||||
<field name="parameter_value">0.0</field>
|
||||
<field name="rule_parameter_id" ref="rule_parameter_er_401k_match_percent"/>
|
||||
<field name="date_from" eval="datetime(2020, 1, 1).date()"/>
|
||||
</record>
|
||||
</data>
|
||||
|
||||
<!-- Categories -->
|
||||
<record id="category_ee_401k_traditional" model="hr.salary.rule.category">
|
||||
<field name="name">EE: 401K Traditional</field>
|
||||
<field name="code">EE_IRA</field>
|
||||
<field name="parent_id" ref="l10n_us_hr_payroll.hr_payroll_category_ded_fit_fica_futa_exempt"/>
|
||||
</record>
|
||||
<record id="category_ee_401k_roth" model="hr.salary.rule.category">
|
||||
<field name="name">EE: 401K Roth</field>
|
||||
<field name="code">EE_IRA_ROTH</field>
|
||||
<field name="parent_id" ref="hr_payroll.DED"/>
|
||||
</record>
|
||||
<!-- It is impossible for only match to meet the limits, but the category exists
|
||||
and the limits are checked against this category. -->
|
||||
<record id="category_er_401k" model="hr.salary.rule.category">
|
||||
<field name="name">ER: 401K Contribution</field>
|
||||
<field name="code">ER_IRA</field>
|
||||
<field name="parent_id" ref="hr_payroll.COMP"/>
|
||||
</record>
|
||||
|
||||
<!-- Rules -->
|
||||
<record id="rule_ee_ira" model="hr.salary.rule">
|
||||
<field name="sequence" eval="110"/>
|
||||
<field name="struct_id" ref="l10n_us_hr_payroll.hr_payroll_structure"/>
|
||||
<field name="category_id" ref="category_ee_401k_traditional"/>
|
||||
<field name="name">EE: 401K</field>
|
||||
<field name="code">EE_IRA</field>
|
||||
<field name="condition_select">python</field>
|
||||
<field name="condition_python">result = ee_401k(contract.ira_amount, contract.ira_rate, payslip, categories, worked_days, inputs)</field>
|
||||
<field name="amount_select">code</field>
|
||||
<field name="amount_python_compute">result = ee_401k(contract.ira_amount, contract.ira_rate, payslip, categories, worked_days, inputs)</field>
|
||||
<field name="appears_on_payslip" eval="True"/>
|
||||
<field name="partner_id" ref="res_partner_ira_provider"/>
|
||||
</record>
|
||||
|
||||
<record id="rule_ee_ira_roth" model="hr.salary.rule">
|
||||
<field name="sequence" eval="196"/>
|
||||
<field name="struct_id" ref="l10n_us_hr_payroll.hr_payroll_structure"/>
|
||||
<field name="category_id" ref="category_ee_401k_roth"/>
|
||||
<field name="name">EE: 401K Roth</field>
|
||||
<field name="code">EE_IRA_ROTH</field>
|
||||
<field name="condition_select">python</field>
|
||||
<field name="condition_python">result = ee_401k(contract.ira_roth_amount, contract.ira_roth_rate, payslip, categories, worked_days, inputs)</field>
|
||||
<field name="amount_select">code</field>
|
||||
<field name="amount_python_compute">result = ee_401k(contract.ira_roth_amount, contract.ira_roth_rate, payslip, categories, worked_days, inputs)</field>
|
||||
<field name="appears_on_payslip" eval="True"/>
|
||||
<field name="partner_id" ref="res_partner_ira_provider"/>
|
||||
</record>
|
||||
|
||||
<record id="rule_er_ira" model="hr.salary.rule">
|
||||
<field name="sequence" eval="445"/>
|
||||
<field name="struct_id" ref="l10n_us_hr_payroll.hr_payroll_structure"/>
|
||||
<field name="category_id" ref="category_er_401k"/>
|
||||
<field name="name">ER: 401K Match</field>
|
||||
<field name="code">ER_IRA_MATCH</field>
|
||||
<field name="condition_select">python</field>
|
||||
<field name="condition_python">result = er_401k_match(categories.BASIC, payslip, categories, worked_days, inputs)</field>
|
||||
<field name="amount_select">code</field>
|
||||
<field name="amount_python_compute">result = er_401k_match(categories.BASIC, payslip, categories, worked_days, inputs)</field>
|
||||
<!-- Normally company contributions are not on Payslip, but this is an additional amount -->
|
||||
<field name="appears_on_payslip" eval="True"/>
|
||||
<field name="partner_id" ref="res_partner_ira_provider"/>
|
||||
</record>
|
||||
|
||||
</odoo>
|
||||
4
l10n_us_hr_payroll_401k/models/__init__.py
Normal file
4
l10n_us_hr_payroll_401k/models/__init__.py
Normal file
@@ -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
|
||||
21
l10n_us_hr_payroll_401k/models/contract.py
Normal file
21
l10n_us_hr_payroll_401k/models/contract.py
Normal file
@@ -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')
|
||||
83
l10n_us_hr_payroll_401k/models/payslip.py
Normal file
83
l10n_us_hr_payroll_401k/models/payslip.py
Normal file
@@ -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
|
||||
3
l10n_us_hr_payroll_401k/tests/__init__.py
Normal file
3
l10n_us_hr_payroll_401k/tests/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
# Part of Hibou Suite Professional. See LICENSE_PROFESSIONAL file for full copyright and licensing details.
|
||||
|
||||
from . import test_payroll
|
||||
132
l10n_us_hr_payroll_401k/tests/test_payroll.py
Normal file
132
l10n_us_hr_payroll_401k/tests/test_payroll.py
Normal file
@@ -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.
|
||||
20
l10n_us_hr_payroll_401k/views/contract_views.xml
Normal file
20
l10n_us_hr_payroll_401k/views/contract_views.xml
Normal file
@@ -0,0 +1,20 @@
|
||||
<?xml version="1.0" encoding="UTF-8" ?>
|
||||
<odoo>
|
||||
|
||||
<record id="hr_contract_view_form_inherit" model="ir.ui.view">
|
||||
<field name="name">hr.contract.form.inherit</field>
|
||||
<field name="model">hr.contract</field>
|
||||
<field name="inherit_id" ref="hr_contract.hr_contract_view_form"/>
|
||||
<field name="arch" type="xml">
|
||||
<xpath expr="//group[@name='salary_and_advantages']" position="after">
|
||||
<group name="usa_advantages_401k" string="401K">
|
||||
<field name="ira_amount"/>
|
||||
<field name="ira_rate"/>
|
||||
<field name="ira_roth_amount"/>
|
||||
<field name="ira_roth_rate"/>
|
||||
</group>
|
||||
</xpath>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
</odoo>
|
||||
Reference in New Issue
Block a user