From bad14c52af9550ee6477dd50039380ce123dc055 Mon Sep 17 00:00:00 2001 From: Jared Kipe Date: Tue, 7 Jan 2020 12:08:23 -0800 Subject: [PATCH] IMP `l10n_us_hr_payroll` Port `l10n_us_fl_hr_payroll` FL Florida including migration. --- l10n_us_hr_payroll/__manifest__.py | 1 + l10n_us_hr_payroll/data/base.xml | 12 +++ l10n_us_hr_payroll/data/final.xml | 2 + l10n_us_hr_payroll/data/state/fl_florida.xml | 61 +++++++++++++ .../12.0.2020.1.0/post-migration.py | 19 +++++ l10n_us_hr_payroll/migrations/data.py | 10 ++- l10n_us_hr_payroll/models/hr_payslip.py | 2 + l10n_us_hr_payroll/models/state/__init__.py | 1 + l10n_us_hr_payroll/models/state/general.py | 85 +++++++++++++++++++ .../models/us_payroll_config.py | 1 + l10n_us_hr_payroll/tests/__init__.py | 3 + l10n_us_hr_payroll/tests/common.py | 75 ++++++++++++++++ .../tests/test_us_fl_florida_payslip_2019.py | 82 ++++++++++++++++++ .../tests/test_us_fl_florida_payslip_2020.py | 14 +++ .../views/us_payroll_config_views.xml | 4 + 15 files changed, 370 insertions(+), 2 deletions(-) create mode 100644 l10n_us_hr_payroll/data/state/fl_florida.xml create mode 100644 l10n_us_hr_payroll/models/state/__init__.py create mode 100644 l10n_us_hr_payroll/models/state/general.py create mode 100755 l10n_us_hr_payroll/tests/test_us_fl_florida_payslip_2019.py create mode 100755 l10n_us_hr_payroll/tests/test_us_fl_florida_payslip_2020.py diff --git a/l10n_us_hr_payroll/__manifest__.py b/l10n_us_hr_payroll/__manifest__.py index e32bde9c..2b656521 100755 --- a/l10n_us_hr_payroll/__manifest__.py +++ b/l10n_us_hr_payroll/__manifest__.py @@ -28,6 +28,7 @@ USA Payroll Rules. 'data/federal/fed_941_fica_rules.xml', 'data/federal/fed_941_fit_parameters.xml', 'data/federal/fed_941_fit_rules.xml', + 'data/state/fl_florida.xml', 'data/final.xml', 'views/hr_contract_views.xml', 'views/us_payroll_config_views.xml', diff --git a/l10n_us_hr_payroll/data/base.xml b/l10n_us_hr_payroll/data/base.xml index 75cafa13..db3e707e 100644 --- a/l10n_us_hr_payroll/data/base.xml +++ b/l10n_us_hr_payroll/data/base.xml @@ -1,4 +1,16 @@ + + + EE: State Unemployment SUTA + EE_US_SUTA + + + + ER: State Unemployment SUTA + ER_US_SUTA + + + \ No newline at end of file diff --git a/l10n_us_hr_payroll/data/final.xml b/l10n_us_hr_payroll/data/final.xml index b2f700cc..2ab128c0 100644 --- a/l10n_us_hr_payroll/data/final.xml +++ b/l10n_us_hr_payroll/data/final.xml @@ -16,6 +16,8 @@ ref('hr_payroll_rule_ee_fed_941_fit'), + ref('hr_payroll_rule_er_us_fl_suta'), + ref('hr_salary_rule_commission'), ref('hr_salary_rule_gamification'), ])]" name="rule_ids"/> diff --git a/l10n_us_hr_payroll/data/state/fl_florida.xml b/l10n_us_hr_payroll/data/state/fl_florida.xml new file mode 100644 index 00000000..de1cc49a --- /dev/null +++ b/l10n_us_hr_payroll/data/state/fl_florida.xml @@ -0,0 +1,61 @@ + + + + + + US FL Florida SUTA Wage Base + us_fl_suta_wage_base + 7000.00 + + + + US FL Florida SUTA Wage Base + us_fl_suta_wage_base + 7000.00 + + + + + + + + US FL Florida SUTA Rate + us_fl_suta_rate + 2.7 + + + + US FL Florida SUTA Rate + us_fl_suta_rate + 2.7 + + + + + + + US Florida - Department of Revenue + + + + US Florida - Department of Revenue (RT-6) + + + + + + + + + + ER: US FL Florida State Unemployment (RT-6) + ER_US_FL_SUTA + python + result, _ = general_state_unemployment(payslip, categories, worked_days, inputs, wage_base='us_fl_suta_wage_base', rate='us_fl_suta_rate', state_code='FL') + code + result, result_rate = general_state_unemployment(payslip, categories, worked_days, inputs, wage_base='us_fl_suta_wage_base', rate='us_fl_suta_rate', state_code='FL') + + + + + \ No newline at end of file diff --git a/l10n_us_hr_payroll/migrations/12.0.2020.1.0/post-migration.py b/l10n_us_hr_payroll/migrations/12.0.2020.1.0/post-migration.py index 752751db..24c8fd0d 100644 --- a/l10n_us_hr_payroll/migrations/12.0.2020.1.0/post-migration.py +++ b/l10n_us_hr_payroll/migrations/12.0.2020.1.0/post-migration.py @@ -24,6 +24,21 @@ def migrate(cr, installed_version): env = Environment(cr, SUPERUSER_ID, {}) new_structure = env.ref('l10n_us_hr_payroll.structure_type_employee') + def get_state(code, cache={}): + country_key = 'US_COUNTRY' + if code in cache: + _logger.warn(' return from cache: ' + str(cache[code])) + return cache[code] + if country_key not in cache: + cache[country_key] = env.ref('base.us') + us_country = cache[country_key] + us_state = env['res.country.state'].search([ + ('country_id', '=', us_country.id), + ('code', '=', code), + ], limit=1) + cache[code] = us_state + return us_state + # We will assume all contracts without a struct (because we deleted it), or with one like US_xx_EMP, need config contracts = env['hr.contract'].search([ '|', @@ -34,13 +49,17 @@ def migrate(cr, installed_version): for contract in contracts: _logger.warn('Migrating contract: ' + str(contract) + ' for employee: ' + str(contract.employee_id)) # Could we somehow detect the state off of the current/orphaned salary structure? + state_code = False old_struct_code = contract.struct_id.code + if old_struct_code: + state_code = old_struct_code.split('_')[1] temp_values = temp_field_values(cr, 'hr_contract', contract.id, fields_to_move) # Resolve mapping to the new field names. values = {FIELDS_CONTRACT_TO_US_PAYROLL_FORMS_2020[k]: v for k, v in temp_values.items()} values.update({ 'name': 'MIG: ' + str(contract.name), 'employee_id': contract.employee_id.id, + 'state_id': get_state(state_code).id, }) us_payroll_config = env['hr.contract.us_payroll_config'].create(values) contract.write({ diff --git a/l10n_us_hr_payroll/migrations/data.py b/l10n_us_hr_payroll/migrations/data.py index 25bb93d5..6d87366e 100644 --- a/l10n_us_hr_payroll/migrations/data.py +++ b/l10n_us_hr_payroll/migrations/data.py @@ -27,7 +27,10 @@ XMLIDS_TO_REMOVE_2020 = [ 'l10n_us_hr_payroll.hr_payroll_rules_fica_emp_m_add_wages_2018', 'l10n_us_hr_payroll.hr_payroll_rules_futa_wages_2018', 'l10n_us_hr_payroll.hr_payroll_rules_fed_inc_withhold_2018_married', - + # State + 'l10n_us_fl_hr_payroll.hr_payroll_fl_unemp_wages', + 'l10n_us_fl_hr_payroll.hr_payroll_fl_unemp', + 'l10n_us_fl_hr_payroll.hr_payroll_rules_fl_unemp_wages_2018', ] XMLIDS_TO_RENAME_2020 = { @@ -43,5 +46,8 @@ XMLIDS_TO_RENAME_2020 = { 'l10n_us_hr_payroll.hr_payroll_rules_fica_comp_ss': 'l10n_us_hr_payroll.hr_payroll_rule_er_fed_941_ss', 'l10n_us_hr_payroll.hr_payroll_rules_fica_comp_m': 'l10n_us_hr_payroll.hr_payroll_rule_er_fed_941_m', 'l10n_us_hr_payroll.hr_payroll_rules_fed_inc_withhold_2018_single': 'l10n_us_hr_payroll.hr_payroll_rule_ee_fed_941_fit', - + # State + 'l10n_us_fl_hr_payroll.hr_payroll_rules_fl_unemp_2018': 'l10n_us_hr_payroll.hr_payroll_rule_er_us_fl_suta', + 'l10n_us_fl_hr_payroll.res_partner_fldor': 'l10n_us_hr_payroll.res_partner_us_fl_dor', + 'l10n_us_fl_hr_payroll.contrib_register_fldor': 'l10n_us_hr_payroll.contrib_register_us_fl_dor', } diff --git a/l10n_us_hr_payroll/models/hr_payslip.py b/l10n_us_hr_payroll/models/hr_payslip.py index 5a335a65..ed37490d 100644 --- a/l10n_us_hr_payroll/models/hr_payslip.py +++ b/l10n_us_hr_payroll/models/hr_payslip.py @@ -9,6 +9,7 @@ from .federal.fed_941 import ee_us_941_fica_ss, \ er_us_941_fica_ss, \ er_us_941_fica_m, \ ee_us_941_fit +from .state.general import general_state_unemployment class HRPayslip(models.Model): @@ -38,6 +39,7 @@ class HRPayslip(models.Model): 'er_us_941_fica_ss': er_us_941_fica_ss, 'er_us_941_fica_m': er_us_941_fica_m, 'ee_us_941_fit': ee_us_941_fit, + 'general_state_unemployment': general_state_unemployment, } def get_year(self): diff --git a/l10n_us_hr_payroll/models/state/__init__.py b/l10n_us_hr_payroll/models/state/__init__.py new file mode 100644 index 00000000..0358305d --- /dev/null +++ b/l10n_us_hr_payroll/models/state/__init__.py @@ -0,0 +1 @@ +# Part of Hibou Suite Professional. See LICENSE_PROFESSIONAL file for full copyright and licensing details. diff --git a/l10n_us_hr_payroll/models/state/general.py b/l10n_us_hr_payroll/models/state/general.py new file mode 100644 index 00000000..88acba56 --- /dev/null +++ b/l10n_us_hr_payroll/models/state/general.py @@ -0,0 +1,85 @@ +# Part of Hibou Suite Professional. See LICENSE_PROFESSIONAL file for full copyright and licensing details. +from odoo.exceptions import UserError + +# import logging +# _logger = logging.getLogger(__name__) + + +def general_state_unemployment(payslip, categories, worked_days, inputs, wage_base=None, wage_start=None, rate=None, state_code=None): + """ + Returns SUTA eligible wage and rate. + WAGE = GROSS - WAGE_US_940_FUTA_EXEMPT + + The contract's `futa_type` determines if SUTA should be collected. + + Function parameters: + wage_base, wage_start, rate can either be strings (rule_parameters) or floats + + :return: result, result_rate (wage, percent) + """ + + if state_code != payslip.dict.contract_id.us_payroll_config_value('state_code'): + return 0.0, 0.0 + + # Determine Eligible. + if payslip.dict.contract_id.futa_type in (payslip.dict.contract_id.FUTA_TYPE_EXEMPT, payslip.dict.contract_id.FUTA_TYPE_BASIC): + return 0.0, 0.0 + + # Resolve parameters. On exception, return (probably missing a year, would rather not have exception) + if wage_base and isinstance(wage_base, str): + try: + wage_base = payslip.dict.rule_parameter(wage_base) + except (KeyError, UserError): + return 0.0, 0.0 + + if wage_start and isinstance(wage_start, str): + try: + wage_start = payslip.dict.rule_parameter(wage_start) + except (KeyError, UserError): + return 0.0, 0.0 + + if rate and isinstance(rate, str): + try: + rate = payslip.dict.rule_parameter(rate) + except (KeyError, UserError): + return 0.0, 0.0 + + if not rate: + return 0.0, 0.0 + else: + # Rate assumed positive percentage! + rate = -rate + + # Determine Wage + year = payslip.dict.get_year() + ytd_wage = payslip.sum_category('GROSS', str(year) + '-01-01', str(year + 1) + '-01-01') + ytd_wage -= payslip.sum_category('WAGE_US_940_FUTA_EXEMPT', str(year) + '-01-01', str(year + 1) + '-01-01') + ytd_wage += payslip.dict.contract_id.external_wages + + wage = categories.GROSS - categories.WAGE_US_940_FUTA_EXEMPT + #_logger.warn('ytd_wage: ' + str(ytd_wage) + ' wage: ' + str(wage)) + + if wage_base: + remaining = wage_base - ytd_wage + if remaining < 0.0: + result = 0.0 + elif remaining < wage: + result = remaining + else: + result = wage + + #_logger.warn(' wage_base method result: ' + str(result) + ' rate: ' + str(rate)) + return result, rate + if wage_start: + if ytd_wage >= wage_start: + #_logger.warn(' wage_start 1 method result: ' + str(wage) + ' rate: ' + str(rate)) + return wage, rate + if ytd_wage + wage <= wage_start: + #_logger.warn(' wage_start 2 method result: ' + str(0.0) + ' rate: ' + str(0.0)) + return 0.0, 0.0 + #_logger.warn(' wage_start 3 method result: ' + str((wage - (wage_start - ytd_wage))) + ' rate: ' + str(rate)) + return (wage - (wage_start - ytd_wage)), rate + + # If the wage doesn't have a start or a base + #_logger.warn(' basic result: ' + str(wage) + ' rate: ' + str(rate)) + return wage, rate diff --git a/l10n_us_hr_payroll/models/us_payroll_config.py b/l10n_us_hr_payroll/models/us_payroll_config.py index ed0e6d73..d87ebcac 100644 --- a/l10n_us_hr_payroll/models/us_payroll_config.py +++ b/l10n_us_hr_payroll/models/us_payroll_config.py @@ -14,6 +14,7 @@ class HRContractUSPayrollConfig(models.Model): name = fields.Char(string="Description") employee_id = fields.Many2one('hr.employee', string="Employee", required=True) state_id = fields.Many2one('res.country.state', string="Applied State") + state_code = fields.Char(related='state_id.code') fed_940_type = fields.Selection([ (FUTA_TYPE_EXEMPT, 'Exempt (0%)'), diff --git a/l10n_us_hr_payroll/tests/__init__.py b/l10n_us_hr_payroll/tests/__init__.py index 23702419..10545ad0 100755 --- a/l10n_us_hr_payroll/tests/__init__.py +++ b/l10n_us_hr_payroll/tests/__init__.py @@ -3,3 +3,6 @@ from . import common from . import test_us_payslip_2019 from . import test_us_payslip_2020 + +from . import test_us_fl_florida_payslip_2019 +from . import test_us_fl_florida_payslip_2020 diff --git a/l10n_us_hr_payroll/tests/common.py b/l10n_us_hr_payroll/tests/common.py index f6989e67..78a3d1f5 100755 --- a/l10n_us_hr_payroll/tests/common.py +++ b/l10n_us_hr_payroll/tests/common.py @@ -3,9 +3,11 @@ from logging import getLogger from sys import float_info as sys_float_info from collections import defaultdict +from datetime import timedelta from odoo.tests import common from odoo.tools.float_utils import float_round as odoo_float_round +from odoo.addons.l10n_us_hr_payroll.models.hr_contract import USHRContract def process_payslip(payslip): @@ -61,6 +63,10 @@ class TestUsPayslip(common.TransactionCase): 'employee_id': employee.id, } + # Backwards compatability with 'futa_type' + if 'futa_type' in kwargs: + kwargs['fed_940_type'] = kwargs['futa_type'] + for key, val in kwargs.items(): # Assume any Odoo object is in a Many2one if hasattr(val, 'id'): @@ -153,3 +159,72 @@ class TestUsPayslip(common.TransactionCase): payslip = self._createPayslip(employee, '2019-01-01', '2019-01-14') payslip.compute_sheet() + + def get_us_state(self, code, cache={}): + country_key = 'US_COUNTRY' + if code in cache: + return cache[code] + if country_key not in cache: + cache[country_key] = self.env.ref('base.us') + us_country = cache[country_key] + us_state = self.env['res.country.state'].search([ + ('country_id', '=', us_country.id), + ('code', '=', code), + ], limit=1) + cache[code] = us_state + return us_state + + def _test_er_suta(self, state_code, rate, date, wage_base=None, **extra_contract): + if wage_base: + # Slightly larger than 1/2 the wage_base + wage = round(wage_base / 2.0) + 100.0 + self.assertTrue((2 * wage) > wage_base, 'Granularity of wage_base too low.') + else: + wage = 1000.0 + + employee = self._createEmployee() + contract = self._createContract(employee, + wage=wage, + state_id=self.get_us_state(state_code), + **extra_contract) + + rate = -rate / 100.0 # Assumed passed as percent positive + + # Tests + payslip = self._createPayslip(employee, date, date + timedelta(days=30)) + + # Test exemptions + contract.us_payroll_config_id.fed_940_type = USHRContract.FUTA_TYPE_EXEMPT + payslip.compute_sheet() + cats = self._getCategories(payslip) + self.assertPayrollEqual(cats.get('ER_US_SUTA', 0.0), 0.0) + + contract.us_payroll_config_id.fed_940_type = USHRContract.FUTA_TYPE_BASIC + payslip.compute_sheet() + cats = self._getCategories(payslip) + self.assertPayrollEqual(cats.get('ER_US_SUTA', 0.0), 0.0) + + # Test Normal + contract.us_payroll_config_id.fed_940_type = USHRContract.FUTA_TYPE_NORMAL + payslip.compute_sheet() + cats = self._getCategories(payslip) + self.assertPayrollEqual(cats.get('ER_US_SUTA', 0.0), wage * rate) + + if wage_base: + process_payslip(payslip) + + remaining_unemp_wages = wage_base - wage + self.assertTrue((remaining_unemp_wages * rate) <= 0.01) # less than 0.01 because rate is negative + payslip = self._createPayslip(employee, date + timedelta(days=31), date + timedelta(days=60)) + payslip.compute_sheet() + cats = self._getCategories(payslip) + self.assertPayrollEqual(cats.get('ER_US_SUTA', 0.0), remaining_unemp_wages * rate) + + # As if they were paid once already, so the first "two payslips" would remove all of the tax obligation + # 1 wage - Payslip (confirmed) + # 1 wage - external_wages + # 1 wage - current Payslip + contract.external_wages = wage + payslip.compute_sheet() + cats = self._getCategories(payslip) + self.assertPayrollEqual(cats.get('ER_US_SUTA', 0.0), 0.0) diff --git a/l10n_us_hr_payroll/tests/test_us_fl_florida_payslip_2019.py b/l10n_us_hr_payroll/tests/test_us_fl_florida_payslip_2019.py new file mode 100755 index 00000000..981e9ce0 --- /dev/null +++ b/l10n_us_hr_payroll/tests/test_us_fl_florida_payslip_2019.py @@ -0,0 +1,82 @@ +from .common import TestUsPayslip, process_payslip +from odoo.addons.l10n_us_hr_payroll.models.hr_contract import USHRContract + + +class TestUsFlPayslip(TestUsPayslip): + ### + # 2019 Taxes and Rates + ### + FL_UNEMP_MAX_WAGE = 7000.0 + FL_UNEMP = -2.7 / 100.0 + + def test_2019_taxes(self): + salary = 5000.0 + + employee = self._createEmployee() + contract = self._createContract(employee, + wage=salary, + state_id=self.get_us_state('FL')) + + self._log('2019 Florida tax first payslip:') + payslip = self._createPayslip(employee, '2019-01-01', '2019-01-31') + + payslip.compute_sheet() + + cats = self._getCategories(payslip) + + self.assertPayrollEqual(cats['ER_US_SUTA'], salary * self.FL_UNEMP) + + process_payslip(payslip) + + # Make a new payslip, this one will have maximums + + remaining_fl_unemp_wages = self.FL_UNEMP_MAX_WAGE - salary if (self.FL_UNEMP_MAX_WAGE - 2*salary < salary) \ + else salary + + self._log('2019 Florida tax second payslip:') + payslip = self._createPayslip(employee, '2019-02-01', '2019-02-28') + + payslip.compute_sheet() + + cats = self._getCategories(payslip) + + self.assertPayrollEqual(cats['ER_US_SUTA'], remaining_fl_unemp_wages * self.FL_UNEMP) + + def test_2019_taxes_with_external(self): + salary = 5000.0 + external_wages = 6000.0 + + employee = self._createEmployee() + contract = self._createContract(employee, + wage=salary, + external_wages=external_wages, + state_id=self.get_us_state('FL')) + + self._log('2019 Forida_external tax first payslip:') + payslip = self._createPayslip(employee, '2019-01-01', '2019-01-31') + + payslip.compute_sheet() + + cats = self._getCategories(payslip) + + self.assertPayrollEqual(cats['ER_US_SUTA'], (self.FL_UNEMP_MAX_WAGE - external_wages) * self.FL_UNEMP) + + def test_2019_taxes_with_state_exempt(self): + salary = 5000.0 + external_wages = 6000.0 + + employee = self._createEmployee() + contract = self._createContract(employee, + wage=salary, + external_wages=external_wages, + futa_type=USHRContract.FUTA_TYPE_BASIC, + state_id=self.get_us_state('FL')) + + self._log('2019 Forida_external tax first payslip:') + payslip = self._createPayslip(employee, '2019-01-01', '2019-01-31') + + payslip.compute_sheet() + + cats = self._getCategories(payslip) + + self.assertPayrollEqual(cats.get('ER_US_SUTA', 0.0), 0.0) diff --git a/l10n_us_hr_payroll/tests/test_us_fl_florida_payslip_2020.py b/l10n_us_hr_payroll/tests/test_us_fl_florida_payslip_2020.py new file mode 100755 index 00000000..b32c1030 --- /dev/null +++ b/l10n_us_hr_payroll/tests/test_us_fl_florida_payslip_2020.py @@ -0,0 +1,14 @@ +from datetime import date +from .common import TestUsPayslip + + +class TestUsFlPayslip(TestUsPayslip): + ### + # 2020 Taxes and Rates + ### + FL_UNEMP_MAX_WAGE = 7000.0 + FL_UNEMP = 2.7 + + def test_2020_taxes(self): + # Only has state unemployment + self._test_er_suta('FL', self.FL_UNEMP, date(2020, 1, 1), wage_base=self.FL_UNEMP_MAX_WAGE) diff --git a/l10n_us_hr_payroll/views/us_payroll_config_views.xml b/l10n_us_hr_payroll/views/us_payroll_config_views.xml index 5b6d0dea..232f27ab 100644 --- a/l10n_us_hr_payroll/views/us_payroll_config_views.xml +++ b/l10n_us_hr_payroll/views/us_payroll_config_views.xml @@ -40,6 +40,10 @@ + + +

No additional fields.

+