From 7a51b6b7147b8c5b55e918065e39c01d222aaf72 Mon Sep 17 00:00:00 2001 From: Kristen Marie Kulha Date: Thu, 17 May 2018 13:08:54 -0700 Subject: [PATCH 01/55] Initial commit of `l10n_us_ca_hr_payroll` for 11.0. --- l10n_us_ca_hr_payroll/__init__.py | 1 + l10n_us_ca_hr_payroll/__manifest__.py | 31 + l10n_us_ca_hr_payroll/data/base.xml | 88 +++ l10n_us_ca_hr_payroll/data/final.xml | 23 + l10n_us_ca_hr_payroll/data/rules_2018.xml | 576 ++++++++++++++++++ l10n_us_ca_hr_payroll/hr_payroll.py | 56 ++ l10n_us_ca_hr_payroll/hr_payroll_view.xml | 34 ++ l10n_us_ca_hr_payroll/tests/__init__.py | 1 + .../tests/test_us_ca_payslip_2018.py | 461 ++++++++++++++ 9 files changed, 1271 insertions(+) create mode 100755 l10n_us_ca_hr_payroll/__init__.py create mode 100755 l10n_us_ca_hr_payroll/__manifest__.py create mode 100755 l10n_us_ca_hr_payroll/data/base.xml create mode 100755 l10n_us_ca_hr_payroll/data/final.xml create mode 100755 l10n_us_ca_hr_payroll/data/rules_2018.xml create mode 100755 l10n_us_ca_hr_payroll/hr_payroll.py create mode 100755 l10n_us_ca_hr_payroll/hr_payroll_view.xml create mode 100755 l10n_us_ca_hr_payroll/tests/__init__.py create mode 100755 l10n_us_ca_hr_payroll/tests/test_us_ca_payslip_2018.py diff --git a/l10n_us_ca_hr_payroll/__init__.py b/l10n_us_ca_hr_payroll/__init__.py new file mode 100755 index 00000000..1027e233 --- /dev/null +++ b/l10n_us_ca_hr_payroll/__init__.py @@ -0,0 +1 @@ +from . import hr_payroll \ No newline at end of file diff --git a/l10n_us_ca_hr_payroll/__manifest__.py b/l10n_us_ca_hr_payroll/__manifest__.py new file mode 100755 index 00000000..1d2ae749 --- /dev/null +++ b/l10n_us_ca_hr_payroll/__manifest__.py @@ -0,0 +1,31 @@ +{ + 'name': 'USA - California - Payroll', + 'author': 'Hibou Corp. ', + 'license': 'AGPL-3', + 'category': 'Localization', + 'depends': ['l10n_us_hr_payroll'], + 'version': '11.0.2018.0.0', + 'description': """ +USA::California Payroll Rules. +============================== + +* Contribution register and partner for California Department of Taxation - Unemployment Insurance Taz +* Contribution register and partner for California Department of Taxation - Income Tax Withholding +* Contribution register and partner for Califronia Department of Taxation - Employee Training Tax +* Contribution register and partner for Califronia Department of Taxation - State Disability Insurance +* Contract level California Exemptions +* Contract level California State Disability Insurance +* Company level California Unemployment Insurance Tax +* Company level California Employee Training Taz + """, + + 'auto_install': False, + 'website': 'https://hibou.io/', + 'data': [ + 'hr_payroll_view.xml', + 'data/base.xml', + 'data/rules_2018.xml', + 'data/final.xml', + ], + 'installable': True +} diff --git a/l10n_us_ca_hr_payroll/data/base.xml b/l10n_us_ca_hr_payroll/data/base.xml new file mode 100755 index 00000000..b00d6277 --- /dev/null +++ b/l10n_us_ca_hr_payroll/data/base.xml @@ -0,0 +1,88 @@ + + + + + + California Department of Taxation - Unemployment Insurance Tax + 1 + + + + California Department of Taxation - Income Tax Withholding + 1 + + + + California Department of Taxation - Employment Training Tax + 1 + + + + California Department of Taxation - State Disability Insurance + 1 + + + + + California Unemployment Insurance Tax + California Department of Taxation - Unemployment Insurance Tax + + + + California Income Tax Withholding + California Department of Taxation - Income Tax Withholding + + + + Employment Training Tax + California Department of Taxation - Employment Training Tax + + + + State Disability Insurance + California Department of Taxation - State Disability Insurance + + + + + + + California Unemployment Insurance Tax - Wages + CA_UIT_WAGES + + + + California Unemployment Insurance Tax + CA_UIT + + + + + California Employee Training Tax - Wages + CA_ETT_WAGES + + + + California Employee Training Tax + CA_ETT + + + + + California State Disability Insurance - Wages + CA_SDI_WAGES + + + + California State Disability Insurance + CA_SDI + + + + + California Income Withholding + CA_WITHHOLD + + + + \ No newline at end of file diff --git a/l10n_us_ca_hr_payroll/data/final.xml b/l10n_us_ca_hr_payroll/data/final.xml new file mode 100755 index 00000000..2aca7646 --- /dev/null +++ b/l10n_us_ca_hr_payroll/data/final.xml @@ -0,0 +1,23 @@ + + + + + + + US_CA_EMP + USA California Employee + + + + + + + diff --git a/l10n_us_ca_hr_payroll/data/rules_2018.xml b/l10n_us_ca_hr_payroll/data/rules_2018.xml new file mode 100755 index 00000000..dd8c7d6e --- /dev/null +++ b/l10n_us_ca_hr_payroll/data/rules_2018.xml @@ -0,0 +1,576 @@ + + + + + + + + + + California Unemployment Insurance Tax - Wages (2018) + CA_UIT_WAGES_2018 + python + result = (payslip.date_to[:4] == '2018') + code + +### +ytd = payslip.sum('CA_UIT_WAGES_2018', '2018-01-01', '2019-01-01') +ytd += contract.external_wages +remaining = 7000.0 - ytd +if remaining <= 0.0: + result = 0 +elif remaining < categories.GROSS: + result = remaining +else: + result = categories.GROSS + + + + + + + + California Unemployment Insurance Tax(2018) + CA_UIT_2018 + python + result = (payslip.date_to[:4] == '2018') + code + +result_rate = -contract.ca_uit_rate(2018) +result = categories.CA_UIT_WAGES + +# result_rate of 0 implies 100% due to bug +if result_rate == 0.0: + result = 0.0 + + + + + + + + + + California Employment Training Tax - Wages (2018) + CA_ETT_WAGES_2018 + python + result = (payslip.date_to[:4] == '2018') + code + +### +ytd = payslip.sum('CA_ETT_WAGES_2018', '2018-01-01', '2019-01-01') +ytd += contract.external_wages +remaining = 7000.0 - ytd +if remaining <= 0.0: + result = 0 +elif remaining < categories.GROSS: + result = remaining +else: + result = categories.GROSS + + + + + + + + California Employee Training Tax(2018) + CA_ETT_2018 + python + result = (payslip.date_to[:4] == '2018') + code + +result_rate = -contract.ca_ett_rate(2018) +result = categories.CA_ETT_WAGES + +# result_rate of 0 implies 100% due to bug +if result_rate == 0.0: + result = 0.0 + + + + + + + + + + California State Disability Insurance Tax - Wages (2018) + CA_SDI_WAGES_2018 + python + result = (payslip.date_to[:4] == '2018') + code + +### +ytd = payslip.sum('CA_SDI_WAGES_2018', '2018-01-01', '2019-01-01') +ytd += contract.external_wages +remaining = 114967.0 - ytd +if remaining <= 0.0: + result = 0 +elif remaining < categories.GROSS: + result = remaining +else: + result = categories.GROSS + + + + + + + + California State Disability Insurance(2018) + CA_SDI_2018 + python + result = (payslip.date_to[:4] == '2018') + code + +result_rate = -contract.ca_sdi_rate(2018) +result = categories.CA_SDI_WAGES + +# result_rate of 0 implies 100% due to bug +if result_rate == 0.0: + result = 0.0 + + + + + + + + + + California Income Withholding + CA_INC_WITHHOLD_2018 + python + result = (payslip.date_to[:4] == '2018') + code + +wages = categories.GROSS +allowances = contract.ca_de4_allowances +additional_allowances = contract.ca_additional_allowances +schedule_pay = contract.schedule_pay +filing_status = contract.ca_de4_filing_status +low_income = False + +# Tables are found in http://www.edd.ca.gov/pdf_pub_ctr/18methb.pdf +# First check low income exemption table (Step 1) +low_income_exemption_table = { +'weekly': (270, 270, 540, 540), +'bi-weekly': (540, 540, 1081, 1081), +'semi-monthly': (585, 585, 1171, 1171), +'monthly': (1171, 1171, 2341, 2341), +'quarterly': (3512, 3512, 7024, 7024), +'semi-annual': (7024, 7024, 14048, 14048), +'annually': (14048, 14048, 28095, 28095), +} + +if filing_status == 'head_household': + _, _, _, income = low_income_exemption_table[schedule_pay] + if wages <= income: + result = 0 + low_income = True +elif filing_status == 'married': + if allowances >= 2: + _, _, income, _ = low_income_exemption_table[schedule_pay] + if wages <= income: + result = 0 + low_income = True + else: + _, income, _, _ = low_income_exemption_table[schedule_pay] + if wages <= income: + result = 0 + low_income = True +else: + income, _, _, _ = low_income_exemption_table[schedule_pay] + if wages <= income: + result = 0 + low_income = True + +if not low_income: + # Estimated deduction table (Step 2) + estimated_deduction_table = { + 'weekly': (19, 38, 58, 77, 96, 115, 135, 154, 173, 192), + 'bi-weekly': (38, 77, 115, 154, 192, 231, 269, 308, 346, 385), + 'semi-monthly': (42, 83, 125, 167, 208, 250, 292, 333, 375, 417), + 'monthly': (83, 167, 250, 333, 417, 500, 583, 667, 750, 833), + 'quarterly': (250, 500, 750, 1000, 1250, 1500, 1750, 2000, 2250, 2500), + 'semi-annual': (500, 1000, 1500, 2000, 2500, 3000, 3500, 4000, 4500, 5000), + 'annually': (1000, 2000, 3000, 4000, 5000, 6000, 7000, 8000, 9000, 10000), + } + + allowance_index = additional_allowances - 1 + if additional_allowances > 10: + deduction = (estimated_deduction_table[schedule_pay][0]) * additional_allowances + wages -= deduction + elif additional_allowances > 0: + deduction = estimated_deduction_table[schedule_pay][allowance_index] + wages -= deduction + + # Standard deduction table (Step 3) + standard_deduction_table = { + 'weekly': (81, 81, 163, 163), + 'bi-weekly': (163, 163, 326, 326), + 'semi-monthly': (177, 177, 353, 353), + 'monthly': (353, 353, 706, 706), + 'quarterly': (1059, 1059, 2118, 2118), + 'semi-annual': (2118, 2118, 4236, 4236), + 'annually': (4236, 4236, 8472, 8472), + } + + if filing_status == 'head_household': + _, _, _, deduction = standard_deduction_table[schedule_pay] + wages -= deduction + elif filing_status == 'married': + if allowances >= 2: + _, _, deduction, _ = standard_deduction_table[schedule_pay] + wages -= deduction + else: + _, deduction, _, _ = standard_deduction_table[schedule_pay] + wages -= deduction + else: + deduction, _, _, _ = standard_deduction_table[schedule_pay] + wages -= deduction + + # Tax Rate Tables (Step 4) + #### WEEKLY #### + if schedule_pay == 'weekly': + if filing_status == 'head_household' and wages > 0: + tax_rate_table = [ + (316, 0.011, 0.0), + (750, 0.022, 3.48), + (967, 0.044, 13.03), + (1196, 0.066, 22.58), + (1413, 0.088, 37.69), + (7212, 0.1023, 56.79), + (8654, 0.1133, 650.03), + (14423, 0.1243, 813.41), + (19231, 0.1353, 1530.50), + (float('inf'), 0.1463, 2181.02), + ] + + elif filing_status == 'married': + tax_rate_table = [ + (316, 0.011, 0.0), + (750, 0.022, 3.48), + (1184, 0.044, 13.03), + (1642, 0.066, 32.13), + (2076, 0.088, 62.36), + (10606, 0.1023, 100.55), + (12726, 0.1133, 973.17), + (19231, 0.1243, 1213.37), + (21210, 0.1353, 2021.94), + (float('inf'), 0.1463, 2289.70), + ] + + else: + tax_rate_table = [ + (158, 0.011, 0.0), + (375, 0.022, 1.74), + (592, 0.044, 6.51), + (821, 0.066, 16.06), + (1038, 0.088, 31.17), + (5303, 0.1023, 50.27), + (6363, 0.1133, 486.58), + (10605, 0.1243, 606.68), + (19231, 0.1353, 1133.96), + (float('inf'), 0.1463, 2301.06), + ] + + #### BI-WEEKLY #### + elif schedule_pay == 'bi-weekly': + if filing_status == 'head_household': + tax_rate_table = [ + (632, 0.011, 0.0), + (1500, 0.022, 6.95), + (1934, 0.044, 26.05), + (2392, 0.066, 45.15), + (2826, 0.088, 75.38), + (14424, 0.1023, 113.57), + (17308, 0.1133, 1300.05), + (28846, 0.1243, 1626.81), + (38462, 0.1353, 3060.98), + (float('inf'), 0.1463, 4362.02), + ] + + elif filing_status == 'married': + tax_rate_table = [ + (632, 0.011, 0.0), + (1500, 0.022, 6.95), + (2368, 0.044, 26.05), + (3284, 0.066, 64.24), + (4152, 0.088, 124.70), + (21212, 0.1023, 201.08), + (25452, 0.1133, 1946.32), + (38462, 0.1243, 2426.71), + (42420, 0.1353, 4043.85), + (float('inf'), 0.1463, 4579.37), + ] + + else: + tax_rate_table = [ + (316, 0.011, 0.0), + (750, 0.022, 3.48), + (1184, 0.044, 13.03), + (1642, 0.066, 32.13), + (2076, 0.088, 62.36), + (10606, 0.1023, 100.55), + (12726, 0.1133, 973.17), + (21210, 0.1243, 1213.37), + (38462, 0.1353, 2267.93), + (float('inf'), 0.1463, 4602.13), + ] + + #### SEMI-MONTHLY #### + elif schedule_pay == 'semi-monthly': + if filing_status == 'head_household': + tax_rate_table = [ + (686, 0.011, 0.0), + (1625, 0.022, 7.55), + (2094, 0.044, 28.21), + (2592, 0.066, 48.85), + (3062, 0.088, 81.72), + (15625, 0.1023, 123.08), + (18750, 0.1133, 1408.27), + (31250, 0.1243, 1762.33), + (41667, 0.1353, 3316.08), + (float('inf'), 0.1463, 4725.50), + ] + + elif filing_status == 'married': + tax_rate_table = [ + (686, 0.011, 0.0), + (1624, 0.022, 7.55), + (2564, 0.044, 28.19), + (3560, 0.066, 69.55), + (4498, 0.088, 135.29), + (22978, 0.1023, 217.83), + (27574, 0.1133, 2108.33), + (41667, 0.1243, 2629.06), + (45956, 0.1353, 4380.82), + (float('inf'), 0.1463, 4961.12), + ] + + else: + tax_rate_table = [ + (343, 0.011, 0.0), + (812, 0.022, 3.77), + (1282, 0.044, 14.09), + (1780, 0.066, 34.77), + (2249, 0.088, 67.64), + (11489, 0.1023, 108.91), + (13787, 0.1133, 1054.16), + (22978, 0.1243, 1314.52), + (41667, 0.1353, 2456.96), + (float('inf'), 0.1463, 4985.58), + ] + + #### MONTHLY #### + elif schedule_pay == 'monthly': + if filing_status == 'head_household': + tax_rate_table = [ + (1372, 0.011, 0.0), + (3250, 0.022, 15.09), + (4188, 0.044, 56.41), + (5184, 0.066, 97.68), + (6124, 0.088, 163.42), + (31250, 0.1023, 246.148), + (37500, 0.1133, 2816.53), + (62500, 0.1243, 3524.66), + (83334, 0.1353, 6632.16), + (float('inf'), 0.1463, 9451.00), + ] + + elif filing_status == 'married': + tax_rate_table = [ + (1372, 0.011, 0.0), + (3248, 0.022, 15.09), + (5128, 0.044, 56.36), + (7120, 0.066, 139.08), + (8996, 0.088, 270.55), + (45956, 0.1023, 435.64), + (55148, 0.1133, 4216.65), + (83334, 0.1243, 5258.10), + (91912, 0.1353, 8761.62), + (float('inf'), 0.1463, 9922.22), + ] + + else: + tax_rate_table = [ + (686, 0.011, 0.0), + (1624, 0.022, 7.55), + (2564, 0.044, 28.19), + (3560, 0.066, 69.55), + (4498, 0.088, 135.29), + (22978, 0.1023, 217.83), + (27574, 0.1133, 2108.33), + (45956, 0.1243, 2629.06), + (83334, 0.1353, 4913.94), + (float('inf'), 0.1463, 9971.18), + ] + + #### QUARTERLY #### + elif schedule_pay == 'quarterly': + if filing_status == 'head_household': + tax_rate_table = [ + (4114, 0.011, 0.0), + (9748, 0.022, 45.25), + (12566, 0.044, 169.20), + (15552, 0.066, 293.19), + (18369, 0.088, 490.27), + (93751, 0.1023, 738.17), + (112501, 0.1133, 8449.75), + (187501, 0.1243, 10574.13), + (250000, 0.1353, 19896.63), + (float('inf'), 0.1463, 28352.74), + ] + + elif filing_status == 'married': + tax_rate_table = [ + (4112, 0.011, 0.0), + (9748, 0.022, 45.23), + (15384, 0.044, 169.22), + (21356, 0.066, 417.20), + (26990, 0.088, 811.35), + (137870, 0.1023, 1307.14), + (165442, 0.1133, 12650.16), + (250000, 0.1243, 15774.07), + (275736, 0.1353, 26284.63), + (float('inf'), 0.1463, 29766.71), + ] + + else: + tax_rate_table = [ + (2056, 0.011, 0.0), + (4874, 0.022, 22.62), + (7692, 0.044, 84.62), + (10678, 0.066, 208.61), + (13495, 0.088, 405.69), + (68935, 0.1023, 653.59), + (82721, 0.1133, 6325.10), + (137868, 0.1243, 7887.05), + (250000, 0.1353, 14741.82), + (float('inf'), 0.1463, 29913.28), + ] + + #### SEMI-ANNUAL #### + elif schedule_pay == 'semi-annual': + if filing_status == 'head_household': + tax_rate_table = [ + (8228, 0.011, 0.0), + (19496, 0.022, 90.51), + (25132, 0.044, 338.41), + (31104, 0.066, 586.39), + (36738, 0.088, 980.54), + (187502, 0.1023, 1476.33), + (225002, 0.1133, 16899.49), + (375002, 0.1243, 21148.24), + (500000, 0.1353, 39793.24), + (float('inf'), 0.1463, 56705.47), + ] + + elif filing_status == 'married': + tax_rate_table = [ + (8224, 0.011, 0.0), + (19496, 0.022, 90.46), + (30768, 0.044, 338.44), + (42712, 0.066, 834.41), + (53980, 0.088, 1622.71), + (275740, 0.1023, 2614.29), + (330884, 0.1133, 25300.34), + (500000, 0.1243, 31548.16), + (551472, 0.1353, 52569.28), + (float('inf'), 0.1463, 59533.44), + ] + + else: + tax_rate_table = [ + (4112, 0.011, 0.0), + (9748, 0.022, 45.23), + (15384, 0.044, 169.22), + (21356, 0.066, 417.20), + (26990, 0.088, 811.35), + (137870, 0.1023, 1307.14), + (165442, 0.1133, 12650.16), + (275736, 0.1243, 15774.07), + (500000, 0.1353, 29483.61), + (float('inf'), 0.1463, 59826.53), + ] + + #### ANNUAL #### + elif schedule_pay == 'annually': + if filing_status == 'head_household': + tax_rate_table = [ + (16457, 0.011, 0.0), + (38991, 0.022, 181.03), + (50264, 0.044, 676.78), + (62206, 0.066, 1172.79), + (73477, 0.088, 1960.96), + (375002, 0.1023, 2952.81), + (450003, 0.1133, 33798.82), + (750003, 0.1243, 42296.43), + (1000000, 0.1353, 79586.43), + (float('inf'), 0.1463, 113411.02), + ] + + elif filing_status == 'married': + tax_rate_table = [ + (16446, 0.011, 0.0), + (38990, 0.022, 180.91), + (61538, 0.044, 676.88), + (85422, 0.066, 1668.99), + (107960, 0.088, 3245.33), + (551476, 0.1023, 5228.67), + (661768, 0.1133, 50600.36), + (1000000, 0.1243, 63096.44), + (1102946, 0.1353, 105138.68), + (float('inf'), 0.1463, 119067.26), + ] + + else: + tax_rate_table = [ + (8223, 0.011, 0.0), + (19495, 0.022, 90.45), + (30769, 0.044, 338.43), + (42711, 0.066, 834.49), + (53980, 0.088, 1622.66), + (275738, 0.1023, 2614.33), + (330884, 0.1133, 25300.17), + (551473, 0.1243, 31548.21), + (1000000, 0.1353, 58967.42), + (float('inf'), 0.1463, 119653.12), + ] + + over = 0.0 + tax = 0.0 + for row in tax_rate_table: + if wages <= row[0]: + tax = ((wages - over) * row[1]) + row[2] + break + over = row[0] + + # Exemption allowance table (Step 5) + exemption_allowance_table = { + 'weekly': (2.41, 4.82, 7.23, 9.65, 12.06, 14.47, 16.88, 19.29, 21.70, 24.12), + 'bi-weekly': (4.82, 9.65, 14.47, 19.29, 24.12, 28.94, 33.76, 38.58, 43.41, 48.23), + 'semi-monthly': (5.23, 10.45, 15.68, 20.90, 26.13, 31.35, 36.58, 41.80, 47.03, 52.25), + 'monthly': (10.45, 20.90, 31.35, 41.80, 52.25, 62.70, 73.15, 83.60, 94.05, 104.50), + 'quarterly': (31.35, 62.70, 94.05, 125.40, 156.75, 188.10, 219.45, 250.80, 282.15, 313.50), + 'semi-annual': (62.70, 125.40, 188.10, 250.80, 313.50, 376.20, 438.90, 501.60, 564.30, 627.00), + 'annually': (125.40, 250.80, 376.20, 501.60, 627.00, 752.40, 877.80, 1003.20, 1128.60, 1254.00), + } + + allowance_index = allowances - 1 + if allowances > 10: + deduction = (exemption_allowance_table[schedule_pay][0]) * allowances + tax -= deduction + elif allowances > 0: + deduction = exemption_allowance_table[schedule_pay][allowance_index] + tax -= deduction + + result = -tax + + + + + + diff --git a/l10n_us_ca_hr_payroll/hr_payroll.py b/l10n_us_ca_hr_payroll/hr_payroll.py new file mode 100755 index 00000000..b1a3eebf --- /dev/null +++ b/l10n_us_ca_hr_payroll/hr_payroll.py @@ -0,0 +1,56 @@ +from odoo import models, fields, api + + +class USCAHrContract(models.Model): + _inherit = 'hr.contract' + + ca_de4_allowances = fields.Integer(string="California CA-4 Allowances", + default=0, + help="Estimated Deductions claimed on DE-4") + ca_additional_allowances = fields.Integer(string="Additional Allowances", default=0) + ca_de4_filing_status = fields.Selection([ + ('exempt', 'Exempt'), + ('single', 'Single'), + ('married', 'Married'), + ('head_household', 'Head of Household') + ], string='CA Filing Status', default='single') + + @api.multi + def ca_uit_rate(self, year): + self.ensure_one() + if self.futa_type == self.FUTA_TYPE_BASIC: + return 0.0 + + if hasattr(self.employee_id.company_id, 'ca_uit_rate_' + str(year)): + return self.employee_id.company_id['ca_uit_rate_' + str(year)] + + raise NotImplemented('Year (' + str(year) + ') Not implemented for US California.') + + def ca_ett_rate(self, year): + self.ensure_one() + if self.futa_type == self.FUTA_TYPE_BASIC: + return 0.0 + + if hasattr(self.employee_id.company_id, 'ca_ett_rate_' + str(year)): + return self.employee_id.company_id['ca_ett_rate_' + str(year)] + + raise NotImplemented('Year (' + str(year) + ') Not implemented for US California.') + + def ca_sdi_rate(self, year): + self.ensure_one() + if self.futa_type == self.FUTA_TYPE_BASIC: + return 0.0 + + if hasattr(self.employee_id.company_id, 'ca_sdi_rate_' + str(year)): + return self.employee_id.company_id['ca_sdi_rate_' + str(year)] + + raise NotImplemented('Year (' + str(year) + ') Not implemented for US California.') + + +class CACompany(models.Model): + _inherit = 'res.company' + + # UIT can be calculated using http://www.edd.ca.gov/pdf_pub_ctr/de44.pdf ETT is default. + ca_uit_rate_2018 = fields.Float(string="California Unemployment Insurance Tax Rate 2018", default=2.6) + ca_ett_rate_2018 = fields.Float(string="California Employment Training Tax Rate 2018", default=0.1) + ca_sdi_rate_2018 = fields.Float(string="California State Disability Insurance Rate 2018", default=1.0) diff --git a/l10n_us_ca_hr_payroll/hr_payroll_view.xml b/l10n_us_ca_hr_payroll/hr_payroll_view.xml new file mode 100755 index 00000000..87ef4b6b --- /dev/null +++ b/l10n_us_ca_hr_payroll/hr_payroll_view.xml @@ -0,0 +1,34 @@ + + + + + res.company.form + res.company + 64 + + + + + + + + + + + hr.contract.form.inherit + hr.contract + 147 + + + + + + + + + + + + + + diff --git a/l10n_us_ca_hr_payroll/tests/__init__.py b/l10n_us_ca_hr_payroll/tests/__init__.py new file mode 100755 index 00000000..8011621b --- /dev/null +++ b/l10n_us_ca_hr_payroll/tests/__init__.py @@ -0,0 +1 @@ +from . import test_us_ca_payslip_2018 \ No newline at end of file diff --git a/l10n_us_ca_hr_payroll/tests/test_us_ca_payslip_2018.py b/l10n_us_ca_hr_payroll/tests/test_us_ca_payslip_2018.py new file mode 100755 index 00000000..e58892a8 --- /dev/null +++ b/l10n_us_ca_hr_payroll/tests/test_us_ca_payslip_2018.py @@ -0,0 +1,461 @@ +from odoo.addons.l10n_us_hr_payroll.tests.test_us_payslip import TestUsPayslip, process_payslip +from odoo.addons.l10n_us_hr_payroll.l10n_us_hr_payroll import USHrContract + + +class TestUsCAPayslip(TestUsPayslip): + ### + # Taxes and Rates + ### + CA_UIT_MAX_WAGE = 7000 + CA_UIT_MAX_WAGE = 7000 + CA_SDI_MAX_WAGE = 114967 + + # Examples from http://www.edd.ca.gov/pdf_pub_ctr/18methb.pdf + def test_example_a(self): + salary = 210 + schedule_pay = 'weekly' + allowances = 1 + additional_allowances = 0 + + wh = 0.00 + + employee = self._createEmployee() + + contract = self._createContract(employee, + salary, + struct_id=self.ref( + 'l10n_us_ca_hr_payroll.hr_payroll_salary_structure_us_ca_employee'), + schedule_pay=schedule_pay) + contract.ca_c4_exemptions = allowances + contract.ca_additional_allowances = additional_allowances + contract.ca_de4_filing_status = 'single' + + self.assertEqual(contract.schedule_pay, 'weekly') + + # tax rates + ca_uit = contract.ca_uit_rate(2018) / -100.0 + ca_ett = contract.ca_ett_rate(2018) / -100.0 + ca_sdi = contract.ca_sdi_rate(2018) / -100.0 + + self._log('2017 California tax last payslip:') + payslip = self._createPayslip(employee, '2017-12-01', '2017-12-31') + payslip.compute_sheet() + process_payslip(payslip) + + self._log('2018 California tax first payslip:') + payslip = self._createPayslip(employee, '2018-01-01', '2018-01-31') + + payslip.compute_sheet() + + cats = self._getCategories(payslip) + + self.assertPayrollEqual(cats['CA_UIT_WAGES'], salary) + self.assertPayrollEqual(cats['CA_UIT'], cats['CA_UIT_WAGES'] * ca_uit) + self.assertPayrollEqual(cats['CA_ETT_WAGES'], salary) + self.assertPayrollEqual(cats['CA_ETT'], cats['CA_ETT_WAGES'] * ca_ett) + self.assertPayrollEqual(cats['CA_SDI_WAGES'], salary) + self.assertPayrollEqual(cats['CA_SDI'], cats['CA_SDI_WAGES'] * ca_sdi) + self.assertPayrollEqual(cats['CA_WITHHOLD'], wh) + + process_payslip(payslip) + + # Make a new payslip, this one will have maximums + + remaining_ca_uit_wages = self.CA_UIT_MAX_WAGE - salary if (self.CA_UIT_MAX_WAGE - 2 * salary < salary) \ + else salary + + self._log('2018 California tax second payslip:') + payslip = self._createPayslip(employee, '2018-02-01', '2018-02-28') + + payslip.compute_sheet() + + cats = self._getCategories(payslip) + + self.assertPayrollEqual(cats['CA_UIT_WAGES'], remaining_ca_uit_wages) + self.assertPayrollEqual(cats['CA_UIT'], remaining_ca_uit_wages * ca_uit) + + def test_example_b(self): + salary = 1250 + schedule_pay = 'bi-weekly' + allowances = 2 + additional_allowances = 1 + + wh = -2.89 + # tax rates + ca_uit = 2.6 + ca_ett = 0.1 + ca_sdi = 1.0 + + employee = self._createEmployee() + employee.company_id.ca_uit_rate_2018 = ca_uit + employee.company_id.ca_ett_rate_2018 = ca_ett + employee.company_id.ca_sdi_rate_2018 = ca_sdi + + contract = self._createContract(employee, + salary, + struct_id=self.ref( + 'l10n_us_ca_hr_payroll.hr_payroll_salary_structure_us_ca_employee'), + schedule_pay=schedule_pay) + contract.ca_de4_allowances = allowances + contract.ca_additional_allowances = additional_allowances + contract.ca_de4_filing_status = 'married' + + self.assertEqual(contract.schedule_pay, 'bi-weekly') + self.assertEqual(contract.ca_de4_filing_status, 'married') + self.assertEqual(contract.ca_de4_allowances, 2) + self.assertEqual(contract.ca_additional_allowances, 1) + + self._log('2017 California tax last payslip:') + payslip = self._createPayslip(employee, '2017-12-01', '2017-12-31') + payslip.compute_sheet() + process_payslip(payslip) + + self._log('2018 California tax first payslip:') + payslip = self._createPayslip(employee, '2018-01-01', '2018-01-31') + + payslip.compute_sheet() + + cats = self._getCategories(payslip) + + self.assertPayrollEqual(cats['CA_UIT_WAGES'], salary) + self.assertPayrollEqual(cats['CA_UIT'], round((cats['CA_UIT_WAGES'] * ca_uit)/-100, 2)) + self.assertPayrollEqual(cats['CA_ETT_WAGES'], salary) + self.assertPayrollEqual(cats['CA_ETT'], round((cats['CA_ETT_WAGES'] * ca_ett)/-100, 2)) + self.assertPayrollEqual(cats['CA_SDI_WAGES'], salary) + self.assertPayrollEqual(cats['CA_SDI'], round((cats['CA_SDI_WAGES'] * ca_sdi)/-100, 2)) + self.assertPayrollEqual(cats['CA_WITHHOLD'], wh) + + process_payslip(payslip) + + # Make a new payslip, this one will have maximums + + remaining_ca_uit_wages = self.CA_UIT_MAX_WAGE - salary if (self.CA_UIT_MAX_WAGE - 2 * salary < salary) \ + else salary + + self._log('2018 California tax second payslip:') + payslip = self._createPayslip(employee, '2018-02-01', '2018-02-28') + + payslip.compute_sheet() + + cats = self._getCategories(payslip) + + self.assertPayrollEqual(cats['CA_UIT_WAGES'], remaining_ca_uit_wages) + self.assertPayrollEqual(cats['CA_UIT'], round((remaining_ca_uit_wages * ca_uit)/-100, 2)) + + def test_example_c(self): + salary = 3800 + schedule_pay = 'monthly' + allowances = 5 + additional_allowances = 0.72 + + wh = -0.72 + # tax rates + ca_uit = 2.6 + ca_ett = 0.1 + ca_sdi = 1.0 + + employee = self._createEmployee() + employee.company_id.ca_uit_rate_2018 = ca_uit + employee.company_id.ca_ett_rate_2018 = ca_ett + employee.company_id.ca_sdi_rate_2018 = ca_sdi + + contract = self._createContract(employee, + salary, + struct_id=self.ref( + 'l10n_us_ca_hr_payroll.hr_payroll_salary_structure_us_ca_employee'), + schedule_pay=schedule_pay) + contract.ca_de4_allowances = allowances + contract.ca_additional_allowances = additional_allowances + contract.ca_de4_filing_status = 'married' + + self.assertEqual(contract.schedule_pay, 'monthly') + self.assertEqual(contract.ca_de4_filing_status, 'married') + self.assertEqual(contract.ca_de4_allowances, 5) + self.assertEqual(contract.ca_additional_allowances, 0) + + self._log('2017 California tax last payslip:') + payslip = self._createPayslip(employee, '2017-12-01', '2017-12-31') + payslip.compute_sheet() + process_payslip(payslip) + + self._log('2018 California tax first payslip:') + payslip = self._createPayslip(employee, '2018-01-01', '2018-01-31') + + payslip.compute_sheet() + + cats = self._getCategories(payslip) + + self.assertPayrollEqual(cats['CA_UIT_WAGES'], salary) + self.assertPayrollEqual(cats['CA_UIT'], round((cats['CA_UIT_WAGES'] * ca_uit)/-100, 2)) + self.assertPayrollEqual(cats['CA_ETT_WAGES'], salary) + self.assertPayrollEqual(cats['CA_ETT'], round((cats['CA_ETT_WAGES'] * ca_ett)/-100, 2)) + self.assertPayrollEqual(cats['CA_SDI_WAGES'], salary) + self.assertPayrollEqual(cats['CA_SDI'], round((cats['CA_SDI_WAGES'] * ca_sdi)/-100, 2)) + self.assertPayrollEqual(cats['CA_WITHHOLD'], wh) + + process_payslip(payslip) + + # Make a new payslip, this one will have maximums + + remaining_ca_uit_wages = self.CA_UIT_MAX_WAGE - salary if (self.CA_UIT_MAX_WAGE - 2 * salary < salary) \ + else salary + + self._log('2018 California tax second payslip:') + payslip = self._createPayslip(employee, '2018-02-01', '2018-02-28') + + payslip.compute_sheet() + + cats = self._getCategories(payslip) + + self.assertPayrollEqual(cats['CA_UIT_WAGES'], remaining_ca_uit_wages) + self.assertPayrollEqual(cats['CA_UIT'], round((remaining_ca_uit_wages * ca_uit)/-100, 2)) + + def test_example_d(self): + salary = 800 + schedule_pay = 'weekly' + allowances = 3 + additional_allowances = 0 + + wh = -3.31 + # tax rates + ca_uit = 2.6 + ca_ett = 0.1 + ca_sdi = 1.0 + + employee = self._createEmployee() + employee.company_id.ca_uit_rate_2018 = ca_uit + employee.company_id.ca_ett_rate_2018 = ca_ett + employee.company_id.ca_sdi_rate_2018 = ca_sdi + + contract = self._createContract(employee, + salary, + struct_id=self.ref( + 'l10n_us_ca_hr_payroll.hr_payroll_salary_structure_us_ca_employee'), + schedule_pay=schedule_pay) + contract.ca_de4_allowances = allowances + contract.ca_additional_allowances = additional_allowances + contract.ca_de4_filing_status = 'head_household' + + self.assertEqual(contract.schedule_pay, 'weekly') + self.assertEqual(contract.ca_de4_filing_status, 'head_household') + self.assertEqual(contract.ca_de4_allowances, 3) + self.assertEqual(contract.ca_additional_allowances, 0) + + self._log('2017 California tax last payslip:') + payslip = self._createPayslip(employee, '2017-12-01', '2017-12-31') + payslip.compute_sheet() + process_payslip(payslip) + + self._log('2018 California tax first payslip:') + payslip = self._createPayslip(employee, '2018-01-01', '2018-01-31') + + payslip.compute_sheet() + + cats = self._getCategories(payslip) + + self.assertPayrollEqual(cats['CA_UIT_WAGES'], salary) + self.assertPayrollEqual(cats['CA_UIT'], round((cats['CA_UIT_WAGES'] * ca_uit)/-100, 2)) + self.assertPayrollEqual(cats['CA_ETT_WAGES'], salary) + self.assertPayrollEqual(cats['CA_ETT'], round((cats['CA_ETT_WAGES'] * ca_ett)/-100, 2)) + self.assertPayrollEqual(cats['CA_SDI_WAGES'], salary) + self.assertPayrollEqual(cats['CA_SDI'], round((cats['CA_SDI_WAGES'] * ca_sdi)/-100, 2)) + self.assertPayrollEqual(cats['CA_WITHHOLD'], wh) + + process_payslip(payslip) + + # Make a new payslip, this one will have maximums + + remaining_ca_uit_wages = self.CA_UIT_MAX_WAGE - salary if (self.CA_UIT_MAX_WAGE - 2 * salary < salary) \ + else salary + + self._log('2018 California tax second payslip:') + payslip = self._createPayslip(employee, '2018-02-01', '2018-02-28') + + payslip.compute_sheet() + + cats = self._getCategories(payslip) + + self.assertPayrollEqual(cats['CA_UIT_WAGES'], remaining_ca_uit_wages) + self.assertPayrollEqual(cats['CA_UIT'], round((remaining_ca_uit_wages * ca_uit)/-100, 2)) + + def test_example_e(self): + salary = 1800 + schedule_pay = 'semi-monthly' + allowances = 4 + additional_allowances = 0 + + wh = -3.39 + # tax rates + ca_uit = 2.6 + ca_ett = 0.1 + ca_sdi = 1.0 + + employee = self._createEmployee() + employee.company_id.ca_uit_rate_2018 = ca_uit + employee.company_id.ca_ett_rate_2018 = ca_ett + employee.company_id.ca_sdi_rate_2018 = ca_sdi + + contract = self._createContract(employee, + salary, + struct_id=self.ref( + 'l10n_us_ca_hr_payroll.hr_payroll_salary_structure_us_ca_employee'), + schedule_pay=schedule_pay) + contract.ca_de4_allowances = allowances + contract.ca_additional_allowances = additional_allowances + contract.ca_de4_filing_status = 'married' + + self.assertEqual(contract.schedule_pay, 'semi-monthly') + self.assertEqual(contract.ca_de4_filing_status, 'married') + self.assertEqual(contract.ca_de4_allowances, 4) + self.assertEqual(contract.ca_additional_allowances, 0) + + self._log('2017 California tax last payslip:') + payslip = self._createPayslip(employee, '2017-12-01', '2017-12-31') + payslip.compute_sheet() + process_payslip(payslip) + + self._log('2018 California tax first payslip:') + payslip = self._createPayslip(employee, '2018-01-01', '2018-01-31') + + payslip.compute_sheet() + + cats = self._getCategories(payslip) + + self.assertPayrollEqual(cats['CA_UIT_WAGES'], salary) + self.assertPayrollEqual(cats['CA_UIT'], round((cats['CA_UIT_WAGES'] * ca_uit)/-100, 2)) + self.assertPayrollEqual(cats['CA_ETT_WAGES'], salary) + self.assertPayrollEqual(cats['CA_ETT'], round((cats['CA_ETT_WAGES'] * ca_ett)/-100, 2)) + self.assertPayrollEqual(cats['CA_SDI_WAGES'], salary) + self.assertPayrollEqual(cats['CA_SDI'], round((cats['CA_SDI_WAGES'] * ca_sdi)/-100, 2)) + self.assertPayrollEqual(cats['CA_WITHHOLD'], wh) + + process_payslip(payslip) + + # Make a new payslip, this one will have maximums + + remaining_ca_uit_wages = self.CA_UIT_MAX_WAGE - salary if (self.CA_UIT_MAX_WAGE - 2 * salary < salary) \ + else salary + + self._log('2018 California tax second payslip:') + payslip = self._createPayslip(employee, '2018-02-01', '2018-02-28') + + payslip.compute_sheet() + + cats = self._getCategories(payslip) + + self.assertPayrollEqual(cats['CA_UIT_WAGES'], remaining_ca_uit_wages) + self.assertPayrollEqual(cats['CA_UIT'], round((remaining_ca_uit_wages * ca_uit)/-100, 2)) + + def test_example_f(self): + salary = 45000 + schedule_pay = 'annually' + allowances = 4 + additional_allowances = 0 + + wh = -121.11 + # tax rates + ca_uit = 2.6 + ca_ett = 0.1 + ca_sdi = 1.0 + + employee = self._createEmployee() + employee.company_id.ca_uit_rate_2018 = ca_uit + employee.company_id.ca_ett_rate_2018 = ca_ett + employee.company_id.ca_sdi_rate_2018 = ca_sdi + + contract = self._createContract(employee, + salary, + struct_id=self.ref( + 'l10n_us_ca_hr_payroll.hr_payroll_salary_structure_us_ca_employee'), + schedule_pay=schedule_pay) + contract.ca_de4_allowances = allowances + contract.ca_additional_allowances = additional_allowances + contract.ca_de4_filing_status = 'married' + + self.assertEqual(contract.schedule_pay, 'annually') + self.assertEqual(contract.ca_de4_filing_status, 'married') + self.assertEqual(contract.ca_de4_allowances, 4) + self.assertEqual(contract.ca_additional_allowances, 0) + + self._log('2017 California tax last payslip:') + payslip = self._createPayslip(employee, '2017-12-01', '2017-12-31') + payslip.compute_sheet() + process_payslip(payslip) + + self._log('2018 California tax first payslip:') + payslip = self._createPayslip(employee, '2018-01-01', '2018-01-31') + + payslip.compute_sheet() + + cats = self._getCategories(payslip) + + self.assertPayrollEqual(cats['CA_UIT'], round((cats['CA_UIT_WAGES'] * ca_uit)/-100, 2)) + self.assertPayrollEqual(cats['CA_ETT'], round((cats['CA_ETT_WAGES'] * ca_ett)/-100, 2)) + self.assertPayrollEqual(cats['CA_SDI'], round((cats['CA_SDI_WAGES'] * ca_sdi)/-100, 2)) + self.assertPayrollEqual(cats['CA_WITHHOLD'], wh) + + process_payslip(payslip) + + def test_estimated_deduction_table(self): + salary = 600 + allowances = 5 + schedule_pay = 'bi-weekly' + expected_deduction = 192 + deduction = 0 + taxable_pay = 0 + estimated_deduction_table = { + 'weekly': (19, 38, 58, 77, 96, 115, 135, 154, 173, 192), + 'bi-weekly': (38, 77, 115, 154, 192, 231, 269, 308, 346, 385), + 'semi-monthly': (42, 83, 125, 167, 208, 250, 292, 333, 375, 417), + 'monthly': (83, 167, 250, 333, 417, 500, 583, 667, 750, 833), + 'quarterly': (250, 500, 750, 1000, 1250, 1500, 1750, 2000, 2250, 2500), + 'semi-annual': (500, 1000, 1500, 2000, 2500, 3000, 3500, 4000, 4500, 5000), + 'annual': (1000, 2000, 3000, 4000, 5000, 6000, 7000, 8000, 9000, 10000), + } + + allowance_index = allowances - 1 + if allowances > 10: + deduction = (estimated_deduction_table[schedule_pay][0]) * allowances + taxable_pay = salary - deduction + elif allowances > 0: + deduction = estimated_deduction_table[schedule_pay][allowance_index] + taxable_pay = salary - deduction + + self.assertEqual(expected_deduction, deduction) + self.assertTrue(taxable_pay < salary) + self.assertEqual(taxable_pay, salary - deduction) + + def test_standard_deduction_table(self): + salary = 3000 + schedule_pay = 'monthly' + filing_status = 'head_household' + expected_deduction = 706 + deduction = 0 + taxable_pay = 0 + standard_deduction_table = { + 'weekly': (81, 81, 163, 163), + 'bi-weekly': (163, 163, 326, 326), + 'semi-monthly': (177, 177, 353, 353), + 'monthly': (353, 352, 706, 706), + 'quarterly': (1059, 1059, 2188, 2188), + 'semi-annual': (2118, 2118, 4236, 4236), + 'annual': (4236, 4236, 8471, 8472), + } + + if filing_status == 'head_household': + _, _, _, deduction = standard_deduction_table[schedule_pay] + taxable_pay = salary - deduction + elif filing_status == 'married': + if allowances >= 2: + _, _, deduction, _ = standard_deduction_table[schedule_pay] + taxable_pay = salary - deduction + else: + _, deduction, _, _ = standard_deduction_table[schedule_pay] + taxable_pay = salary - deduction + else: + deduction, _, _, _ = standard_deduction_table[schedule_pay] + taxable_pay = salary - deduction + + self.assertEqual(expected_deduction, deduction) + self.assertTrue(taxable_pay < salary) + self.assertEqual(taxable_pay, salary - deduction) From 9cdae837bd9341b49546249f4631b88703855fe2 Mon Sep 17 00:00:00 2001 From: Kristen Marie Kulha Date: Mon, 21 May 2018 10:50:02 -0700 Subject: [PATCH 02/55] Initial commit of `l10n_us_ks_hr_payroll` for 11.0. --- l10n_us_ks_hr_payroll/__init__.py | 1 + l10n_us_ks_hr_payroll/__manifest__.py | 26 ++ l10n_us_ks_hr_payroll/data/base.xml | 48 ++++ l10n_us_ks_hr_payroll/data/final.xml | 19 ++ l10n_us_ks_hr_payroll/data/rules_2018.xml | 229 ++++++++++++++++++ l10n_us_ks_hr_payroll/tests/__init__.py | 1 + .../tests/test_us_ks_payslip_2018.py | 92 +++++++ l10n_us_ks_hr_payroll/us_ks_hr_payroll.py | 36 +++ .../us_ks_hr_payroll_view.xml | 35 +++ 9 files changed, 487 insertions(+) create mode 100755 l10n_us_ks_hr_payroll/__init__.py create mode 100755 l10n_us_ks_hr_payroll/__manifest__.py create mode 100755 l10n_us_ks_hr_payroll/data/base.xml create mode 100755 l10n_us_ks_hr_payroll/data/final.xml create mode 100755 l10n_us_ks_hr_payroll/data/rules_2018.xml create mode 100755 l10n_us_ks_hr_payroll/tests/__init__.py create mode 100755 l10n_us_ks_hr_payroll/tests/test_us_ks_payslip_2018.py create mode 100755 l10n_us_ks_hr_payroll/us_ks_hr_payroll.py create mode 100755 l10n_us_ks_hr_payroll/us_ks_hr_payroll_view.xml diff --git a/l10n_us_ks_hr_payroll/__init__.py b/l10n_us_ks_hr_payroll/__init__.py new file mode 100755 index 00000000..2b72afb4 --- /dev/null +++ b/l10n_us_ks_hr_payroll/__init__.py @@ -0,0 +1 @@ +from . import us_ks_hr_payroll diff --git a/l10n_us_ks_hr_payroll/__manifest__.py b/l10n_us_ks_hr_payroll/__manifest__.py new file mode 100755 index 00000000..d4e45bae --- /dev/null +++ b/l10n_us_ks_hr_payroll/__manifest__.py @@ -0,0 +1,26 @@ +{ + 'name': 'USA - Kansas - Payroll', + 'author': 'Hibou Corp. ', + 'license': 'AGPL-3', + 'category': 'Localization', + 'depends': ['l10n_us_hr_payroll'], + 'version': '11.0.2018.0.0', + 'description': """ +USA::Kansas Payroll Rules. +=========================== + + * Kansas Department of Revenue partner + * Contribution register for Kansas DoR + * Company level Kansas Unemployment Rate + """, + + 'auto_install': False, + 'website': 'https://hibou.io/', + 'data':[ + 'us_ks_hr_payroll_view.xml', + 'data/base.xml', + 'data/rules_2018.xml', + 'data/final.xml', + ], + 'installable': True +} diff --git a/l10n_us_ks_hr_payroll/data/base.xml b/l10n_us_ks_hr_payroll/data/base.xml new file mode 100755 index 00000000..d15980c0 --- /dev/null +++ b/l10n_us_ks_hr_payroll/data/base.xml @@ -0,0 +1,48 @@ + + + + + + + Kansas Department of Revenue - Unemployment Insurance Tax + 1 + + + + Kansas Department of Revenue - Income Tax Withholding + 1 + + + + + Kansas Unemployment Insurance Tax + Kansas Department of Revenue - Unemployment Insurance Tax + + + + Kansas Income Tax Withholding + Kansas Department of Revenue - Income Tax Withholding + + + + + + + Kansas Unemployment Insurance Tax - Wages + KS_UNEMP_WAGES + + + + Kansas Unemployment Insurance Tax + KS_UNEMP + + + + + Kansas Income Withholding + KS_WITHHOLD + + + + + diff --git a/l10n_us_ks_hr_payroll/data/final.xml b/l10n_us_ks_hr_payroll/data/final.xml new file mode 100755 index 00000000..663d71a5 --- /dev/null +++ b/l10n_us_ks_hr_payroll/data/final.xml @@ -0,0 +1,19 @@ + + + + + + + US_KS_EMP + USA Kansas Employee + + + + + + + diff --git a/l10n_us_ks_hr_payroll/data/rules_2018.xml b/l10n_us_ks_hr_payroll/data/rules_2018.xml new file mode 100755 index 00000000..29b9110c --- /dev/null +++ b/l10n_us_ks_hr_payroll/data/rules_2018.xml @@ -0,0 +1,229 @@ + + + + + + + + + Kansas Unemployment Insurance Tax - Wages (2018) + KS_UNEMP_WAGES_2018 + python + result = (payslip.date_to[:4] == '2018') + code + +### +ytd = payslip.sum('KS_UNEMP_WAGES_2018', '2018-01-01', '2019-01-01') +ytd += contract.external_wages +remaining = 14000.0 - ytd +if remaining <= 0.0: + result = 0 +elif remaining < categories.GROSS: + result = remaining +else: + result = categories.GROSS + + + + + + + Kansas Unemployment (2018) + KS_UNEMP_2018 + python + result = (payslip.date_to[:4] == '2018') + code + +result_rate = -contract.ks_unemp_rate(2018) +result = categories.KS_UNEMP_WAGES + +# result_rate of 0 implies 100% due to bug +if result_rate == 0.0: + result = 0.0 + + + + + + + + + + Kansas Income Withholding + KS_INC_WITHHOLD_2018 + python + result = (payslip.date_to[:4] == '2018') + code + +wages = categories.GROSS +allowances = contract.ks_k4_allowances +additional_withholding = contract.ks_additional_withholding +schedule_pay = contract.schedule_pay +filing_status = contract.ks_k4_filing_status + +# Tables are found in https://www.ksrevenue.org/pdf/kw1002017.pdf +# First check for exemption status (Step 1) +if filing_status == 'exempt': + result = 0 + +else: + # Calculate Withholding Allowance Amounts using table (allowance multipliers are from table). + if schedule_pay == 'weekly': + wages -= (allowances * 43.27) + elif schedule_pay == 'bi-weekly': + wages -= (allowances * 86.54) + elif schedule_pay == 'semi-monthly': + wages -= (allowances * 93.75) + elif schedule_pay == 'monthly': + wages -= (allowances * 187.50) + elif schedule_pay == 'quarterly': + wages -= (allowances * 562.50) + elif schedule_pay == 'semi-annual': + wages -= (allowances * 1125.00) + elif schedule_pay == 'annually': + wages -= (allowances * 2250.00) + + # Tax Rate Tables to calculate income withholding + #### WEEKLY #### + if schedule_pay == 'weekly': + if filing_status == 'head_household' or 'single' and wages > 0: + tax_rate_table = [ + (58, 0.0, 0.0), + (346, 0.031, 0.0), + (635, 0.0525, 8.94), + (float('inf'), 0.057, 24.09), + ] + + elif filing_status == 'married': + tax_rate_table = [ + (144, 0.0, 0.0), + (721, 0.031, 0.0), + (1298, 0.0525, 17.88), + (float('inf'), 0.057, 48.17), + ] + + ### BI-WEEKLY ### + if schedule_pay == 'bi-weekly': + if filing_status == 'head_household' or 'single' and wages > 0: + tax_rate_table = [ + (115, 0.0, 0.0), + (692, 0.031, 0.0), + (1269, 0.0525, 17.88), + (float('inf'), 0.057, 48.17), + ] + + elif filing_status == 'married': + tax_rate_table = [ + (288, 0.0, 0.0), + (1442, 0.031, 0.0), + (2596, 0.0525, 35.77), + (float('inf'), 0.057, 96.35), + ] + + ### SEMI-MONTHLY ### + if schedule_pay == 'semi-monthly': + if filing_status == 'head_household' or 'single' and wages > 0: + tax_rate_table = [ + (125, 0.0, 0.0), + (750, 0.031, 0.0), + (1375, 0.0525, 19.38), + (float('inf'), 0.057, 52.19), + ] + + elif filing_status == 'married': + tax_rate_table = [ + (313, 0.0, 0.0), + (1563, 0.031, 0.0), + (2813, 0.0525, 38.75), + (float('inf'), 0.057, 104.38), + ] + + ### MONTHLY ### + if schedule_pay == 'monthly': + if filing_status == 'head_household' or 'single' and wages > 0: + tax_rate_table = [ + (250, 0.0, 0.0), + (1500, 0.031, 0.0), + (2750, 0.0525, 38.75), + (float('inf'), 0.057, 104.38), + ] + + elif filing_status == 'married': + tax_rate_table = [ + (625, 0.0, 0.0), + (3125, 0.031, 0.0), + (5625, 0.0525, 77.50), + (float('inf'), 0.057, 208.75), + ] + + ### QUARTERLY ### + if schedule_pay == 'quarterly': + if filing_status == 'head_household' or 'single' and wages > 0: + tax_rate_table = [ + (750, 0.0, 0.0), + (4500, 0.031, 0.0), + (8250, 0.0525, 116.25), + (float('inf'), 0.057, 313.13), + ] + + elif filing_status == 'married': + tax_rate_table = [ + (1875, 0.0, 0.0), + (9375, 0.031, 0.0), + (16875, 0.0525, 232.50), + (float('inf'), 0.057, 626.25), + ] + + ### SEMI-ANNUAL ### + if schedule_pay == 'semi-annual': + if filing_status == 'head_household' or 'single' and wages > 0: + tax_rate_table = [ + (1500, 0.0, 0.0), + (9000, 0.031, 0.0), + (16500, 0.0525, 232.50), + (float('inf'), 0.057, 626.25), + ] + + elif filing_status == 'married': + tax_rate_table = [ + (3750, 0.0, 0.0), + (18750, 0.031, 0.0), + (33750, 0.0525, 465.00), + (float('inf'), 0.057, 1252.50), + ] + + ### ANNUAL ### + if schedule_pay == 'annually': + if filing_status == 'head_household' or 'single' and wages > 0: + tax_rate_table = [ + (3000, 0.0, 0.0), + (18000, 0.031, 0.0), + (33000, 0.0525, 465.00), + (float('inf'), 0.057, 1252.50), + ] + + elif filing_status == 'married': + tax_rate_table = [ + (7500, 0.0, 0.0), + (37500, 0.031, 0.0), + (67500, 0.0525, 930.00), + (float('inf'), 0.057, 2505.00), + ] + + over = 0.0 + tax = 0.0 + for row in tax_rate_table: + if wages <= row[0]: + tax = ((wages - over) * row[1]) + row[2] + tax += additional_withholding + break + over = row[0] + + result = -tax + + + + + + + diff --git a/l10n_us_ks_hr_payroll/tests/__init__.py b/l10n_us_ks_hr_payroll/tests/__init__.py new file mode 100755 index 00000000..62f312a3 --- /dev/null +++ b/l10n_us_ks_hr_payroll/tests/__init__.py @@ -0,0 +1 @@ +from . import test_us_ks_payslip_2018 diff --git a/l10n_us_ks_hr_payroll/tests/test_us_ks_payslip_2018.py b/l10n_us_ks_hr_payroll/tests/test_us_ks_payslip_2018.py new file mode 100755 index 00000000..9f6a0871 --- /dev/null +++ b/l10n_us_ks_hr_payroll/tests/test_us_ks_payslip_2018.py @@ -0,0 +1,92 @@ +from odoo.addons.l10n_us_hr_payroll.tests.test_us_payslip import TestUsPayslip, process_payslip +from odoo.addons.l10n_us_hr_payroll.l10n_us_hr_payroll import USHrContract + + +class TestUsKSPayslip(TestUsPayslip): + ### + # 2018 Taxes and Rates + ### + KS_UNEMP_MAX_WAGE = 14000.0 + + def test_2018_taxes(self): + self.debug = True + salary = 210 + schedule_pay = 'weekly' + allowances = 2 + additional_withholding = 0 + + # Amount of each withholding allowance for weekly from Withholding Allowance Amounts Table + # https://www.ksrevenue.org/pdf/kw1002017.pdf + withholding_allowance = 43.27 * allowances + taxable_pay = salary - withholding_allowance + + # Tax Percentage Method for Single, taxable pay over $58, under $346 + wh = -round(((taxable_pay - 58) * 0.031) - additional_withholding, 2) + + employee = self._createEmployee() + + contract = self._createContract(employee, + salary, + struct_id=self.ref( + 'l10n_us_ks_hr_payroll.hr_payroll_salary_structure_us_ks_employee'), + schedule_pay=schedule_pay) + contract.ks_k4_allowances = allowances + contract.ks_additional_withholding = additional_withholding + contract.ks_k4_filing_status = 'single' + + self.assertEqual(contract.schedule_pay, 'weekly') + + # tax rates + ks_unemp = contract.ks_unemp_rate(2018) / -100.0 + + self._log('2018 Kansas tax first payslip:') + payslip = self._createPayslip(employee, '2018-01-01', '2018-01-31') + + payslip.compute_sheet() + + cats = self._getCategories(payslip) + + self.assertPayrollEqual(cats['KS_UNEMP_WAGES'], salary) + self.assertPayrollEqual(cats['KS_UNEMP'], cats['KS_UNEMP_WAGES'] * ks_unemp) + self.assertPayrollEqual(cats['KS_WITHHOLD'], wh) + + process_payslip(payslip) + + # Make a new payslip, this one will have maximums + + remaining_ks_unemp_wages = self.KS_UNEMP_MAX_WAGE - salary if (self.KS_UNEMP_MAX_WAGE - 2 * salary < salary) \ + else salary + + self._log('2018 Kansas tax second payslip:') + payslip = self._createPayslip(employee, '2018-02-01', '2018-02-28') + payslip.compute_sheet() + + cats = self._getCategories(payslip) + + self.assertPayrollEqual(cats['KS_UNEMP_WAGES'], remaining_ks_unemp_wages) + self.assertPayrollEqual(cats['KS_UNEMP'], remaining_ks_unemp_wages * ks_unemp) + + def test_2018_taxes_with_state_exempt(self): + salary = 210 + schedule_pay = 'weekly' + allowances = 2 + + # Tax Exempt + wh = 0 + + employee = self._createEmployee() + + contract = self._createContract(employee, + salary, + struct_id=self.ref( + 'l10n_us_ks_hr_payroll.hr_payroll_salary_structure_us_ks_employee'), + schedule_pay=schedule_pay) + contract.ks_k4_allowances = allowances + contract.ks_k4_filing_status = 'exempt' + + payslip = self._createPayslip(employee, '2018-02-01', '2018-02-28') + payslip.compute_sheet() + + cats = self._getCategories(payslip) + + self.assertEqual(cats['KS_WITHHOLD'], wh) diff --git a/l10n_us_ks_hr_payroll/us_ks_hr_payroll.py b/l10n_us_ks_hr_payroll/us_ks_hr_payroll.py new file mode 100755 index 00000000..e921c8f2 --- /dev/null +++ b/l10n_us_ks_hr_payroll/us_ks_hr_payroll.py @@ -0,0 +1,36 @@ +from odoo import models, fields, api + + +class USKSHrContract(models.Model): + _inherit = 'hr.contract' + + ks_k4_allowances = fields.Integer(string="Kansas K-4 Allowances", + default=0, + help="Allowances claimed on K-4") + ks_additional_withholding = fields.Float(string="Additional Withholding", + default=0.0, + help='Additional withholding from line 5 of form K-4') + ks_k4_filing_status = fields.Selection([ + ('exempt', 'Exempt'), + ('single', 'Single'), + ('married', 'Married'), + ('head_household', 'Head of Household') + ], string='KS Filing Status', default='single') + + + @api.multi + def ks_unemp_rate(self, year): + self.ensure_one() + if self.futa_type == self.FUTA_TYPE_BASIC: + return 0.0 + + if hasattr(self.employee_id.company_id, 'ks_unemp_rate_' + str(year)): + return self.employee_id.company_id['ks_unemp_rate_' + str(year)] + + raise NotImplemented('Year (' + str(year) + ') Not implemented for US Kansas.') + + +class KSCompany(models.Model): + _inherit = 'res.company' + + ks_unemp_rate_2018 = fields.Float(string="Kansas Unemployment Insurance Rate 2018", default=2.7) diff --git a/l10n_us_ks_hr_payroll/us_ks_hr_payroll_view.xml b/l10n_us_ks_hr_payroll/us_ks_hr_payroll_view.xml new file mode 100755 index 00000000..4138a303 --- /dev/null +++ b/l10n_us_ks_hr_payroll/us_ks_hr_payroll_view.xml @@ -0,0 +1,35 @@ + + + + + res.company.form + res.company + 20 + + + + + + + + + + + + hr.contract.form.inherit + hr.contract + 147 + + + + + + + + + + + + + + From 519771d62184759ec523d31a8b12b436f729fbaf Mon Sep 17 00:00:00 2001 From: Kristen Marie Kulha Date: Tue, 22 May 2018 13:11:55 -0700 Subject: [PATCH 03/55] Initial commit of `l10n_us_tx_hr_payroll` for 11.0. --- l10n_us_tx_hr_payroll/__init__.py | 1 + l10n_us_tx_hr_payroll/__manifest__.py | 29 ++++ l10n_us_tx_hr_payroll/data/base.xml | 53 ++++++ l10n_us_tx_hr_payroll/data/final.xml | 20 +++ l10n_us_tx_hr_payroll/data/rules_2018.xml | 87 ++++++++++ l10n_us_tx_hr_payroll/tests/__init__.py | 1 + .../tests/test_us_tx_payslip_2018.py | 161 ++++++++++++++++++ l10n_us_tx_hr_payroll/us_tx_hr_payroll.py | 44 +++++ .../us_tx_hr_payroll_view.xml | 35 ++++ 9 files changed, 431 insertions(+) create mode 100755 l10n_us_tx_hr_payroll/__init__.py create mode 100755 l10n_us_tx_hr_payroll/__manifest__.py create mode 100755 l10n_us_tx_hr_payroll/data/base.xml create mode 100755 l10n_us_tx_hr_payroll/data/final.xml create mode 100755 l10n_us_tx_hr_payroll/data/rules_2018.xml create mode 100755 l10n_us_tx_hr_payroll/tests/__init__.py create mode 100755 l10n_us_tx_hr_payroll/tests/test_us_tx_payslip_2018.py create mode 100755 l10n_us_tx_hr_payroll/us_tx_hr_payroll.py create mode 100755 l10n_us_tx_hr_payroll/us_tx_hr_payroll_view.xml diff --git a/l10n_us_tx_hr_payroll/__init__.py b/l10n_us_tx_hr_payroll/__init__.py new file mode 100755 index 00000000..1bbfd266 --- /dev/null +++ b/l10n_us_tx_hr_payroll/__init__.py @@ -0,0 +1 @@ +from . import us_tx_hr_payroll diff --git a/l10n_us_tx_hr_payroll/__manifest__.py b/l10n_us_tx_hr_payroll/__manifest__.py new file mode 100755 index 00000000..875e7d87 --- /dev/null +++ b/l10n_us_tx_hr_payroll/__manifest__.py @@ -0,0 +1,29 @@ +# -*- encoding: utf-8 -*- +{ + 'name': 'USA - Texas - Payroll', + 'author': 'Hibou Corp. ', + 'license': 'AGPL-3', + 'category': 'Localization', + 'depends': ['l10n_us_hr_payroll'], + 'version': '11.0.2018.0.0', + 'description': """ +USA::Texas Payroll Rules. +========================= + + * Texas Workforce Commission partner + * Contribution register for Texas Workforce Commission + * Company level Texas Umemployment Rate + * Company level Texas Obligation Assessment Rate + * Company level Texas Employment & Training Investment Assessment + """, + + 'auto_install': False, + 'website': 'https://hibou.io/', + 'data':[ + 'us_tx_hr_payroll_view.xml', + 'data/base.xml', + 'data/rules_2018.xml', + 'data/final.xml', + ], + 'installable': True +} diff --git a/l10n_us_tx_hr_payroll/data/base.xml b/l10n_us_tx_hr_payroll/data/base.xml new file mode 100755 index 00000000..58aca4aa --- /dev/null +++ b/l10n_us_tx_hr_payroll/data/base.xml @@ -0,0 +1,53 @@ + + + + + + + Texas Workforce Commission + 1 + + + + Texas Unemployment + Texas Workforce Commission - Unemployment + + + + Texas Obligation Assessment + Texas Workforce Commission - Obligation Assessment + + + + Texas Employment and Training Investment Assessment + Texas Workforce Commission - Employment and Trainging Investment Assessment + + + + + + + Texas Unemployment - Wages + TX_UNEMP_WAGES + + + + Texas Unemployment + TX_UNEMP + + + + + Texas Obligation Assessment + TX_OA + + + + + Texas Employment and Training Investment Assessment + TX_ETIA + + + + + diff --git a/l10n_us_tx_hr_payroll/data/final.xml b/l10n_us_tx_hr_payroll/data/final.xml new file mode 100755 index 00000000..82997a70 --- /dev/null +++ b/l10n_us_tx_hr_payroll/data/final.xml @@ -0,0 +1,20 @@ + + + + + + + US_TX_EMP + USA Texas Employee + + + + + + + diff --git a/l10n_us_tx_hr_payroll/data/rules_2018.xml b/l10n_us_tx_hr_payroll/data/rules_2018.xml new file mode 100755 index 00000000..551f7426 --- /dev/null +++ b/l10n_us_tx_hr_payroll/data/rules_2018.xml @@ -0,0 +1,87 @@ + + + + + + + + + Texas Unemployment - Wages (2018) + TX_UNEMP_WAGES_2018 + python + result = (payslip.date_to[:4] == '2018') + code + +### +ytd = payslip.sum('TX_UNEMP_WAGES_2018', '2018-01-01', '2019-01-01') +ytd += contract.external_wages +remaining = 9000.0 - ytd +if remaining <= 0.0: + result = 0 +elif remaining < categories.GROSS: + result = remaining +else: + result = categories.GROSS + + + + + + + Texas Unemployment (2018) + TX_UNEMP_2018 + python + result = (payslip.date_to[:4] == '2018') + code + +result_rate = -contract.tx_unemp_rate(2018) +result = categories.TX_UNEMP_WAGES + +# result_rate of 0 implies 100% due to bug +if result_rate == 0.0: + result = 0.0 + + + + + + + + Texas Obligation Assessment (2018) + TX_OA_2018 + python + result = (payslip.date_to[:4] == '2018') + code + +result_rate = -contract.tx_oa_rate(2018) +result = categories.TX_UNEMP_WAGES + +# result_rate of 0 implies 100% due to bug +if result_rate == 0.0: + result = 0.0 + + + + + + + + Texas Employment and Training Investment Assessment (2018) + TX_ETIA_2018 + python + result = (payslip.date_to[:4] == '2018') + code + +result_rate = -contract.tx_etia_rate(2018) +result = categories.TX_UNEMP_WAGES + +# result_rate of 0 implies 100% due to bug +if result_rate == 0.0: + result = 0.0 + + + + + + + diff --git a/l10n_us_tx_hr_payroll/tests/__init__.py b/l10n_us_tx_hr_payroll/tests/__init__.py new file mode 100755 index 00000000..a174888c --- /dev/null +++ b/l10n_us_tx_hr_payroll/tests/__init__.py @@ -0,0 +1 @@ +from . import test_us_tx_payslip_2018 diff --git a/l10n_us_tx_hr_payroll/tests/test_us_tx_payslip_2018.py b/l10n_us_tx_hr_payroll/tests/test_us_tx_payslip_2018.py new file mode 100755 index 00000000..9701b517 --- /dev/null +++ b/l10n_us_tx_hr_payroll/tests/test_us_tx_payslip_2018.py @@ -0,0 +1,161 @@ +from odoo.addons.l10n_us_hr_payroll.tests.test_us_payslip import TestUsPayslip, process_payslip +from odoo.addons.l10n_us_hr_payroll.l10n_us_hr_payroll import USHrContract + + +class TestUsTXPayslip(TestUsPayslip): + ### + # 2018 Taxes and Rates + ### + TX_UNEMP_MAX_WAGE = 9000.0 + + def test_2018_taxes(self): + salary = 5000.0 + + employee = self._createEmployee() + employee.company_id.tx_unemp_rate_2018 = 2.7 + employee.company_id.tx_oa_rate_2018 = 0.0 + employee.company_id.tx_etia_rate_2018 = 0.1 + + contract = self._createContract(employee, salary, struct_id=self.ref('l10n_us_tx_hr_payroll.hr_payroll_salary_structure_us_tx_employee')) + + # tax rates + tx_unemp = contract.tx_unemp_rate(2018) / -100.0 + tx_oa = contract.tx_oa_rate(2018) / -100.00 + tx_etia = contract.tx_etia_rate(2018) / -100.00 + + self._log('2018 Texas tax first payslip:') + payslip = self._createPayslip(employee, '2018-01-01', '2018-01-31') + + payslip.compute_sheet() + + cats = self._getCategories(payslip) + + self.assertPayrollEqual(cats['TX_UNEMP_WAGES'], salary) + self.assertPayrollEqual(cats['TX_UNEMP'], cats['TX_UNEMP_WAGES'] * tx_unemp) + self.assertPayrollEqual(cats['TX_OA'], cats['TX_UNEMP_WAGES'] * tx_oa) + self.assertPayrollEqual(cats['TX_ETIA'], cats['TX_UNEMP_WAGES'] * tx_etia) + + process_payslip(payslip) + + # Make a new payslip, this one will have maximums + + remaining_tx_unemp_wages = self.TX_UNEMP_MAX_WAGE - salary if (self.TX_UNEMP_MAX_WAGE - 2*salary < salary) \ + else salary + + self._log('2018 Texas tax second payslip:') + payslip = self._createPayslip(employee, '2018-02-01', '2018-02-28') + + payslip.compute_sheet() + + cats = self._getCategories(payslip) + + self.assertPayrollEqual(cats['TX_UNEMP_WAGES'], remaining_tx_unemp_wages) + self.assertPayrollEqual(cats['TX_UNEMP'], remaining_tx_unemp_wages * tx_unemp) + + def test_2018_taxes_with_external(self): + salary = 5000.0 + external_wages = 6000.0 + + employee = self._createEmployee() + employee.company_id.tx_unemp_rate_2018 = 2.7 + employee.company_id.tx_oa_rate_2018 = 0.0 + employee.company_id.tx_etia_rate_2018 = 0.1 + + contract = self._createContract(employee, salary, external_wages=external_wages, + struct_id=self.ref('l10n_us_tx_hr_payroll.hr_payroll_salary_structure_us_tx_employee')) + + # tax rates + tx_unemp = contract.tx_unemp_rate(2018) / -100.0 + tx_oa = contract.tx_oa_rate(2018) / -100.00 + tx_etia = contract.tx_etia_rate(2018) / -100.00 + + self._log('2018 Texas_external tax first payslip:') + payslip = self._createPayslip(employee, '2018-01-01', '2018-01-31') + + payslip.compute_sheet() + + cats = self._getCategories(payslip) + + self.assertPayrollEqual(cats['TX_UNEMP_WAGES'], self.TX_UNEMP_MAX_WAGE - external_wages) + self.assertPayrollEqual(cats['TX_UNEMP'], cats['TX_UNEMP_WAGES'] * tx_unemp) + self.assertPayrollEqual(cats['TX_OA'], cats['TX_UNEMP_WAGES'] * tx_oa) + self.assertPayrollEqual(cats['TX_ETIA'], cats['TX_UNEMP_WAGES'] * tx_etia) + + def test_2018_taxes_with_state_exempt(self): + salary = 5000.0 + external_wages = 6000.0 + + employee = self._createEmployee() + employee.company_id.tx_unemp_rate_2018 = 2.7 + employee.company_id.tx_oa_rate_2018 = 0.0 + employee.company_id.tx_etia_rate_2018 = 0.1 + + contract = self._createContract(employee, salary, external_wages=external_wages, struct_id=self.ref( + 'l10n_us_tx_hr_payroll.hr_payroll_salary_structure_us_tx_employee'), futa_type=USHrContract.FUTA_TYPE_BASIC) + + # tax rates + tx_unemp = contract.tx_unemp_rate(2018) / -100.0 + tx_oa = contract.tx_oa_rate(2018) / -100.00 + tx_etia = contract.tx_etia_rate(2018) / -100.00 + + self.assertPayrollEqual(tx_unemp, 0.0) + + self._log('2018 Texas_external tax first payslip:') + payslip = self._createPayslip(employee, '2018-01-01', '2018-01-31') + + payslip.compute_sheet() + + cats = self._getCategories(payslip) + + self.assertPayrollEqual(cats['TX_UNEMP_WAGES'], self.TX_UNEMP_MAX_WAGE - external_wages) + self.assertPayrollEqual(cats['TX_UNEMP'], cats['TX_UNEMP_WAGES'] * tx_unemp) + self.assertPayrollEqual(cats['TX_OA'], cats['TX_UNEMP_WAGES'] * tx_oa) + self.assertPayrollEqual(cats['TX_ETIA'], cats['TX_UNEMP_WAGES'] * tx_etia) + + def test_payslip_example(self): + salary = 2916.67 + + employee = self._createEmployee() + employee.company_id.tx_unemp_rate_2018 = 2.7 + employee.company_id.tx_oa_rate_2018 = 0.0 + employee.company_id.tx_etia_rate_2018 = 0.1 + + contract = self._createContract(employee, salary, struct_id=self.ref( + 'l10n_us_tx_hr_payroll.hr_payroll_salary_structure_us_tx_employee')) + contract.w4_allowances = 2 + contract.w4_filing_status = 'single' + + # tax rates + tx_unemp = contract.tx_unemp_rate(2018) / -100.0 + tx_oa = contract.tx_oa_rate(2018) / -100.00 + tx_etia = contract.tx_etia_rate(2018) / -100.00 + + self._log('2018 Texas tax first payslip:') + payslip = self._createPayslip(employee, '2018-01-01', '2018-01-31') + + payslip.compute_sheet() + + cats = self._getCategories(payslip) + + self.assertPayrollEqual(cats['TX_UNEMP_WAGES'], salary) + self.assertPayrollEqual(cats['TX_UNEMP'], cats['TX_UNEMP_WAGES'] * tx_unemp) + self.assertPayrollEqual(cats['TX_OA'], cats['TX_UNEMP_WAGES'] * tx_oa) + self.assertPayrollEqual(cats['TX_ETIA'], cats['TX_UNEMP_WAGES'] * tx_etia) + + process_payslip(payslip) + + # Make a new payslip, this one will have maximums + + remaining_tx_unemp_wages = self.TX_UNEMP_MAX_WAGE - salary if (self.TX_UNEMP_MAX_WAGE - 2 * salary < salary) \ + else salary + + self._log('2018 Texas tax second payslip:') + payslip = self._createPayslip(employee, '2018-02-01', '2018-02-28') + + payslip.compute_sheet() + + cats = self._getCategories(payslip) + + self.assertPayrollEqual(cats['TX_UNEMP_WAGES'], remaining_tx_unemp_wages) + self.assertPayrollEqual(cats['TX_UNEMP'], remaining_tx_unemp_wages * tx_unemp) + diff --git a/l10n_us_tx_hr_payroll/us_tx_hr_payroll.py b/l10n_us_tx_hr_payroll/us_tx_hr_payroll.py new file mode 100755 index 00000000..e27219c0 --- /dev/null +++ b/l10n_us_tx_hr_payroll/us_tx_hr_payroll.py @@ -0,0 +1,44 @@ +from odoo import models, fields, api + + +class USTXHrContract(models.Model): + _inherit = 'hr.contract' + + @api.multi + def tx_unemp_rate(self, year): + self.ensure_one() + if self.futa_type == self.FUTA_TYPE_BASIC: + return 0.0 + + if hasattr(self.employee_id.company_id, 'tx_unemp_rate_' + str(year)): + return self.employee_id.company_id['tx_unemp_rate_' + str(year)] + + raise NotImplemented('Year (' + str(year) + ') Not implemented for US Texas.') + + def tx_oa_rate(self, year): + self.ensure_one() + if self.futa_type == self.FUTA_TYPE_BASIC: + return 0.0 + + if hasattr(self.employee_id.company_id, 'tx_oa_rate_' + str(year)): + return self.employee_id.company_id['tx_oa_rate_' + str(year)] + + raise NotImplemented('Year (' + str(year) + ') Not implemented for US Texas.') + + def tx_etia_rate(self, year): + self.ensure_one() + if self.futa_type == self.FUTA_TYPE_BASIC: + return 0.0 + + if hasattr(self.employee_id.company_id, 'tx_etia_rate_' + str(year)): + return self.employee_id.company_id['tx_etia_rate_' + str(year)] + + raise NotImplemented('Year (' + str(year) + ') Not implemented for US Texas.') + + +class TXCompany(models.Model): + _inherit = 'res.company' + + tx_unemp_rate_2018 = fields.Float(string="Texas Unemployment Rate 2018", default=2.7) + tx_oa_rate_2018 = fields.Float(strimg="Texas Obligation Assessment Rate 2018", default=0.0) + tx_etia_rate_2018 = fields.Float(string="Texas Employment & Training Investment Assessment Rate", default=0.1) diff --git a/l10n_us_tx_hr_payroll/us_tx_hr_payroll_view.xml b/l10n_us_tx_hr_payroll/us_tx_hr_payroll_view.xml new file mode 100755 index 00000000..6481f458 --- /dev/null +++ b/l10n_us_tx_hr_payroll/us_tx_hr_payroll_view.xml @@ -0,0 +1,35 @@ + + + + + res.company.form + res.company + 20 + + + + + + + + + + + + + hr.contract.form.inherit + hr.contract + 110 + + + + + +

No additional fields.

+
+
+
+
+
+
+
From 82f79e21ddda8a925f5b599dbc718771cac255ac Mon Sep 17 00:00:00 2001 From: Kristen Marie Kulha Date: Tue, 22 May 2018 13:31:06 -0700 Subject: [PATCH 04/55] Initial commit of `l10n_us_nj_hr_payroll` for 11.0. --- l10n_us_nj_hr_payroll/__init__.py | 1 + l10n_us_nj_hr_payroll/__manifest__.py | 31 + l10n_us_nj_hr_payroll/data/base.xml | 155 ++++ l10n_us_nj_hr_payroll/data/final.xml | 27 + l10n_us_nj_hr_payroll/data/rules_2018.xml | 697 ++++++++++++++++++ l10n_us_nj_hr_payroll/tests/__init__.py | 1 + .../tests/test_us_nj_payslip_2018.py | 135 ++++ l10n_us_nj_hr_payroll/us_nj_hr_payroll.py | 92 +++ .../us_nj_hr_payroll_view.xml | 40 + 9 files changed, 1179 insertions(+) create mode 100755 l10n_us_nj_hr_payroll/__init__.py create mode 100755 l10n_us_nj_hr_payroll/__manifest__.py create mode 100755 l10n_us_nj_hr_payroll/data/base.xml create mode 100755 l10n_us_nj_hr_payroll/data/final.xml create mode 100755 l10n_us_nj_hr_payroll/data/rules_2018.xml create mode 100755 l10n_us_nj_hr_payroll/tests/__init__.py create mode 100755 l10n_us_nj_hr_payroll/tests/test_us_nj_payslip_2018.py create mode 100755 l10n_us_nj_hr_payroll/us_nj_hr_payroll.py create mode 100755 l10n_us_nj_hr_payroll/us_nj_hr_payroll_view.xml diff --git a/l10n_us_nj_hr_payroll/__init__.py b/l10n_us_nj_hr_payroll/__init__.py new file mode 100755 index 00000000..48629d95 --- /dev/null +++ b/l10n_us_nj_hr_payroll/__init__.py @@ -0,0 +1 @@ +from . import us_nj_hr_payroll diff --git a/l10n_us_nj_hr_payroll/__manifest__.py b/l10n_us_nj_hr_payroll/__manifest__.py new file mode 100755 index 00000000..6b34c3aa --- /dev/null +++ b/l10n_us_nj_hr_payroll/__manifest__.py @@ -0,0 +1,31 @@ +{ + 'name': 'USA - New Jersey - Payroll', + 'author': 'Hibou Corp. ', + 'license': 'AGPL-3', + 'category': 'Localization', + 'depends': ['l10n_us_hr_payroll'], + 'version': '11.0.2018.0.0', + 'description': """ +USA::New Jersey Payroll Rules. +============================== + + * New Jersey Division of Taxation partner + * Contribution register for New Jersey DoT + * Company level New Jersey Unemployment Rate + * Company level New Jersey State Disability Insurance Rate + * Contract level New Jersey Unemployment Rate + * Contract level New Jersey State Disability Insurance Rate + * Contract level New Jersey Family Leave Insurance Rate + * Contract level New Jersey Workforce Development/Supplemental Workforce Funds + """, + + 'auto_install': False, + 'website': 'https://hibou.io/', + 'data':[ + 'us_nj_hr_payroll_view.xml', + 'data/base.xml', + 'data/rules_2018.xml', + 'data/final.xml', + ], + 'installable': True +} diff --git a/l10n_us_nj_hr_payroll/data/base.xml b/l10n_us_nj_hr_payroll/data/base.xml new file mode 100755 index 00000000..5db83ed6 --- /dev/null +++ b/l10n_us_nj_hr_payroll/data/base.xml @@ -0,0 +1,155 @@ + + + + + + + New Jersey Division of Taxation - Unemployment Insurance Tax(Employee) + 1 + + + + + New Jersey Division of Taxation - Unemployment Insurance Tax(Employer) + 1 + + + + + New Jersey Division of Taxation - State Disability Insurance Tax(Employee) + 1 + + + + + New Jersey Division of Taxation - State Disability Insurance Tax(Employer) + 1 + + + + + New Jersey Division of Taxation - Family Leave Insurance Tax + 1 + + + + + New Jersey Division of Taxation - Workforce Development/Supplemental Workforce Funds Tax + 1 + + + + + New Jersey Division of Taxation - Income Tax Withholding + 1 + + + + + New Jersey Unemployment Insurance Tax + New Jersey Division of Taxation - Unemployment Insurance Tax(Employee) + + + + + New Jersey Unemployment Insurance Tax + New Jersey Division of Taxation - Unemployment Insurance Tax(Employer) + + + + + New Jersey State Disability Insurance + New Jersey Division of Taxation - State Disability Insurance Tax(Employee) + + + + + New Jersey State Disability Insurance + New Jersey Division of Taxation - State Disability Insurance Tax(Employer) + + + + + New Jersey State Family Leave Insurance + New Jersey Division of Taxation - Family Leave Insurance Tax + + + + + New Jersey State Workforce Development/Supplemental Workforce Funds + New Jersey Division of Taxation - Workforce Development/Supplemental Workforce Funds Tax + + + + + New Jersey Income Tax Withholding + New Jersey Division of Taxation - Income Tax Withholding + + + + + + + New Jersey Unemployment Insurance Tax - Wages + NJ_UNEMP_WAGES + + + + New Jersey State Disability Insurance Tax - Wages + NJ_SDI_WAGES + + + + New Jersey Family Leave Insurance Tax - Wages + NJ_FLI_WAGES + + + + New Jersey Work Force Development - Wages + NJ_WF_WAGES + + + + New Jersey Unemployment Insurance Tax - Employee + NJ_UNEMP_EMPLOYEE + + + + + New Jersey Unemployment Insurance Tax - Employer + NJ_UNEMP_COMPANY + + + + + New Jersey State Disability Insurance - Employer + NJ_SDI_COMPANY + + + + + New Jersey State Disability Insurance - Employee + NJ_SDI_EMPLOYEE + + + + + New Jersey State Family Leave Insurance Tax + NJ_FLI + + + + + New Jersey State Workforce Development/Suppllemental Tax + NJ_WF + + + + + New Jersey Income Withholding + NJ_WITHHOLD + + + + + diff --git a/l10n_us_nj_hr_payroll/data/final.xml b/l10n_us_nj_hr_payroll/data/final.xml new file mode 100755 index 00000000..568bd523 --- /dev/null +++ b/l10n_us_nj_hr_payroll/data/final.xml @@ -0,0 +1,27 @@ + + + + + + + US_NJ_EMP + USA New Jersey Employee + + + + + + + diff --git a/l10n_us_nj_hr_payroll/data/rules_2018.xml b/l10n_us_nj_hr_payroll/data/rules_2018.xml new file mode 100755 index 00000000..0641e08f --- /dev/null +++ b/l10n_us_nj_hr_payroll/data/rules_2018.xml @@ -0,0 +1,697 @@ + + + + + + + + + + New Jersey Unemployment Insurance Tax - Wages (2018) + NJ_UNEMP_WAGES_2018 + python + result = (payslip.date_to[:4] == '2018') + code + +### +ytd = payslip.sum('NJ_UNEMP_WAGES_2018', '2018-01-01', '2019-01-01') +ytd += contract.external_wages +remaining = 33700.0 - ytd +if remaining <= 0.0: + result = 0 +elif remaining < categories.GROSS: + result = remaining +else: + result = categories.GROSS + + + + + + + New Jersey Unemployment - Employee(2018) + NJ_UNEMP_EMPLOYEE_2018 + python + result = (payslip.date_to[:4] == '2018') + code + +result_rate = -contract.nj_unemp_employee_rate(2018) +result = categories.NJ_UNEMP_WAGES + +# result_rate of 0 implies 100% due to bug +if result_rate == 0.0: + result = 0.0 + + + + + + + + New Jersey Unemployment - Employer(2018) + NJ_UNEMP_COMPANY_2018 + python + result = (payslip.date_to[:4] == '2018') + code + +result_rate = -contract.nj_unemp_company_rate(2018) +result = categories.NJ_UNEMP_WAGES + +# result_rate of 0 implies 100% due to bug +if result_rate == 0.0: + result = 0.0 + + + + + + + + + + + New Jersey State Disability Tax - Wages (2018) + NJ_SDI_WAGES_2018 + python + result = (payslip.date_to[:4] == '2018') + code + +### +ytd = payslip.sum('NJ_SDI_WAGES_2018', '2018-01-01', '2019-01-01') +ytd += contract.external_wages +remaining = 33700.0 - ytd +if remaining <= 0.0: + result = 0 +elif remaining < categories.GROSS: + result = remaining +else: + result = categories.GROSS + + + + + + + New Jersey State Disability Insurance - Employee(2018) + NJ_SDI_EMPLOYEE_2018 + python + result = (payslip.date_to[:4] == '2018') + code + +result_rate = -contract.nj_sdi_employee_rate(2018) +result = categories.NJ_SDI_WAGES + +# result_rate of 0 implies 100% due to bug +if result_rate == 0.0: + result = 0.0 + + + + + + + + New Jersey State Disability Insurance - Employer(2018) + NJ_SDI_COMPANY_2018 + python + result = (payslip.date_to[:4] == '2018') + code + +result_rate = -contract.nj_sdi_company_rate(2018) +result = categories.NJ_SDI_WAGES + +# result_rate of 0 implies 100% due to bug +if result_rate == 0.0: + result = 0.0 + + + + + + + + + + New Jersey Family Leave Insurance Tax - Wages (2018) + NJ_FLI_WAGES_2018 + python + result = (payslip.date_to[:4] == '2018') + code + +### +ytd = payslip.sum('NJ_FLI_WAGES_2018', '2018-01-01', '2019-01-01') +ytd += contract.external_wages +remaining = 33700.0 - ytd +if remaining <= 0.0: + result = 0 +elif remaining < categories.GROSS: + result = remaining +else: + result = categories.GROSS + + + + + + + New Jersey Family Leave Insurance(2018) + NJ_FLI_2018 + python + result = (payslip.date_to[:4] == '2018') + code + +result_rate = -contract.nj_fli_rate(2018) +result = categories.NJ_FLI_WAGES + + + + + + + + + + New Jersey Workforce Development/Supplemental Workforce Funds Tax - Wages (2018) + NJ_WF_WAGES_2018 + python + result = (payslip.date_to[:4] == '2018') + code + +### +ytd = payslip.sum('NJ_WF_WAGES_2018', '2018-01-01', '2019-01-01') +ytd += contract.external_wages +remaining = 33700.0 - ytd +if remaining <= 0.0: + result = 0 +elif remaining < categories.GROSS: + result = remaining +else: + result = categories.GROSS + + + + + + + New Jersey Workforce Development/Supplemental Workforce Funds(2018) + NJ_WF_2018 + python + result = (payslip.date_to[:4] == '2018') + code + +result_rate = -contract.nj_wf_rate(2018) +result = categories.NJ_WF_WAGES + + + + + + + + + + New Jersey Income Withholding + NJ_INC_WITHHOLD_2018 + python + result = (payslip.date_to[:4] == '2018') + code + +wages = categories.GROSS +allowances = contract.nj_njw4_allowances +additional_withholding = contract.nj_additional_withholding +schedule_pay = contract.schedule_pay +filing_status = contract.nj_njw4_filing_status +rate_table = contract.nj_njw4_rate_table + +# Determine which rate table to use +if not rate_table: + if filing_status == 'single' or filing_status == 'married_separate': + rate_table = 'A' + elif filing_status == 'married_joint' or filing_status == 'widower' or filing_status == 'head_household': + rate_table = 'B' + +# Tables are found in http://www.state.nj.us/treasury/taxation/pdf/current/njwt.pdf +# Tax Rate Tables to calculate income withholding + +#### RATE 'A' #### +if rate_table == 'A': + ### WEEKLY ### + if schedule_pay == 'weekly': + wages -= 19.20 * allowances + tax_rate_table = [ + (384, 0.015, 0.0), + (673, 0.02, 5.76), + (769, 0.039, 11.54), + (1442, 0.061, 15.28), + (9615, 0.07, 56.34), + (float('inf'), 0.099, 628.45), + ] + + ### BI-WEEKLY ### + elif schedule_pay == 'bi-weekly': + wages -= 38.40 * allowances + tax_rate_table = [ + (769, 0.015, 0.0), + (1346, 0.02, 11.54), + (1538, 0.039, 23.08), + (2884, 0.061, 30.56), + (19231, 0.07, 112.67), + (float('inf'), 0.099, 1256.96), + ] + + ### SEMI-MONTHLY ### + elif schedule_pay == 'semi-monthly': + wages -= 41.60 * allowances + tax_rate_table = [ + (833, 0.015, 0.0), + (1458, 0.02, 12.50), + (1666, 0.039, 25.00), + (3125, 0.061, 33.11), + (20833, 0.07, 112.11), + (float('inf'), 0.099, 1361.67), + ] + + ### MONTHLY ### + elif schedule_pay == 'monthly': + wages -= 83.30 * allowances + tax_rate_table = [ + (1666, 0.015, 0.0), + (2916, 0.02, 24.99), + (3333, 0.039, 49.99), + (6250, 0.061, 66.25), + (41667, 0.07, 244.19), + (float('inf'), 0.099, 2723.38), + ] + + ### QUARTERLY ### + elif schedule_pay == 'quarterly': + wages -= 250.00 * allowances + tax_rate_table = [ + (5000, 0.015, 0.0), + (8750, 0.02, 75.00), + (10000, 0.039, 150.00), + (18750, 0.061, 198.75), + (125000, 0.07, 732.50), + (float('inf'), 0.099, 8170.00), + ] + + ### SEMI-ANNUAL ### + elif schedule_pay == 'semi-annual': + wages -= 500 * allowances + tax_rate_table = [ + (10000, 0.015, 0.0), + (17500, 0.02, 150.00), + (20000, 0.039, 300.00), + (37500, 0.061, 397.50), + (250000, 0.07, 1465.00), + (float('inf'), 0.099, 16340.00), + ] + + ### ANNUAL ### + elif schedule_pay == 'annually': + wages -= 1000.00 * allowances + tax_rate_table = [ + (20000, 0.015, 0.0), + (35000, 0.02, 300.00), + (40000, 0.039, 600.00), + (75000, 0.061, 795.00), + (500000, 0.07, 2930.00), + (float('inf'), 0.099, 32680.00), + ] + +#### RATE 'B' #### +elif rate_table == 'B': + ### WEEKLY ### + if schedule_pay == 'weekly': + wages -= 19.20 * allowances + tax_rate_table = [ + (384, 0.015, 0.0), + (961, 0.02, 5.76), + (1346, 0.027, 17.30), + (1538, 0.039, 27.70), + (2884, 0.061, 35.18), + (9615, 0.07, 117.29), + (float('inf'), 0.099, 588.46), + ] + + ### BI-WEEKLY ### + elif schedule_pay == 'bi-weekly': + wages -= 38.40 * allowances + tax_rate_table = [ + (769, 0.015, 0.0), + (1923, 0.02, 11.54), + (2692, 0.027, 34.62), + (3076, 0.039, 55.38), + (5769, 0.061, 70.35), + (19231, 0.07, 234.63), + (float('inf'), 0.099, 1176.97), + ] + + ### SEMI-MONTHLY ### + elif schedule_pay == 'semi-monthly': + wages -= 41.60 * allowances + tax_rate_table = [ + (833, 0.015, 0.0), + (2083, 0.02, 12.50), + (2916, 0.027, 37.50), + (3333, 0.039, 59.99), + (6250, 0.061, 76.25), + (20833, 0.07, 254.19), + (float('inf'), 0.099, 1275), + ] + + ### MONTHLY ### + elif schedule_pay == 'monthly': + wages -= 83.30 * allowances + tax_rate_table = [ + (1666, 0.015, 0.0), + (4166, 0.02, 24.99), + (5833, 0.027, 74.99), + (6666, 0.039, 120.00), + (12500, 0.061, 152.49), + (41667, 0.07, 508.36), + (float('inf'), 0.099, 2550.05), + ] + + ### QUARTERLY ### + elif schedule_pay == 'quarterly': + wages -= 250.00 * allowances + tax_rate_table = [ + (5000, 0.015, 0.0), + (12500, 0.02, 75.00), + (17500, 0.027, 225.00), + (20000, 0.039, 360.00), + (37500, 0.061, 457.50), + (125000, 0.07, 1525.00), + (float('inf'), 0.099, 7650.00), + ] + + ### SEMI-ANNUAL ### + elif schedule_pay == 'semi-annual': + wages -= 500.00 * allowances + tax_rate_table = [ + (10000, 0.015, 0.0), + (25000, 0.02, 150.00), + (35000, 0.027, 450.00), + (40000, 0.039, 720.00), + (75000, 0.061, 915.00), + (250000, 0.07, 3050.00), + (float('inf'), 0.099, 15300.00), + ] + + ### ANNUAL ### + elif schedule_pay == 'annually': + wages -= 1000.00 * allowances + tax_rate_table = [ + (20000, 0.015, 0.0), + (50000, 0.02, 300.00), + (70000, 0.027, 900.00), + (80000, 0.039, 1440.00), + (150000, 0.061, 1830.00), + (500000, 0.07, 6100.00), + (float('inf'), 0.099, 30600.00), + ] + +#### RATE 'C' #### +elif rate_table == 'C': + ### WEEKLY ### + if schedule_pay == 'weekly': + wages -= 19.20 * allowances + tax_rate_table = [ + (384, 0.015, 0.0), + (769, 0.023, 5.76), + (961, 0.028, 14.62), + (1153, 0.035, 19.99), + (2884, 0.056, 26.71), + (9615, 0.066, 123.65), + (float('inf'), 0.099, 567.90), + ] + + ### BI-WEEKLY ### + elif schedule_pay == 'bi-weekly': + wages -= 38.40 * allowances + tax_rate_table = [ + (769, 0.015, 0.0), + (1538, 0.023, 11.54), + (1923, 0.028, 29.22), + (2307, 0.035, 40.00), + (5769, 0.056, 53.44), + (19231, 0.066, 247.31), + (float('inf'), 0.099, 1135.80), + ] + + ### SEMI-MONTHLY ### + elif schedule_pay == 'semi-monthly': + wages -= 41.60 * allowances + tax_rate_table = [ + (833, 0.015, 0.0), + (1666, 0.023, 12.50), + (2083, 0.028, 31.65), + (2500, 0.035, 43.33), + (6250, 0.056, 57.93), + (20833, 0.066, 26793), + (float('inf'), 0.099, 1230.41), + ] + + ### MONTHLY ### + elif schedule_pay == 'monthly': + wages -= 83.30 * allowances + tax_rate_table = [ + (1666, 0.015, 0.0), + (3333, 0.023, 24.99), + (4166, 0.028, 63.33), + (5000, 0.035, 86.66), + (12500, 0.056, 115.85), + (41667, 0.066, 535.85), + (float('inf'), 0.099, 2460.87), + ] + + ### QUARTERLY ### + elif schedule_pay == 'quarterly': + wages -= 250.00 * allowances + tax_rate_table = [ + (5000, 0.015, 0.0), + (10000, 0.023, 75.00), + (12500, 0.028, 190.00), + (15000, 0.035, 260.00), + (37500, 0.056, 347.50), + (125000, 0.066, 1607.50), + (float('inf'), 0.099, 7382.50), + ] + + ### SEMI-ANNUAL ### + elif schedule_pay == 'semi-annual': + wages -= 500.00 * allowances + tax_rate_table = [ + (10000, 0.015, 0.0), + (20000, 0.023, 150.00), + (25000, 0.028, 380.00), + (30000, 0.035, 520.00), + (75000, 0.056, 695.00), + (250000, 0.066, 3215.00), + (float('inf'), 0.099, 14765.00), + ] + + ### ANNUAL ### + elif schedule_pay == 'annually': + wages -= 1000.00 * allowances + tax_rate_table = [ + (20000, 0.015, 0.0), + (40000, 0.023, 300.00), + (50000, 0.028, 760.00), + (60000, 0.035, 1040.00), + (150000, 0.056, 1390.00), + (500000, 0.066, 6430.00), + (float('inf'), 0.099, 29530.00), + ] + +#### RATE 'D' #### +elif rate_table == 'D': + ### WEEKLY ### + if schedule_pay == 'weekly': + wages -= 19.20 * allowances + tax_rate_table = [ + (384, 0.015, 0.0), + (769, 0.027, 5.76), + (961, 0.034, 16.16), + (1153, 0.043, 22.68), + (2884, 0.056, 30.94), + (9615, 0.065, 127.88), + (float('inf'), 0.099, 565.40), + ] + + ### BI-WEEKLY ### + elif schedule_pay == 'bi-weekly': + wages -= 38.40 * allowances + tax_rate_table = [ + (769, 0.015, 0.0), + (1538, 0.027, 11.54), + (1923, 0.034, 32.30), + (2307, 0.043, 45.39), + (5769, 0.056, 61.90), + (19231, 0.065, 255.77), + (float('inf'), 0.099, 1130.80), + ] + + ### SEMI-MONTHLY ### + elif schedule_pay == 'semi-monthly': + wages -= 41.60 * allowances + tax_rate_table = [ + (833, 0.015, 0.0), + (1666, 0.027, 12.50), + (2083, 0.034, 34.99), + (2500, 0.043, 49.16), + (6250, 0.056, 67.10), + (20833, 0.065, 277.10), + (float('inf'), 0.099, 1225.00), + ] + + ### MONTHLY ### + elif schedule_pay == 'monthly': + wages -= 83.30 * allowances + tax_rate_table = [ + (1666, 0.015, 0.0), + (3333, 0.027, 24.99), + (4166, 0.034, 70.00), + (5000, 0.043, 98.32), + (12500, 0.056, 134.18), + (41667, 0.065, 554.18), + (float('inf'), 0.099, 2450.04), + ] + + ### QUARTERLY ### + elif schedule_pay == 'quarterly': + wages -= 250.00 * allowances + tax_rate_table = [ + (5000, 0.015, 0.0), + (10000, 0.027, 75.00), + (12500, 0.034, 210.00), + (15000, 0.043, 295.00), + (37500, 0.056, 402.50), + (125000, 0.065, 1662.50), + (float('inf'), 0.099, 7350.00), + ] + + ### SEMI-ANNUAL ### + elif schedule_pay == 'semi-annual': + wages -= 500.00 * allowances + tax_rate_table = [ + (10000, 0.015, 0.0), + (20000, 0.027, 150.00), + (25000, 0.034, 420.00), + (30000, 0.043, 590.00), + (75000, 0.056, 805.00), + (250000, 0.065, 3325.00), + (float('inf'), 0.099, 14700.00), + ] + + ### ANNUAL ### + elif schedule_pay == 'annually': + wages -= 1000.00 * allowances + tax_rate_table = [ + (20000, 0.015, 0.0), + (40000, 0.027, 300.00), + (50000, 0.034, 840.00), + (60000, 0.043, 1180.00), + (150000, 0.056, 1610.00), + (250000, 0.065, 6650.00), + (float('inf'), 0.099, 29400.00), + ] + +#### RATE 'E' #### +elif rate_table == 'E': + ### WEEKLY ### + if schedule_pay == 'weekly': + wages -= 19.20 * allowances + tax_rate_table = [ + (384, 0.015, 0.0), + (673, 0.02, 5.76), + (1923, 0.058, 11.54), + (9615, 0.065, 84.04), + (float('inf'), 0.099, 584.02), + ] + + ### BI-WEEKLY ### + elif schedule_pay == 'bi-weekly': + wages -= 38.40 * allowances + tax_rate_table = [ + (769, 0.015, 0.0), + (1346, 0.02, 11.54), + (3846, 0.058, 23.08), + (19231, 0.065, 168.08), + (float('inf'), 0.099, 1168.11), + ] + + ### SEMI-MONTHLY ### + elif schedule_pay == 'semi-monthly': + wages -= 41.60 * allowances + tax_rate_table = [ + (833, 0.015, 0.0), + (1458, 0.02, 12.50), + (4166, 0.058, 25.00), + (20833, 0.065, 182.06), + (float('inf'), 0.099, 1265.42), + ] + + ### MONTHLY ### + elif schedule_pay == 'monthly': + wages -= 83.30 * allowances + tax_rate_table = [ + (1666, 0.015, 0.0), + (2916, 0.02, 24.99), + (8333, 0.058, 49.99), + (41667, 0.065, 364.18), + (float('inf'), 0.099, 2530.89), + ] + + ### QUARTERLY ### + elif schedule_pay == 'quarterly': + wages -= 250.00 * allowances + tax_rate_table = [ + (5000, 0.015, 0.0), + (8750, 0.02, 75.00), + (25000, 0.058, 150.00), + (125000, 0.065, 1092.50), + (float('inf'), 0.099, 7592.50), + ] + + ### SEMI-ANNUAL ### + elif schedule_pay == 'semi-annual': + wages -= 500.00 * allowances + tax_rate_table = [ + (10000, 0.015, 0.0), + (17500, 0.02, 150.00), + (50000, 0.058, 300.00), + (250000, 0.065, 2185.00), + (float('inf'), 0.099, 15,185.00), + ] + + ### ANNUAL ### + elif schedule_pay == 'annually': + wages -= 1000.00 * allowances + tax_rate_table = [ + (20000, 0.015, 0.0), + (35000, 0.02, 300.00), + (100000, 0.058, 600.00), + (500000, 0.065, 4370.00), + (float('inf'), 0.099, 30370.00), + ] + + +over = 0.0 +tax = 0.0 +for row in tax_rate_table: + if wages <= row[0]: + tax = ((wages - over) * row[1]) + row[2] + tax += additional_withholding + break + over = row[0] + +result = -tax + + + + + + + diff --git a/l10n_us_nj_hr_payroll/tests/__init__.py b/l10n_us_nj_hr_payroll/tests/__init__.py new file mode 100755 index 00000000..02adeeae --- /dev/null +++ b/l10n_us_nj_hr_payroll/tests/__init__.py @@ -0,0 +1 @@ +from . import test_us_nj_payslip_2018 diff --git a/l10n_us_nj_hr_payroll/tests/test_us_nj_payslip_2018.py b/l10n_us_nj_hr_payroll/tests/test_us_nj_payslip_2018.py new file mode 100755 index 00000000..eb9f4e61 --- /dev/null +++ b/l10n_us_nj_hr_payroll/tests/test_us_nj_payslip_2018.py @@ -0,0 +1,135 @@ +from odoo.addons.l10n_us_hr_payroll.tests.test_us_payslip import TestUsPayslip, process_payslip +from odoo.addons.l10n_us_hr_payroll.l10n_us_hr_payroll import USHrContract + + +class TestUsNJPayslip(TestUsPayslip): + ### + # 2018 Taxes and Rates + ### + NJ_UNEMP_MAX_WAGE = 33700.0 + + # Examples found on page 24 of http://www.state.nj.us/treasury/taxation/pdf/current/njwt.pdf + def test_2018_taxes_example1(self): + salary = 300 + schedule_pay = 'weekly' + allowances = 1 + additional_withholding = 0 + + # Tax Percentage Method for Single, taxable pay over $58, under $346 + wh = -4.21 + + employee = self._createEmployee() + employee.company_id.nj_unemp_employee = 0.3825 + employee.company_id.nj_unemp_company = 3.4 + employee.company_id.nj_sdi_employee = 0.19 + employee.company_id.nj_sdi_company = 0.5 + employee.company_id.nj_fli = 0.09 + employee.company_id.nj_wf = 0.0 + + contract = self._createContract(employee, + salary, + struct_id=self.ref( + 'l10n_us_nj_hr_payroll.hr_payroll_salary_structure_us_nj_employee'), + schedule_pay=schedule_pay) + contract.nj_njw4_allowances = allowances + contract.nj_additional_withholding = additional_withholding + contract.nj_njw4_filing_status = 'single' + contract.nj_njw4_rate_table = 'A' + + # tax rates + nj_unemp_employee = contract.nj_unemp_employee_rate(2018) / -100.0 + nj_unemp_company = contract.nj_unemp_company_rate(2018) / -100.0 + nj_sdi_employee = contract.nj_sdi_employee_rate(2018) / -100.0 + nj_sdi_company = contract.nj_sdi_company_rate(2018) / -100.0 + nj_fli = contract.nj_fli_rate(2018) / -100.0 + nj_wf = contract.nj_wf_rate(2018) / -100.0 + + self.assertEqual(contract.schedule_pay, 'weekly') + + self._log('2018 New Jersey tax first payslip:') + payslip = self._createPayslip(employee, '2018-01-01', '2018-01-31') + + payslip.compute_sheet() + + cats = self._getCategories(payslip) + + self.assertPayrollEqual(cats['NJ_UNEMP_WAGES'], salary) + self.assertPayrollEqual(cats['NJ_UNEMP_EMPLOYEE'], round(cats['NJ_UNEMP_WAGES'] * nj_unemp_employee, 2)) + self.assertPayrollEqual(cats['NJ_UNEMP_COMPANY'], cats['NJ_UNEMP_WAGES'] * nj_unemp_company) + self.assertPayrollEqual(cats['NJ_SDI_EMPLOYEE'], cats['NJ_SDI_WAGES'] * nj_sdi_employee) + self.assertPayrollEqual(cats['NJ_SDI_COMPANY'], cats['NJ_SDI_WAGES'] * nj_sdi_company) + self.assertPayrollEqual(cats['NJ_FLI'], cats['NJ_FLI_WAGES'] * nj_fli) + self.assertPayrollEqual(cats['NJ_WF'], cats['NJ_WF_WAGES'] * nj_wf) + self.assertPayrollEqual(cats['NJ_WITHHOLD'], wh) + + process_payslip(payslip) + + # Make a new payslip, this one will have maximums + + remaining_nj_unemp_wages = self.NJ_UNEMP_MAX_WAGE - salary if (self.NJ_UNEMP_MAX_WAGE - 2 * salary < salary) \ + else salary + + self._log('2018 New Jersey tax second payslip:') + payslip = self._createPayslip(employee, '2018-02-01', '2018-02-28') + + payslip.compute_sheet() + + cats = self._getCategories(payslip) + + self.assertPayrollEqual(cats['NJ_UNEMP_WAGES'], remaining_nj_unemp_wages) + self.assertPayrollEqual(cats['NJ_UNEMP_COMPANY'], remaining_nj_unemp_wages * nj_unemp_company) + self.assertPayrollEqual(cats['NJ_UNEMP_EMPLOYEE'], remaining_nj_unemp_wages * nj_unemp_employee) + + def test_2018_taxes_example2(self): + salary = 1400.00 + schedule_pay = 'weekly' + allowances = 3 + additional_withholding = 0 + + # Tax Percentage Method for Single, taxable pay over $58, under $346 + wh = -27.60 + + employee = self._createEmployee() + employee.company_id.nj_unemp_employee = 0.3825 + employee.company_id.nj_unemp_company = 3.4 + employee.company_id.nj_sdi_employee = 0.19 + employee.company_id.nj_sdi_company = 0.5 + employee.company_id.nj_fli = 0.09 + employee.company_id.nj_wf = 0.0 + + contract = self._createContract(employee, + salary, + struct_id=self.ref( + 'l10n_us_nj_hr_payroll.hr_payroll_salary_structure_us_nj_employee'), + schedule_pay=schedule_pay) + contract.nj_njw4_allowances = allowances + contract.nj_additional_withholding = additional_withholding + contract.nj_njw4_filing_status = 'married_joint' + + # tax rates + nj_unemp_employee = contract.nj_unemp_employee_rate(2018) / -100.0 + nj_unemp_company = contract.nj_unemp_company_rate(2018) / -100.0 + nj_sdi_employee = contract.nj_sdi_employee_rate(2018) / -100.0 + nj_sdi_company = contract.nj_sdi_company_rate(2018) / -100.0 + nj_fli = contract.nj_fli_rate(2018) / -100.0 + nj_wf = contract.nj_wf_rate(2018) / -100.0 + + self.assertEqual(contract.schedule_pay, 'weekly') + + self._log('2018 New Jersey tax first payslip:') + payslip = self._createPayslip(employee, '2018-01-01', '2018-01-31') + + payslip.compute_sheet() + + cats = self._getCategories(payslip) + + self.assertPayrollEqual(cats['NJ_UNEMP_WAGES'], salary) + self.assertPayrollEqual(cats['NJ_UNEMP_EMPLOYEE'], round((cats['NJ_UNEMP_WAGES'] * nj_unemp_employee), 2)) + self.assertPayrollEqual(cats['NJ_UNEMP_COMPANY'], cats['NJ_UNEMP_WAGES'] * nj_unemp_company) + self.assertPayrollEqual(cats['NJ_SDI_EMPLOYEE'], cats['NJ_SDI_WAGES'] * nj_sdi_employee) + self.assertPayrollEqual(cats['NJ_SDI_COMPANY'], cats['NJ_SDI_WAGES'] * nj_sdi_company) + self.assertPayrollEqual(cats['NJ_FLI'], cats['NJ_FLI_WAGES'] * nj_fli) + self.assertPayrollEqual(cats['NJ_WF'], cats['NJ_WF_WAGES'] * nj_wf) + self.assertPayrollEqual(cats['NJ_WITHHOLD'], wh) + + process_payslip(payslip) diff --git a/l10n_us_nj_hr_payroll/us_nj_hr_payroll.py b/l10n_us_nj_hr_payroll/us_nj_hr_payroll.py new file mode 100755 index 00000000..567aa1f2 --- /dev/null +++ b/l10n_us_nj_hr_payroll/us_nj_hr_payroll.py @@ -0,0 +1,92 @@ +from odoo import models, fields, api + + +class USNJHrContract(models.Model): + _inherit = 'hr.contract' + + nj_njw4_allowances = fields.Integer(string="New Jersey NJ-W4 Allowances", + default=0, + help="Allowances claimed on NJ W-4") + nj_additional_withholding = fields.Float(string="Additional Withholding", + default=0.0, + help='Additional withholding from line 5 of form NJ-W4') + nj_njw4_filing_status = fields.Selection([ + ('single', 'Single'), + ('married_separate', 'Married/Civil Union partner Separate'), + ('married_joint', 'Married/Civil Union Couple Joint'), + ('widower', 'Widower/Surviving Civil Union Partner'), + ('head_household', 'Head of Household') + ], string='NJ Filing Status', default='single') + nj_njw4_rate_table = fields.Char(string='Wage Chart Letter', + help='Wage Chart Letter from line 3 of form NJ-W4.') + + def nj_unemp_employee_rate(self, year): + self.ensure_one() + if self.futa_type == self.FUTA_TYPE_BASIC: + return 0.0 + + if hasattr(self.employee_id.company_id, 'nj_unemp_employee_rate_' + str(year)): + return self.employee_id.company_id['nj_unemp_employee_rate_' + str(year)] + + raise NotImplemented('Year (' + str(year) + ') Not implemented for US New Jersey.') + + def nj_unemp_company_rate(self, year): + self.ensure_one() + if self.futa_type == self.FUTA_TYPE_BASIC: + return 0.0 + + if hasattr(self.employee_id.company_id, 'nj_unemp_company_rate_' + str(year)): + return self.employee_id.company_id['nj_unemp_company_rate_' + str(year)] + + raise NotImplemented('Year (' + str(year) + ') Not implemented for US New Jersey.') + + def nj_sdi_company_rate(self, year): + self.ensure_one() + if self.futa_type == self.FUTA_TYPE_BASIC: + return 0.0 + + if hasattr(self.employee_id.company_id, 'nj_sdi_company_rate_' + str(year)): + return self.employee_id.company_id['nj_sdi_company_rate_' + str(year)] + + raise NotImplemented('Year (' + str(year) + ') Not implemented for US New Jersey.') + + def nj_sdi_employee_rate(self, year): + self.ensure_one() + if self.futa_type == self.FUTA_TYPE_BASIC: + return 0.0 + + if hasattr(self.employee_id.company_id, 'nj_sdi_employee_rate_' + str(year)): + return self.employee_id.company_id['nj_sdi_employee_rate_' + str(year)] + + raise NotImplemented('Year (' + str(year) + ') Not implemented for US New Jersey.') + + def nj_fli_rate(self, year): + self.ensure_one() + if self.futa_type == self.FUTA_TYPE_BASIC: + return 0.0 + + if hasattr(self.employee_id.company_id, 'nj_fli_rate_' + str(year)): + return self.employee_id.company_id['nj_fli_rate_' + str(year)] + + raise NotImplemented('Year (' + str(year) + ') Not implemented for US New Jersey.') + + def nj_wf_rate(self, year): + self.ensure_one() + if self.futa_type == self.FUTA_TYPE_BASIC: + return 0.0 + + if hasattr(self.employee_id.company_id, 'nj_wf_rate_' + str(year)): + return self.employee_id.company_id['nj_wf_rate_' + str(year)] + + raise NotImplemented('Year (' + str(year) + ') Not implemented for US New Jersey.') + + +class NJCompany(models.Model): + _inherit = 'res.company' + + nj_unemp_company_rate_2018 = fields.Float(string="New Jersey Employer State Unemployment Insurance Rate 2018", default=3.4) + nj_unemp_employee_rate_2018 = fields.Float(string="New Jersey Employee State Unemployment Insurance Rate 2018", default=0.3825) + nj_sdi_company_rate_2018 = fields.Float(string="New Jersey Employer State Disability Insurance Rate 2018", default=0.5) + nj_sdi_employee_rate_2018 = fields.Float(string="New Jersey Employee State Disability Insurance Rate 2018", default=0.19) + nj_fli_rate_2018 = fields.Float(string="New Jersey Family Leave Insurance Rate 2018", default=0.09) + nj_wf_rate_2018 = fields.Float(string="New Jersey Workforce Development Rate 2018", default=0.0) diff --git a/l10n_us_nj_hr_payroll/us_nj_hr_payroll_view.xml b/l10n_us_nj_hr_payroll/us_nj_hr_payroll_view.xml new file mode 100755 index 00000000..e8bc9a4f --- /dev/null +++ b/l10n_us_nj_hr_payroll/us_nj_hr_payroll_view.xml @@ -0,0 +1,40 @@ + + + + + res.company.form + res.company + 20 + + + + + + + + + + + + + + + + + hr.contract.form.inherit + hr.contract + 147 + + + + + + + + + + + + + + From bd0a91f36ee5229359dd5c0485961a377cf9da16 Mon Sep 17 00:00:00 2001 From: Kristen Marie Kulha Date: Thu, 24 May 2018 13:54:02 -0700 Subject: [PATCH 05/55] Initial commit of `l10n_us_ny_hr_payroll` for 11.0. --- l10n_us_ny_hr_payroll/__init__.py | 1 + l10n_us_ny_hr_payroll/__manifest__.py | 29 ++ l10n_us_ny_hr_payroll/data/base.xml | 78 ++++ l10n_us_ny_hr_payroll/data/final.xml | 21 ++ l10n_us_ny_hr_payroll/data/rules_2018.xml | 350 ++++++++++++++++++ l10n_us_ny_hr_payroll/hr_payroll.py | 57 +++ l10n_us_ny_hr_payroll/hr_payroll_view.xml | 37 ++ l10n_us_ny_hr_payroll/tests/__init__.py | 1 + .../tests/test_us_ny_payslip_2018.py | 167 +++++++++ 9 files changed, 741 insertions(+) create mode 100755 l10n_us_ny_hr_payroll/__init__.py create mode 100755 l10n_us_ny_hr_payroll/__manifest__.py create mode 100755 l10n_us_ny_hr_payroll/data/base.xml create mode 100755 l10n_us_ny_hr_payroll/data/final.xml create mode 100755 l10n_us_ny_hr_payroll/data/rules_2018.xml create mode 100755 l10n_us_ny_hr_payroll/hr_payroll.py create mode 100755 l10n_us_ny_hr_payroll/hr_payroll_view.xml create mode 100755 l10n_us_ny_hr_payroll/tests/__init__.py create mode 100755 l10n_us_ny_hr_payroll/tests/test_us_ny_payslip_2018.py diff --git a/l10n_us_ny_hr_payroll/__init__.py b/l10n_us_ny_hr_payroll/__init__.py new file mode 100755 index 00000000..e99aa24a --- /dev/null +++ b/l10n_us_ny_hr_payroll/__init__.py @@ -0,0 +1 @@ +from . import hr_payroll diff --git a/l10n_us_ny_hr_payroll/__manifest__.py b/l10n_us_ny_hr_payroll/__manifest__.py new file mode 100755 index 00000000..79b2f871 --- /dev/null +++ b/l10n_us_ny_hr_payroll/__manifest__.py @@ -0,0 +1,29 @@ +{ + 'name': 'USA - New York - Payroll', + 'author': 'Hibou Corp. ', + 'license': 'AGPL-3', + 'category': 'Localization', + 'depends': ['l10n_us_hr_payroll'], + 'version': '11.0.2018.0.0', + 'description': """ +USA::New York Payroll Rules. +============================== + +* New Jersey Department of Taxation and Finance partner +* Contribution register and partner for New York State Department of Taxation and Finance +* Company level New York Unemployment Rate +* Company Level New York Re-employment Service Fund +* Company level New York Metropolitan Commuter Transportation Mobility Tax +* Contract level New York Income Tax + """, + + 'auto_install': False, + 'website': 'https://hibou.io/', + 'data': [ + 'hr_payroll_view.xml', + 'data/base.xml', + 'data/rules_2018.xml', + 'data/final.xml', + ], + 'installable': True +} diff --git a/l10n_us_ny_hr_payroll/data/base.xml b/l10n_us_ny_hr_payroll/data/base.xml new file mode 100755 index 00000000..ac14d74b --- /dev/null +++ b/l10n_us_ny_hr_payroll/data/base.xml @@ -0,0 +1,78 @@ + + + + + + New York State Department of Taxation and Finance - Unemployment Insurance Tax + 1 + + + + New York State Department of Taxation and Finance - Income Tax Withholding + 1 + + + + New York State Department of Taxation and Finance - Re-employment Service Fund + 1 + + + + New York State Department of Taxation and Finance - Metropolitan Commuter Transportation Mobility Tax + 1 + + + + + New York Unemployment Insurance Tax + New York State Department of Taxation and Finance - Unemployment Insurance Tax + + + + New York Income Tax Withholding + New York State Department of Taxation and Finance - Income Tax Withholding + + + + Re-employment Service Fund + New York State Department of Taxation and Finance - Re-employment Service Fund + + + + Metropolitan Commuter Transportation Mobility Tax + New York State Department of Taxation and Finance - Metropolitan Commuter Transportation Mobility Tax + + + + + + + New York Unemployment Insurance Tax - Wages + NY_UNEMP_WAGES + + + + New York Unemployment Insurance Tax + NY_UNEMP + + + + + New York Re-employment Service Fund + NY_RSF + + + + + New York Metropolitan Commuter Transportation Mobility Tax + NY_MCTMT + + + + + New York Income Withholding + NY_WITHHOLD + + + + \ No newline at end of file diff --git a/l10n_us_ny_hr_payroll/data/final.xml b/l10n_us_ny_hr_payroll/data/final.xml new file mode 100755 index 00000000..c0dd889f --- /dev/null +++ b/l10n_us_ny_hr_payroll/data/final.xml @@ -0,0 +1,21 @@ + + + + + + + US_NY_EMP + USA New York Employee + + + + + + + diff --git a/l10n_us_ny_hr_payroll/data/rules_2018.xml b/l10n_us_ny_hr_payroll/data/rules_2018.xml new file mode 100755 index 00000000..c10ea324 --- /dev/null +++ b/l10n_us_ny_hr_payroll/data/rules_2018.xml @@ -0,0 +1,350 @@ + + + + + + + + + + New York Unemployment Insurance Tax - Wages (2018) + NY_UNEMP_WAGES_2018 + python + result = (payslip.date_to[:4] == '2018') + code + +### +ytd = payslip.sum('NY_UNEMP_WAGES_2018', '2018-01-01', '2019-01-01') +ytd += contract.external_wages +remaining = 11100.00 - ytd +if remaining <= 0.0: + result = 0 +elif remaining < categories.GROSS: + result = remaining +else: + result = categories.GROSS + + + + + + + + New York Unemployment Insurance Tax(2018) + NY_UNEMP_2018 + python + result = (payslip.date_to[:4] == '2018') + code + +result_rate = -contract.ny_unemp_rate(2018) +result = categories.NY_UNEMP_WAGES + +# result_rate of 0 implies 100% due to bug +if result_rate == 0.0: + result = 0.0 + + + + + + + + + + New York Re-employment Service Fund(2018) + NY_RSF_2018 + python + result = (payslip.date_to[:4] == '2018') + code + +result_rate = -contract.ny_rsf_rate(2018) +result = categories.NY_UNEMP_WAGES + +# result_rate of 0 implies 100% due to bug +if result_rate == 0.0: + result = 0.0 + + + + + + + + + + New York Metropolitan Commuter Transportation Mobility Tax(2018) + NY_MCTMT_2018 + python + result = (payslip.date_to[:4] == '2018') + code + +result_rate = -contract.ny_mctmt_rate(2018) +result = categories.MCTMT_WAGES + +# result_rate of 0 implies 100% due to bug +if result_rate == 0.0: + result = 0.0 + + + + + + + + + + New York Income Withholding + NY_INC_WITHHOLD_2018 + python + result = (payslip.date_to[:4] == '2018') + code + +wages = categories.GROSS +allowances = contract.ny_it2104_allowances +additional_withholding = contract.ny_additional_withholding +schedule_pay = contract.schedule_pay +filing_status = contract.ny_it2104_filing_status + +if filing_status == 'exempt': + result = 0.0 + +# Tables are found in https://www.tax.ny.gov/pdf/publications/withholding/nys50_t_nys.pdf +# Table A - Combined deduction and exemption allowance (Step 1) +deduction_exemption_table_single = { +'weekly': (142.30, 161.55, 180.80, 200.05, 219.30, 238.55, 257.80, 277.05, 296.30, 315.55, 334.80), +'bi-weekly': (284.60, 323.10, 361.60, 400.10, 438.60, 477.10, 515.60, 544.10, 592.60, 631.10, 669.60), +'semi-monthly': (308.35, 350.0, 391.65, 433.30, 474.95, 516.60, 558.25, 599.90, 641.55, 683.20, 724.85), +'monthly': (616.70, 700, 783.30, 866.60, 949.90, 1033.20, 1116.50, 1199.80, 1283.10, 1366.40, 1449.70), +'annually': (7400, 8400, 9400, 10400, 11400, 12400, 13400, 14400, 15400, 16400, 17400), +} + +deduction_exemption_table_married = { +'weekly': (152.90, 172.15, 191.40, 210.65, 229.90, 249.15, 268.40, 287.65, 306.90, 326.15, 345.40), +'bi-weekly': (305.80, 344.30, 382.80, 421.30, 459.80, 498.30, 536.80, 575.30, 613.80, 652.30, 690.80), +'semi-monthly': (331.25, 372.90, 414.55, 456.20, 497.85, 539.50, 581.15, 622.80, 664.45, 706.10, 747.75), +'monthly': (662.50, 745.80, 829.10, 912.40, 995.70, 1079.00, 1162.30, 1245.60, 1328.90, 1412.20, 1495.50), +'annually': (7950, 8950, 9950, 10950, 11950, 12950, 13950, 14950, 15950, 16950, 17950), +} + +# For greater than 10 exemptions, from tables B and C +over_10_deduction_table = { +'weekly': (142.30, 152.90, 19.25), +'bi-weekly': (284.60, 305.80, 38.50), +'semi-monthly': (308.35, 331.25, 41.65), +'monthly': (616.70, 662.50, 83.30), +'annual': (7400, 7950, 1000), +} + +if allowances > 10: + if filing_status == 'single': + wages -= over_10_deduction_table[schedule_pay][0] + over_10_deduction_table[schedule_pay][2] * allowances + elif filing_status == 'married': + wages -= over_10_deduction_table[schedule_pay][1] + over_10_deduction_table[schedule_pay][2] * allowances + +else: + if filing_status == 'single': + wages -= deduction_exemption_table_single[schedule_pay][allowances] + elif filing_status == 'married': + wages -= deduction_exemption_table_married[schedule_pay][allowances] + +# Tax Rate Tables +#### SINGLE #### +if filing_status == 'single': + if schedule_pay == 'weekly': + tax_rate_table = [ + (163, 0.0400, 0.0), + (225, 0.0450, 6.54), + (267, 0.0525, 9.31), + (412, 0.0590, 11.54), + (1551, 0.0633, 20.04), + (1862, 0.0657, 92.17), + (2070, 0.0758, 112.58), + (3032, 0.0808, 128.38), + (4142, 0.0707, 206.08), + (5104, 0.0856, 284.60), + (20722, 0.0735, 366.90), + (21684, 0.5208, 1514.85), + (float('inf'), 0.0962, 2015.62), + ] + + elif schedule_pay == 'bi-weekly': + tax_rate_table = [ + (327, 0.0400, 0.0), + (450, 0.0450, 13.08), + (535, 0.0525, 18.62), + (823, 0.0590, 23.08), + (3102, 0.0633, 40.08), + (3723, 0.0657, 184.35), + (4140, 0.0758, 225.15), + (6063, 0.0808, 256.77), + (8285, 0.0707, 412.15), + (10208, 0.0856, 569.19), + (41444, 0.0735, 733.81), + (43367, 0.5208, 3029.69), + (float('inf'), 0.0962, 4021.23), + ] + + elif schedule_pay == 'semi-monthly': + tax_rate_table = [ + (354, 0.0400, 0.0), + (488, 0.0450, 14.17), + (579, 0.0525, 20.17), + (892, 0.0590, 25.00), + (3360, 0.0633, 43.42), + (4033, 0.0657, 199.71), + (4485, 0.0758, 243.92), + (6569, 0.0808, 278.17), + (8975, 0.0707, 446.50), + (11058, 0.0856, 616.63), + (44898, 0.0735, 794.96), + (46981, 0.5208, 3282.17), + (float('inf'), 0.0962, 4367.17), + ] + + elif schedule_pay == 'monthly': + tax_rate_table = [ + (708, 0.0400, 0.0), + (975, 0.0450, 28.33), + (1158, 0.0525, 40.33), + (1783, 0.0590, 50.00), + (6721, 0.0633, 86.83), + (8067, 0.0657, 399.42), + (8971, 0.0758, 487.83), + (13138, 0.0808, 556.33), + (17950, 0.0707, 893.00), + (22117, 0.0856, 1233.25), + (89796, 0.0735, 1589.92), + (93963, 0.5208, 6564.33), + (float('inf'), 0.0962, 8734.33), + ] + + elif schedule_pay == 'annually': + tax_rate_table = [ + (8500, 0.0400, 0.0), + (11700, 0.0450, 340.00), + (13900, 0.0525, 484.00), + (21400, 0.0590, 600.00), + (80650, 0.0633, 1042.00), + (96800, 0.0657, 4793.00), + (107650, 0.0758, 5854.00), + (157650, 0.0808, 6676.00), + (215400, 0.0707, 10716.00), + (265400, 0.0856, 14799.00), + (1077550, 0.0735, 19079.00), + (1127550, 0.5208, 78772.00), + (float('inf'), 0.0962, 104812.00), + ] + +#### MARRIED #### +elif filing_status == 'married': + if schedule_pay == 'weekly': + tax_rate_table = [ + (163, 0.0400, 0.0), + (225, 0.0450, 6.54), + (267, 0.0525, 9.31), + (412, 0.0590, 11.54), + (1551, 0.0633, 20.04), + (1862, 0.0657, 92.17), + (2070, 0.0783, 112.58), + (3032, 0.0833, 128.90), + (4068, 0.0785, 209.00), + (6215, 0.0707, 290.37), + (7177, 0.0916, 442.17), + (20722, 0.0735, 530.25), + (41449, 0.0765, 1525.83), + (42411, 0.9454, 3111.42), + (float('inf'), 0.0962, 4020.46), + ] + + elif schedule_pay == 'bi-weekly': + tax_rate_table = [ + (327, 0.0400, 0.0), + (450, 0.0450, 13.08), + (535, 0.0525, 18.62), + (823, 0.0590, 23.08), + (3102, 0.0633, 40.08), + (3723, 0.0657, 184.35), + (4140, 0.0783, 225.15), + (6063, 0.0833, 257.81), + (8137, 0.0785, 418.00), + (12431, 0.0707, 580.73), + (14354, 0.0916, 884.35), + (41444, 0.0735, 1060.50), + (82898, 0.0765, 3051.65), + (84821, 0.9454, 6222.85), + (float('inf'), 0.0962, 8040.92), + ] + + elif schedule_pay == 'semi-monthly': + tax_rate_table = [ + (354, 0.0400, 0.0), + (488, 0.0450, 14.17), + (579, 0.0525, 20.17), + (892, 0.0590, 25.00), + (3360, 0.0633, 43.42), + (4033, 0.0657, 199.71), + (4485, 0.0783, 243.92), + (6569, 0.0833, 279.29), + (8815, 0.0785, 452.83), + (13476, 0.0707, 629.13), + (15550, 0.0916, 958.04), + (44898, 0.0735, 1148.88), + (89806, 0.0765, 3305.96), + (91890, 0.9454, 6741.42), + (float('inf'), 0.0962, 8711.00), + ] + + elif schedule_pay == 'monthly': + tax_rate_table = [ + (708, 0.0400, 0.0), + (975, 0.0450, 28.33), + (1158, 0.0525, 40.33), + (1783, 0.0590, 50.00), + (6721, 0.0633, 86.83), + (8067, 0.0657, 399.42), + (8971, 0.0783, 487.83), + (13138, 0.0833, 558.58), + (17629, 0.0785, 905.67), + (26933, 0.0707, 1258.25), + (31100, 0.0916, 1916.08), + (89796, 0.0735, 2297.75), + (179613, 0.0765, 6611.92), + (183779, 0.9454, 13482.83), + (float('inf'), 0.0962, 17422.00), + ] + + elif schedule_pay == 'annually': + tax_rate_table = [ + (8500, 0.0400, 0.0), + (11700, 0.0450, 340.00), + (13900, 0.0525, 484.00), + (21400, 0.0590, 600.00), + (80650, 0.0633, 1042.00), + (96800, 0.0657, 4793.00), + (107650, 0.0783, 5854.00), + (157650, 0.0833, 6703.00), + (211550, 0.0785, 10868.00), + (323200, 0.0707, 15099.00), + (373200, 0.0916, 22993.00), + (1077550, 0.0735, 27573.00), + (2155350, 0.0765, 79343.00), + (2205350, 0.9454, 161794.00), + (float('inf'), 0.0962, 209064.00), + ] + +over = 0.0 +tax = 0.0 +for row in tax_rate_table: + if wages <= row[0]: + tax = ((wages - over) * row[1]) + row[2] + break + over = row[0] + +tax += additional_withholding +result = -tax + + + + + + diff --git a/l10n_us_ny_hr_payroll/hr_payroll.py b/l10n_us_ny_hr_payroll/hr_payroll.py new file mode 100755 index 00000000..c2c305a6 --- /dev/null +++ b/l10n_us_ny_hr_payroll/hr_payroll.py @@ -0,0 +1,57 @@ +from odoo import models, fields, api + + +class USNYHrContract(models.Model): + _inherit = 'hr.contract' + + ny_it2104_allowances = fields.Integer(string="New York IT-2104 Allowances", + default=0, + help="Allowances claimed on line 1 of IT-2104") + ny_additional_withholding = fields.Integer(string="Additional Withholding", + default=0, + help="Line 3 of IT-2104") + ny_it2104_filing_status = fields.Selection([ + ('exempt', 'Exempt'), + ('single', 'Single'), + ('married', 'Married'), + ], string='NY Filing Status', default='single') + + @api.multi + def ny_unemp_rate(self, year): + self.ensure_one() + if self.futa_type == self.FUTA_TYPE_BASIC: + return 0.0 + + if hasattr(self.employee_id.company_id, 'ny_unemp_rate_' + str(year)): + return self.employee_id.company_id['ny_unemp_rate_' + str(year)] + + raise NotImplemented('Year (' + str(year) + ') Not implemented for US New York.') + + def ny_rsf_rate(self, year): + self.ensure_one() + if self.futa_type == self.FUTA_TYPE_BASIC: + return 0.0 + + if hasattr(self.employee_id.company_id, 'ny_rsf_rate_' + str(year)): + return self.employee_id.company_id['ny_rsf_rate_' + str(year)] + + raise NotImplemented('Year (' + str(year) + ') Not implemented for US New York.') + + def ny_mctmt_rate(self, year): + self.ensure_one() + if self.futa_type == self.FUTA_TYPE_BASIC: + return 0.0 + + if hasattr(self.employee_id.company_id, 'ny_mctmt_rate_' + str(year)): + return self.employee_id.company_id['ny_mctmt_rate_' + str(year)] + + raise NotImplemented('Year (' + str(year) + ') Not implemented for US New York.') + + +class NYCompany(models.Model): + _inherit = 'res.company' + + # Unemployment Rate is default for New Employer. + ny_unemp_rate_2018 = fields.Float(string="New York Unemployment Insurance Tax Rate 2018", default=3.925) + ny_rsf_rate_2018 = fields.Float(string="New York Re-employment Service Fund Rate 2018", default=0.075) + ny_mctmt_rate_2018 = fields.Float(string="New York Metropolitan Commuter Transportation Mobility Tax Rate 2018", default=0.0) diff --git a/l10n_us_ny_hr_payroll/hr_payroll_view.xml b/l10n_us_ny_hr_payroll/hr_payroll_view.xml new file mode 100755 index 00000000..cb291da0 --- /dev/null +++ b/l10n_us_ny_hr_payroll/hr_payroll_view.xml @@ -0,0 +1,37 @@ + + + + + res.company.form + res.company + 64 + + + + + + + + + + + + + hr.contract.form.inherit + hr.contract + 147 + + + + + + + + + + + + + + + diff --git a/l10n_us_ny_hr_payroll/tests/__init__.py b/l10n_us_ny_hr_payroll/tests/__init__.py new file mode 100755 index 00000000..050166dd --- /dev/null +++ b/l10n_us_ny_hr_payroll/tests/__init__.py @@ -0,0 +1 @@ +from . import test_us_ny_payslip_2018 diff --git a/l10n_us_ny_hr_payroll/tests/test_us_ny_payslip_2018.py b/l10n_us_ny_hr_payroll/tests/test_us_ny_payslip_2018.py new file mode 100755 index 00000000..48271fc3 --- /dev/null +++ b/l10n_us_ny_hr_payroll/tests/test_us_ny_payslip_2018.py @@ -0,0 +1,167 @@ +from odoo.addons.l10n_us_hr_payroll.tests.test_us_payslip import TestUsPayslip, process_payslip +from odoo.addons.l10n_us_hr_payroll.l10n_us_hr_payroll import USHrContract + + +class TestUsNYPayslip(TestUsPayslip): + ### + # Taxes and Rates + ### + NY_UNEMP_MAX_WAGE = 11100 + + # Examples from http://www.edd.ca.gov/pdf_pub_ctr/18methb.pdf + def test_single_example1(self): + salary = 400 + schedule_pay = 'weekly' + allowances = 3 + additional_withholding = 0 + + wh = -8.20 + + employee = self._createEmployee() + employee.company_id.ny_unemp_rate_2018 = 0.825 + employee.company_id.ny_rsf_rate_2018 = 0.075 + employee.company_id.ny_mctmt_rate_2018 = 0.0 + + contract = self._createContract(employee, + salary, + struct_id=self.ref( + 'l10n_us_ny_hr_payroll.hr_payroll_salary_structure_us_ny_employee'), + schedule_pay=schedule_pay) + contract.ny_it2104_allowances = allowances + contract.ny_additional_withholding = additional_withholding + contract.ny_it2104_filing_status = 'single' + + self.assertEqual(contract.schedule_pay, 'weekly') + + # tax rates + ny_unemp = contract.ny_unemp_rate(2018) / -100.0 + ny_rsf = contract.ny_rsf_rate(2018) / -100.0 + ny_mctmt = contract.ny_mctmt_rate(2018) / -100.0 + + self._log('2018 New York tax first payslip:') + payslip = self._createPayslip(employee, '2018-01-01', '2018-01-31') + + payslip.compute_sheet() + + cats = self._getCategories(payslip) + + self.assertPayrollEqual(cats['NY_UNEMP_WAGES'], salary) + self.assertPayrollEqual(cats['NY_UNEMP'], cats['NY_UNEMP_WAGES'] * ny_unemp) + self.assertPayrollEqual(cats['NY_RSF'], cats['NY_UNEMP_WAGES'] * ny_rsf) + self.assertPayrollEqual(cats['NY_MCTMT'], cats['NY_UNEMP_WAGES'] * ny_mctmt) + self.assertPayrollEqual(cats['NY_WITHHOLD'], wh) + + process_payslip(payslip) + + # Make a new payslip, this one will have maximums + + remaining_ny_unemp_wages = self.NY_UNEMP_MAX_WAGE - salary if (self.NY_UNEMP_MAX_WAGE - 2 * salary < salary) \ + else salary + + self._log('2018 New York tax second payslip:') + payslip = self._createPayslip(employee, '2018-02-01', '2018-02-28') + + payslip.compute_sheet() + + cats = self._getCategories(payslip) + + self.assertPayrollEqual(cats['NY_UNEMP_WAGES'], remaining_ny_unemp_wages) + self.assertPayrollEqual(cats['NY_UNEMP'], remaining_ny_unemp_wages * ny_unemp) + + def test_single_example2(self): + salary = 5000 + schedule_pay = 'semi-monthly' + allowances = 3 + additional_withholding = 0 + + wh = -284.19 + + employee = self._createEmployee() + employee.company_id.ny_unemp_rate_2018 = 0.825 + employee.company_id.ny_rsf_rate_2018 = 0.075 + employee.company_id.ny_mctmt_rate_2018 = 0.0 + + contract = self._createContract(employee, + salary, + struct_id=self.ref( + 'l10n_us_ny_hr_payroll.hr_payroll_salary_structure_us_ny_employee'), + schedule_pay=schedule_pay) + contract.ny_it2104_allowances = allowances + contract.ny_additional_withholding = additional_withholding + contract.ny_it2104_filing_status = 'married' + + self.assertEqual(contract.schedule_pay, 'semi-monthly') + + # tax rates + ny_unemp = contract.ny_unemp_rate(2018) / -100.0 + ny_rsf = contract.ny_rsf_rate(2018) / -100.0 + ny_mctmt = contract.ny_mctmt_rate(2018) / -100.0 + + self._log('2018 New York tax first payslip:') + payslip = self._createPayslip(employee, '2018-01-01', '2018-01-31') + + payslip.compute_sheet() + + cats = self._getCategories(payslip) + + self.assertPayrollEqual(cats['NY_UNEMP_WAGES'], salary) + self.assertPayrollEqual(cats['NY_UNEMP'], cats['NY_UNEMP_WAGES'] * ny_unemp) + self.assertPayrollEqual(cats['NY_RSF'], cats['NY_UNEMP_WAGES'] * ny_rsf) + self.assertPayrollEqual(cats['NY_MCTMT'], cats['NY_UNEMP_WAGES'] * ny_mctmt) + self.assertPayrollEqual(cats['NY_WITHHOLD'], wh) + + process_payslip(payslip) + + def test_single_example3(self): + salary = 50000 + schedule_pay = 'monthly' + allowances = 3 + additional_withholding = 0 + + wh = -3575.63 + + employee = self._createEmployee() + employee.company_id.ny_unemp_rate_2018 = 0.825 + employee.company_id.ny_rsf_rate_2018 = 0.075 + employee.company_id.ny_mctmt_rate_2018 = 0.0 + + contract = self._createContract(employee, + salary, + struct_id=self.ref( + 'l10n_us_ny_hr_payroll.hr_payroll_salary_structure_us_ny_employee'), + schedule_pay=schedule_pay) + contract.ny_it2104_allowances = allowances + contract.ny_additional_withholding = additional_withholding + contract.ny_it2104_filing_status = 'single' + + self.assertEqual(contract.schedule_pay, 'monthly') + + # tax rates + ny_unemp = contract.ny_unemp_rate(2018) / -100.0 + ny_rsf = contract.ny_rsf_rate(2018) / -100.0 + ny_mctmt = contract.ny_mctmt_rate(2018) / -100.0 + + self._log('2018 New York tax first payslip:') + payslip = self._createPayslip(employee, '2018-01-01', '2018-01-31') + + payslip.compute_sheet() + + cats = self._getCategories(payslip) + + self.assertPayrollEqual(cats['NY_WITHHOLD'], wh) + + def test_tax_exempt(self): + schedule_pay = 'monthly' + + wh = 0.0 + + employee = self._createEmployee() + + contract = self._createContract(employee, + salary, + struct_id=self.ref( + 'l10n_us_ny_hr_payroll.hr_payroll_salary_structure_us_ny_employee'), + schedule_pay=schedule_pay) + contract.ny_it2104_filing_status = 'exempt' + + self.assertPayrollEqual(cats['NY_WITHHOLD'], wh) From 5d3d30a1874b2c2843ef22e7403287e1f326244d Mon Sep 17 00:00:00 2001 From: Kristen Marie Kulha Date: Fri, 25 May 2018 17:01:59 -0700 Subject: [PATCH 06/55] Initial commit of `l10n_us_pa_hr_payroll` for 11.0. --- l10n_us_pa_hr_payroll/__init__.py | 1 + l10n_us_pa_hr_payroll/__manifest__.py | 28 +++++++ l10n_us_pa_hr_payroll/data/base.xml | 66 +++++++++++++++ l10n_us_pa_hr_payroll/data/final.xml | 20 +++++ l10n_us_pa_hr_payroll/data/rules_2018.xml | 84 +++++++++++++++++++ l10n_us_pa_hr_payroll/hr_payroll.py | 49 +++++++++++ l10n_us_pa_hr_payroll/hr_payroll_view.xml | 35 ++++++++ l10n_us_pa_hr_payroll/tests/__init__.py | 1 + .../tests/test_us_pa_payslip_2018.py | 39 +++++++++ 9 files changed, 323 insertions(+) create mode 100644 l10n_us_pa_hr_payroll/__init__.py create mode 100755 l10n_us_pa_hr_payroll/__manifest__.py create mode 100755 l10n_us_pa_hr_payroll/data/base.xml create mode 100755 l10n_us_pa_hr_payroll/data/final.xml create mode 100755 l10n_us_pa_hr_payroll/data/rules_2018.xml create mode 100755 l10n_us_pa_hr_payroll/hr_payroll.py create mode 100755 l10n_us_pa_hr_payroll/hr_payroll_view.xml create mode 100755 l10n_us_pa_hr_payroll/tests/__init__.py create mode 100755 l10n_us_pa_hr_payroll/tests/test_us_pa_payslip_2018.py diff --git a/l10n_us_pa_hr_payroll/__init__.py b/l10n_us_pa_hr_payroll/__init__.py new file mode 100644 index 00000000..e99aa24a --- /dev/null +++ b/l10n_us_pa_hr_payroll/__init__.py @@ -0,0 +1 @@ +from . import hr_payroll diff --git a/l10n_us_pa_hr_payroll/__manifest__.py b/l10n_us_pa_hr_payroll/__manifest__.py new file mode 100755 index 00000000..e589dcd2 --- /dev/null +++ b/l10n_us_pa_hr_payroll/__manifest__.py @@ -0,0 +1,28 @@ +{ + 'name': 'USA - Pennsylvania - Payroll', + 'author': 'Hibou Corp. ', + 'license': 'AGPL-3', + 'category': 'Localization', + 'depends': ['l10n_us_hr_payroll'], + 'version': '11.0.2018.0.0', + 'description': """ +USA::Pennsylvania Payroll Rules. +================================ + +* Partner for Pennsylvania Department of Revenue +* Contribution register for Pennsylvania Department of Revenue - Unemployment Insurance +* Contribution register Pennsylvania Department of Revenue - State Income Tax +* Contract level Pennsylvania Unemployment Rate +* Company level Pennsylvania Unemployement Rate + """, + + 'auto_install': False, + 'website': 'https://hibou.io/', + 'data': [ + 'hr_payroll_view.xml', + 'data/base.xml', + 'data/rules_2018.xml', + 'data/final.xml', + ], + 'installable': True +} diff --git a/l10n_us_pa_hr_payroll/data/base.xml b/l10n_us_pa_hr_payroll/data/base.xml new file mode 100755 index 00000000..ef4615ef --- /dev/null +++ b/l10n_us_pa_hr_payroll/data/base.xml @@ -0,0 +1,66 @@ + + + + + + Pennsylvania Department of Revenue - Unemployment Tax(Employee) + 1 + + + + + Pennsylvania Department of Revenue - Unemployment Tax(Employer) + 1 + + + + + Pennsylvania Department of Revenue - Income Tax + 1 + + + + + Pennsylvania Unemployment(Employee) + Pennsylvania Department of Revenue - Unemployment(Employee) + + + + + Pennsylvania Unemployment(Employer) + Pennsylvania Department of Revenue - Unemployment(Employee) + + + + + Pennsylvania Income Tax Withholding + Pennsylvania Department of Revenue - Income Tax + + + + + + + Pennsylvania Unemployment - Wages + PA_UNEMP_WAGES + + + + Pennsylvania Unemployment(Employee) + PA_UNEMP_EMPLOYEE + + + + + Pennsylvania Unemployment(Employer) + PA_UNEMP_COMPANY + + + + + Pennsylvania Income Withholding + PA_WITHHOLD + + + + diff --git a/l10n_us_pa_hr_payroll/data/final.xml b/l10n_us_pa_hr_payroll/data/final.xml new file mode 100755 index 00000000..4245d2fc --- /dev/null +++ b/l10n_us_pa_hr_payroll/data/final.xml @@ -0,0 +1,20 @@ + + + + + + + US_PA_EMP + USA Pennsylvania Employee + + + + + + + diff --git a/l10n_us_pa_hr_payroll/data/rules_2018.xml b/l10n_us_pa_hr_payroll/data/rules_2018.xml new file mode 100755 index 00000000..c8dd9e46 --- /dev/null +++ b/l10n_us_pa_hr_payroll/data/rules_2018.xml @@ -0,0 +1,84 @@ + + + + + + + + + Pennsylvania Unemployment - Wages (2018) + PA_UNEMP_WAGES_2018 + python + result = (payslip.date_to[:4] == '2018') + code + +### +ytd = payslip.sum('PA_UNEMP_WAGES_2018', '2018-01-01', '2019-01-01') +ytd += contract.external_wages +remaining = 10000.0 - ytd +if remaining <= 0.0: + result = 0 +elif remaining < categories.GROSS: + result = remaining +else: + result = categories.GROSS + + + + + + + Pennsylvania Unemployment - Employee(2018) + PA_UNEMP_EMPLOYEE_2018 + python + result = (payslip.date_to[:4] == '2018') + code + +result_rate = -contract.pa_unemp_employee_rate(2018) +result = categories.PA_UNEMP_WAGES + +# result_rate of 0 implies 100% due to bug +if result_rate == 0.0: + result = 0.0 + + + + + + + + Pennsylvania Unemployment - Company(2018) + PA_UNEMP_COMPANY_2018 + python + result = (payslip.date_to[:4] == '2018') + code + +result_rate = -contract.pa_unemp_company_rate(2018) +result = categories.PA_UNEMP_WAGES + +# result_rate of 0 implies 100% due to bug +if result_rate == 0.0: + result = 0.0 + + + + + + + + + Pennsylvania Income Withholding + PA_INC_WITHHOLD_2018 + python + result = (payslip.date_to[:4] == '2018') + code + +wages = categories.GROSS +income_tax_rate = contract.pa_withhold_rate(2018) / -100.0 +result = wages * income_tax_rate + + + + + + diff --git a/l10n_us_pa_hr_payroll/hr_payroll.py b/l10n_us_pa_hr_payroll/hr_payroll.py new file mode 100755 index 00000000..33f5ea06 --- /dev/null +++ b/l10n_us_pa_hr_payroll/hr_payroll.py @@ -0,0 +1,49 @@ +from odoo import models, fields, api + + +class USPAHrContract(models.Model): + _inherit = 'hr.contract' + + pa_additional_withholding = fields.Integer(string="Additional Withholding", + default=0) + + @api.multi + def pa_unemp_company_rate(self, year): + self.ensure_one() + if self.futa_type == self.FUTA_TYPE_BASIC: + return 0.0 + + if hasattr(self.employee_id.company_id, 'pa_unemp_company_rate_' + str(year)): + return self.employee_id.company_id['pa_unemp_company_rate_' + str(year)] + + raise NotImplemented('Year (' + str(year) + ') Not implemented for US Pennsylvania') + + def pa_unemp_employee_rate(self, year): + self.ensure_one() + if self.futa_type == self.FUTA_TYPE_BASIC: + return 0.0 + + if hasattr(self.employee_id.company_id, 'pa_unemp_employee_rate_' + str(year)): + return self.employee_id.company_id['pa_unemp_employee_rate_' + str(year)] + + raise NotImplemented('Year (' + str(year) + ') Not implemented for US Pennsylvania') + + def pa_withhold_rate(self, year): + self.ensure_one() + if self.futa_type == self.FUTA_TYPE_BASIC: + return 0.0 + + if hasattr(self.employee_id.company_id, 'pa_withhold_rate_' + str(year)): + return self.employee_id.company_id['pa_withhold_rate_' + str(year)] + + raise NotImplemented('Year (' + str(year) + ') Not implemented for US Pennsylvania') + + +class PACompany(models.Model): + _inherit = 'res.company' + + # Company Unemployment rate is default rate for new employers. + pa_unemp_company_rate_2018 = fields.Float(string="Pennsylvania Unemployment Rate 2018", default=3.6890) + pa_unemp_employee_rate_2018 = fields.Float(string="Pennsylvania Unemployment Rate 2018", default=0.06) + pa_withhold_rate_2018 = fields.Float(string="Pennsylvania Income Tax Rate 2018", default=3.07) + diff --git a/l10n_us_pa_hr_payroll/hr_payroll_view.xml b/l10n_us_pa_hr_payroll/hr_payroll_view.xml new file mode 100755 index 00000000..f6d22c94 --- /dev/null +++ b/l10n_us_pa_hr_payroll/hr_payroll_view.xml @@ -0,0 +1,35 @@ + + + + + res.company.form + res.company + 64 + + + + + + + + + + + + + hr.contract.form.inherit + hr.contract + 148 + + + + + + + + + + + + + diff --git a/l10n_us_pa_hr_payroll/tests/__init__.py b/l10n_us_pa_hr_payroll/tests/__init__.py new file mode 100755 index 00000000..e6301d7f --- /dev/null +++ b/l10n_us_pa_hr_payroll/tests/__init__.py @@ -0,0 +1 @@ +from . import test_us_pa_payslip_2018 diff --git a/l10n_us_pa_hr_payroll/tests/test_us_pa_payslip_2018.py b/l10n_us_pa_hr_payroll/tests/test_us_pa_payslip_2018.py new file mode 100755 index 00000000..025caf5a --- /dev/null +++ b/l10n_us_pa_hr_payroll/tests/test_us_pa_payslip_2018.py @@ -0,0 +1,39 @@ +from odoo.addons.l10n_us_hr_payroll.tests.test_us_payslip import TestUsPayslip, process_payslip +from odoo.addons.l10n_us_hr_payroll.l10n_us_hr_payroll import USHrContract + + +class TestUsPAPayslip(TestUsPayslip): + ### + # Taxes and Rates + ### + PA_UNEMP_MAX_WAGE = 10000.0 + + def test_2018_taxes(self): + self.debug = True + salary = 4166.67 + wh = -127.92 + + employee = self._createEmployee() + employee.company_id.pa_unemp_employee_rate_2018 = 0.06~ + employee.company_id.pa_unemp_company_rate_2018 = 3.6785 + employee.company_id.pa_withhold_rate_2018 = 3.07 + + contract = self._createContract(employee, salary, struct_id=self.ref('l10n_us_pa_hr_payroll.hr_payroll_salary_structure_us_pa_employee')) + + # tax rates + pa_unemp_employee = contract.pa_unemp_employee_rate(2018) / -100.0 + pa_unemp_company = contract.pa_unemp_company_rate(2018) / -100.0 + + + self._log('2018 Pennsylvania tax first payslip:') + payslip = self._createPayslip(employee, '2018-01-01', '2018-01-31') + payslip.onchange_contract() + payslip.compute_sheet() + + cats = self._getCategories(payslip) + + self.assertPayrollEqual(cats['PA_UNEMP_WAGES'], salary) + self.assertPayrollEqual(cats['PA_UNEMP_EMPLOYEE'], cats['PA_UNEMP_WAGES'] * pa_unemp_employee) + self.assertPayrollEqual(cats['PA_UNEMP_COMPANY'], cats['PA_UNEMP_WAGES'] * pa_unemp_company) + self.assertPayrollEqual(cats['PA_WITHHOLD'], wh) + From 4b64c8b72278f3d0a761779d54a02707b6c67473 Mon Sep 17 00:00:00 2001 From: Jared Kipe Date: Tue, 5 Jun 2018 11:30:29 -0700 Subject: [PATCH 07/55] Initial commit of `product_catch_weight` for 11.0 --- product_catch_weight/__init__.py | 1 + product_catch_weight/__manifest__.py | 19 +++ product_catch_weight/models/__init__.py | 3 + .../models/account_invoice.py | 42 +++++ product_catch_weight/models/stock.py | 20 +++ product_catch_weight/models/stock_patch.py | 114 +++++++++++++ product_catch_weight/tests/__init__.py | 1 + .../tests/test_catch_weight.py | 152 ++++++++++++++++++ product_catch_weight/views/stock_views.xml | 35 ++++ 9 files changed, 387 insertions(+) create mode 100644 product_catch_weight/__init__.py create mode 100644 product_catch_weight/__manifest__.py create mode 100644 product_catch_weight/models/__init__.py create mode 100644 product_catch_weight/models/account_invoice.py create mode 100644 product_catch_weight/models/stock.py create mode 100644 product_catch_weight/models/stock_patch.py create mode 100644 product_catch_weight/tests/__init__.py create mode 100644 product_catch_weight/tests/test_catch_weight.py create mode 100644 product_catch_weight/views/stock_views.xml diff --git a/product_catch_weight/__init__.py b/product_catch_weight/__init__.py new file mode 100644 index 00000000..0650744f --- /dev/null +++ b/product_catch_weight/__init__.py @@ -0,0 +1 @@ +from . import models diff --git a/product_catch_weight/__manifest__.py b/product_catch_weight/__manifest__.py new file mode 100644 index 00000000..7c2ef68c --- /dev/null +++ b/product_catch_weight/__manifest__.py @@ -0,0 +1,19 @@ +{ + 'name': 'Product Catch Weight', + 'version': '11.0.1.0.0', + 'category': 'Warehouse', + 'depends': [ + 'sale_stock', + 'purchase', + ], + 'description': """ + """, + 'author': 'Hibou Corp.', + 'license': 'AGPL-3', + 'website': 'https://hibou.io/', + 'data': [ + 'views/stock_views.xml', + ], + 'installable': True, + 'application': False, +} diff --git a/product_catch_weight/models/__init__.py b/product_catch_weight/models/__init__.py new file mode 100644 index 00000000..57a87093 --- /dev/null +++ b/product_catch_weight/models/__init__.py @@ -0,0 +1,3 @@ +from . import account_invoice +from . import stock_patch +from . import stock diff --git a/product_catch_weight/models/account_invoice.py b/product_catch_weight/models/account_invoice.py new file mode 100644 index 00000000..ab72943c --- /dev/null +++ b/product_catch_weight/models/account_invoice.py @@ -0,0 +1,42 @@ +from odoo import api, fields, models +import logging + +_logger = logging.getLogger(__name__) + + +class AccountInvoiceLine(models.Model): + _inherit = 'account.invoice.line' + + @api.one + @api.depends('price_unit', 'discount', 'invoice_line_tax_ids', 'quantity', + 'product_id', 'invoice_id.partner_id', 'invoice_id.currency_id', 'invoice_id.company_id', + 'invoice_id.date_invoice', 'invoice_id.date') + def _compute_price(self): + currency = self.invoice_id and self.invoice_id.currency_id or None + price = self.price_unit * (1 - (self.discount or 0.0) / 100.0) + + ratio = 1.0 + qty_done_total = 0.0 + if self.invoice_id.type in ('out_invoice', 'out_refund'): + move_lines = self.sale_line_ids.mapped('move_ids.move_line_ids') + else: + move_lines = self.purchase_line_id.mapped('move_ids.move_line_ids') + for move_line in move_lines: + qty_done = move_line.qty_done + r = move_line.lot_id.catch_weight_ratio + ratio = ((ratio * qty_done_total) + (qty_done * r)) / (qty_done + qty_done_total) + qty_done_total += qty_done + price = price * ratio + + taxes = False + if self.invoice_line_tax_ids: + taxes = self.invoice_line_tax_ids.compute_all(price, currency, self.quantity, product=self.product_id, + partner=self.invoice_id.partner_id) + self.price_subtotal = price_subtotal_signed = taxes['total_excluded'] if taxes else self.quantity * price + self.price_total = taxes['total_included'] if taxes else self.price_subtotal + if self.invoice_id.currency_id and self.invoice_id.currency_id != self.invoice_id.company_id.currency_id: + price_subtotal_signed = self.invoice_id.currency_id.with_context( + date=self.invoice_id._get_currency_rate_date()).compute(price_subtotal_signed, + self.invoice_id.company_id.currency_id) + sign = self.invoice_id.type in ['in_refund', 'out_refund'] and -1 or 1 + self.price_subtotal_signed = price_subtotal_signed * sign \ No newline at end of file diff --git a/product_catch_weight/models/stock.py b/product_catch_weight/models/stock.py new file mode 100644 index 00000000..6acec3a4 --- /dev/null +++ b/product_catch_weight/models/stock.py @@ -0,0 +1,20 @@ +from odoo import api, fields, models + + +class StockProductionLot(models.Model): + _inherit = 'stock.production.lot' + + catch_weight_ratio = fields.Float(string='Catch Weight Ratio', digits=(10, 6), default=1.0) + + +class StockMoveLine(models.Model): + _inherit = 'stock.move.line' + + lot_catch_weight_ratio = fields.Float(string='Catch Weight Ratio', digits=(10, 6), default=1.0) + lot_catch_weight_ratio_related = fields.Float(related='lot_id.catch_weight_ratio') + #lot_catch_weight_ratio = fields.Float(related='lot_id.catch_weight_ratio') + + # def _action_done(self): + # super(StockMoveLine, self)._action_done() + # for ml in self.filtered(lambda l: l.product_id.tracking == 'serial' and l.lot_id): + # ml.lot_id.catch_weight_ratio = ml.lot_catch_weight_ratio diff --git a/product_catch_weight/models/stock_patch.py b/product_catch_weight/models/stock_patch.py new file mode 100644 index 00000000..8787f4f6 --- /dev/null +++ b/product_catch_weight/models/stock_patch.py @@ -0,0 +1,114 @@ +from odoo import fields +from odoo.exceptions import UserError +from odoo.tools.float_utils import float_round, float_compare, float_is_zero +from odoo.addons.stock.models.stock_move_line import StockMoveLine + + +def _action_done(self): + """ This method is called during a move's `action_done`. It'll actually move a quant from + the source location to the destination location, and unreserve if needed in the source + location. + + This method is intended to be called on all the move lines of a move. This method is not + intended to be called when editing a `done` move (that's what the override of `write` here + is done. + """ + + # First, we loop over all the move lines to do a preliminary check: `qty_done` should not + # be negative and, according to the presence of a picking type or a linked inventory + # adjustment, enforce some rules on the `lot_id` field. If `qty_done` is null, we unlink + # the line. It is mandatory in order to free the reservation and correctly apply + # `action_done` on the next move lines. + ml_to_delete = self.env['stock.move.line'] + for ml in self: + # Check here if `ml.qty_done` respects the rounding of `ml.product_uom_id`. + uom_qty = float_round(ml.qty_done, precision_rounding=ml.product_uom_id.rounding, rounding_method='HALF-UP') + precision_digits = self.env['decimal.precision'].precision_get('Product Unit of Measure') + qty_done = float_round(ml.qty_done, precision_digits=precision_digits, rounding_method='HALF-UP') + if float_compare(uom_qty, qty_done, precision_digits=precision_digits) != 0: + raise UserError(_('The quantity done for the product "%s" doesn\'t respect the rounding precision \ + defined on the unit of measure "%s". Please change the quantity done or the \ + rounding precision of your unit of measure.') % ( + ml.product_id.display_name, ml.product_uom_id.name)) + + qty_done_float_compared = float_compare(ml.qty_done, 0, precision_rounding=ml.product_uom_id.rounding) + if qty_done_float_compared > 0: + if ml.product_id.tracking != 'none': + picking_type_id = ml.move_id.picking_type_id + if picking_type_id: + if picking_type_id.use_create_lots: + # If a picking type is linked, we may have to create a production lot on + # the fly before assigning it to the move line if the user checked both + # `use_create_lots` and `use_existing_lots`. + if ml.lot_name and not ml.lot_id: + lot = self.env['stock.production.lot'].create( + {'name': ml.lot_name, 'product_id': ml.product_id.id, 'catch_weight_ratio': ml.lot_catch_weight_ratio} + ) + ml.write({'lot_id': lot.id}) + elif not picking_type_id.use_create_lots and not picking_type_id.use_existing_lots: + # If the user disabled both `use_create_lots` and `use_existing_lots` + # checkboxes on the picking type, he's allowed to enter tracked + # products without a `lot_id`. + continue + elif ml.move_id.inventory_id: + # If an inventory adjustment is linked, the user is allowed to enter + # tracked products without a `lot_id`. + continue + + if not ml.lot_id: + raise UserError(_('You need to supply a lot/serial number for %s.') % ml.product_id.name) + elif qty_done_float_compared < 0: + raise UserError(_('No negative quantities allowed')) + else: + ml_to_delete |= ml + ml_to_delete.unlink() + + # Now, we can actually move the quant. + done_ml = self.env['stock.move.line'] + for ml in self - ml_to_delete: + if ml.product_id.type == 'product': + Quant = self.env['stock.quant'] + rounding = ml.product_uom_id.rounding + + # if this move line is force assigned, unreserve elsewhere if needed + if not ml.location_id.should_bypass_reservation() and float_compare(ml.qty_done, ml.product_qty, + precision_rounding=rounding) > 0: + extra_qty = ml.qty_done - ml.product_qty + ml._free_reservation(ml.product_id, ml.location_id, extra_qty, lot_id=ml.lot_id, + package_id=ml.package_id, owner_id=ml.owner_id, ml_to_ignore=done_ml) + # unreserve what's been reserved + if not ml.location_id.should_bypass_reservation() and ml.product_id.type == 'product' and ml.product_qty: + try: + Quant._update_reserved_quantity(ml.product_id, ml.location_id, -ml.product_qty, lot_id=ml.lot_id, + package_id=ml.package_id, owner_id=ml.owner_id, strict=True) + except UserError: + Quant._update_reserved_quantity(ml.product_id, ml.location_id, -ml.product_qty, lot_id=False, + package_id=ml.package_id, owner_id=ml.owner_id, strict=True) + + # move what's been actually done + quantity = ml.product_uom_id._compute_quantity(ml.qty_done, ml.move_id.product_id.uom_id, + rounding_method='HALF-UP') + available_qty, in_date = Quant._update_available_quantity(ml.product_id, ml.location_id, -quantity, + lot_id=ml.lot_id, package_id=ml.package_id, + owner_id=ml.owner_id) + if available_qty < 0 and ml.lot_id: + # see if we can compensate the negative quants with some untracked quants + untracked_qty = Quant._get_available_quantity(ml.product_id, ml.location_id, lot_id=False, + package_id=ml.package_id, owner_id=ml.owner_id, + strict=True) + if untracked_qty: + taken_from_untracked_qty = min(untracked_qty, abs(quantity)) + Quant._update_available_quantity(ml.product_id, ml.location_id, -taken_from_untracked_qty, + lot_id=False, package_id=ml.package_id, owner_id=ml.owner_id) + Quant._update_available_quantity(ml.product_id, ml.location_id, taken_from_untracked_qty, + lot_id=ml.lot_id, package_id=ml.package_id, owner_id=ml.owner_id) + Quant._update_available_quantity(ml.product_id, ml.location_dest_id, quantity, lot_id=ml.lot_id, + package_id=ml.result_package_id, owner_id=ml.owner_id, in_date=in_date) + done_ml |= ml + # Reset the reserved quantity as we just moved it to the destination location. + (self - ml_to_delete).with_context(bypass_reservation_update=True).write({ + 'product_uom_qty': 0.00, + 'date': fields.Datetime.now(), + }) + +StockMoveLine._action_done = _action_done diff --git a/product_catch_weight/tests/__init__.py b/product_catch_weight/tests/__init__.py new file mode 100644 index 00000000..0edad729 --- /dev/null +++ b/product_catch_weight/tests/__init__.py @@ -0,0 +1 @@ +from . import test_catch_weight diff --git a/product_catch_weight/tests/test_catch_weight.py b/product_catch_weight/tests/test_catch_weight.py new file mode 100644 index 00000000..a167e530 --- /dev/null +++ b/product_catch_weight/tests/test_catch_weight.py @@ -0,0 +1,152 @@ +import logging +# from odoo.addons.stock.tests.test_move2 import TestPickShip +from odoo import fields +from odoo.tests.common import TransactionCase + +_logger = logging.getLogger(__name__) + + +class TestPicking(TransactionCase): + def setUp(self): + super(TestPicking, self).setUp() + self.partner1 = self.env.ref('base.res_partner_2') + self.product1 = self.env['product.product'].create({ + 'name': 'Product 1', + 'type': 'product', + 'tracking': 'serial', + 'list_price': 100.0, + 'standard_price': 50.0, + 'taxes_id': [(5, 0, 0)], + }) + #self.product1 = self.env.ref('product.product_order_01') + self.product1.write({ + 'type': 'product', + 'tracking': 'serial', + }) + self.stock_location = self.env.ref('stock.stock_location_stock') + + + # def test_creation(self): + # self.productA.tracking = 'serial' + # lot = self.env['stock.production.lot'].create({ + # 'product_id': self.productA.id, + # 'name': '123456789', + # }) + # + # lot.catch_weight_ratio = 0.8 + # _logger.warn(lot.xxxcatch_weight_ratio) + + + + # def test_delivery(self): + # self.productA.tracking = 'serial' + # picking_pick, picking_pack, picking_ship = self.create_pick_pack_ship() + # stock_location = self.env['stock.location'].browse(self.stock_location) + # lot = self.env['stock.production.lot'].create({ + # 'product_id': self.productA.id, + # 'name': '123456789', + # 'catch_weight_ratio': 0.8, + # }) + # self.env['stock.quant']._update_available_quantity(self.productA, stock_location, 1.0, lot_id=lot) + + def test_so_invoice(self): + ratio = 0.8 + lot = self.env['stock.production.lot'].create({ + 'product_id': self.product1.id, + 'name': '123456789', + 'catch_weight_ratio': ratio, + }) + self.env['stock.quant']._update_available_quantity(self.product1, self.stock_location, 1.0, lot_id=lot) + so = self.env['sale.order'].create({ + 'partner_id': self.partner1.id, + 'partner_invoice_id': self.partner1.id, + 'partner_shipping_id': self.partner1.id, + 'order_line': [(0, 0, {'product_id': self.product1.id})], + }) + so.action_confirm() + self.assertTrue(so.state in ('sale', 'done')) + self.assertEqual(len(so.picking_ids), 1) + picking = so.picking_ids + self.assertEqual(picking.state, 'assigned') + self.assertEqual(picking.move_lines.move_line_ids.lot_id, lot) + picking.move_lines.move_line_ids.qty_done = 1.0 + picking.button_validate() + self.assertEqual(picking.state, 'done') + + inv_id = so.action_invoice_create() + inv = self.env['account.invoice'].browse(inv_id) + self.assertEqual(inv.amount_total, ratio * self.product1.list_price) + + def test_so_invoice2(self): + ratio1 = 0.8 + ratio2 = 1.1 + lot1 = self.env['stock.production.lot'].create({ + 'product_id': self.product1.id, + 'name': '1-low', + 'catch_weight_ratio': ratio1, + }) + lot2 = self.env['stock.production.lot'].create({ + 'product_id': self.product1.id, + 'name': '1-high', + 'catch_weight_ratio': ratio2, + }) + self.env['stock.quant']._update_available_quantity(self.product1, self.stock_location, 1.0, lot_id=lot1) + self.env['stock.quant']._update_available_quantity(self.product1, self.stock_location, 1.0, lot_id=lot2) + so = self.env['sale.order'].create({ + 'partner_id': self.partner1.id, + 'partner_invoice_id': self.partner1.id, + 'partner_shipping_id': self.partner1.id, + 'order_line': [(0, 0, {'product_id': self.product1.id, 'product_uom_qty': 2.0})], + }) + so.action_confirm() + self.assertTrue(so.state in ('sale', 'done')) + self.assertEqual(len(so.picking_ids), 1) + picking = so.picking_ids + self.assertEqual(picking.state, 'assigned') + self.assertEqual(picking.move_lines.move_line_ids.mapped('lot_id'), lot1 + lot2) + for line in picking.move_lines.move_line_ids: + line.qty_done = 1.0 + picking.button_validate() + self.assertEqual(picking.state, 'done') + + inv_id = so.action_invoice_create() + inv = self.env['account.invoice'].browse(inv_id) + self.assertEqual(inv.amount_total, (ratio1 * self.product1.list_price) + (ratio2 * self.product1.list_price)) + + def test_po_invoice(self): + ratio1 = 0.8 + ratio2 = 1.1 + ratios = (ratio1, ratio2) + price = self.product1.standard_price + po = self.env['purchase.order'].create({ + 'partner_id': self.partner1.id, + 'order_line': [(0, 0, { + 'product_id': self.product1.id, + 'product_qty': 2.0, + 'name': 'Test', + 'date_planned': fields.Datetime.now(), + 'product_uom': self.product1.uom_po_id.id, + 'price_unit': price, + })] + }) + po.button_confirm() + self.assertEqual(po.state, 'purchase') + self.assertEqual(len(po.picking_ids), 1) + + picking = po.picking_ids + for i, line in enumerate(picking.move_lines.move_line_ids): + line.write({'lot_name': str(i), 'qty_done': 1.0, 'lot_catch_weight_ratio': ratios[i]}) + picking.button_validate() + self.assertEqual(picking.state, 'done') + + inv = self.env['account.invoice'].create({ + 'type': 'in_invoice', + 'partner_id': self.partner1.id, + 'purchase_id': po.id, + }) + inv.purchase_order_change() + self.assertEqual(len(inv.invoice_line_ids), 1) + self.assertEqual(inv.invoice_line_ids.quantity, 2.0) + self.assertEqual(inv.amount_total, (ratio1 * price) + (ratio2 * price)) + + diff --git a/product_catch_weight/views/stock_views.xml b/product_catch_weight/views/stock_views.xml new file mode 100644 index 00000000..cee50753 --- /dev/null +++ b/product_catch_weight/views/stock_views.xml @@ -0,0 +1,35 @@ + + + + stock.production.lot.form.inherit + stock.production.lot + + + + + + + + + + stock.move.line.form.inherit + stock.move.line + + + + + + + + + stock.move.line.operations.tree.inherit + stock.move.line + + + + + + + + + \ No newline at end of file From 7ae88479d2dfa9946529b890a7e98650245bb567 Mon Sep 17 00:00:00 2001 From: Jared Kipe Date: Sun, 10 Jun 2018 11:02:27 -0700 Subject: [PATCH 08/55] Improve UI, allowing user to measure the catch weight in a specific unit of measure. This UOM should be convertable (in the same category) as the normal stock UOM for the product. Example: If you want to sell 'units' of 50lbs then you should make a "50lbs" UOM in the Weight category and use that as the sale and purchase UOM, then your "Catch Weight UOM" can be the stock "lb(s)" UOM. --- product_catch_weight/__manifest__.py | 1 + product_catch_weight/models/__init__.py | 1 + .../models/account_invoice.py | 6 ++ product_catch_weight/models/product.py | 7 ++ product_catch_weight/models/stock.py | 44 +++++++++--- product_catch_weight/models/stock_patch.py | 3 +- .../tests/test_catch_weight.py | 46 +++++++------ .../views/account_invoice_views.xml | 69 +++++++++++++++++++ product_catch_weight/views/stock_views.xml | 43 +++++++++--- 9 files changed, 182 insertions(+), 38 deletions(-) create mode 100644 product_catch_weight/models/product.py create mode 100644 product_catch_weight/views/account_invoice_views.xml diff --git a/product_catch_weight/__manifest__.py b/product_catch_weight/__manifest__.py index 7c2ef68c..76d3cea2 100644 --- a/product_catch_weight/__manifest__.py +++ b/product_catch_weight/__manifest__.py @@ -12,6 +12,7 @@ 'license': 'AGPL-3', 'website': 'https://hibou.io/', 'data': [ + 'views/account_invoice_views.xml', 'views/stock_views.xml', ], 'installable': True, diff --git a/product_catch_weight/models/__init__.py b/product_catch_weight/models/__init__.py index 57a87093..5e099bc5 100644 --- a/product_catch_weight/models/__init__.py +++ b/product_catch_weight/models/__init__.py @@ -1,3 +1,4 @@ from . import account_invoice +from . import product from . import stock_patch from . import stock diff --git a/product_catch_weight/models/account_invoice.py b/product_catch_weight/models/account_invoice.py index ab72943c..ee8c9f92 100644 --- a/product_catch_weight/models/account_invoice.py +++ b/product_catch_weight/models/account_invoice.py @@ -7,6 +7,9 @@ _logger = logging.getLogger(__name__) class AccountInvoiceLine(models.Model): _inherit = 'account.invoice.line' + catch_weight = fields.Float(string='Catch Weight', digits=(10, 4), compute='_compute_price', store=True) + catch_weight_uom_id = fields.Many2one('product.uom', related='product_id.catch_weight_uom_id') + @api.one @api.depends('price_unit', 'discount', 'invoice_line_tax_ids', 'quantity', 'product_id', 'invoice_id.partner_id', 'invoice_id.currency_id', 'invoice_id.company_id', @@ -17,6 +20,7 @@ class AccountInvoiceLine(models.Model): ratio = 1.0 qty_done_total = 0.0 + catch_weight = 0.0 if self.invoice_id.type in ('out_invoice', 'out_refund'): move_lines = self.sale_line_ids.mapped('move_ids.move_line_ids') else: @@ -26,7 +30,9 @@ class AccountInvoiceLine(models.Model): r = move_line.lot_id.catch_weight_ratio ratio = ((ratio * qty_done_total) + (qty_done * r)) / (qty_done + qty_done_total) qty_done_total += qty_done + catch_weight += move_line.lot_id.catch_weight price = price * ratio + self.catch_weight = catch_weight taxes = False if self.invoice_line_tax_ids: diff --git a/product_catch_weight/models/product.py b/product_catch_weight/models/product.py new file mode 100644 index 00000000..16cb4b91 --- /dev/null +++ b/product_catch_weight/models/product.py @@ -0,0 +1,7 @@ +from odoo import api, fields, models + + +class ProductProduct(models.Model): + _inherit = 'product.template' + + catch_weight_uom_id = fields.Many2one('product.uom', string='Catch Weight UOM') diff --git a/product_catch_weight/models/stock.py b/product_catch_weight/models/stock.py index 6acec3a4..1ad5d3b6 100644 --- a/product_catch_weight/models/stock.py +++ b/product_catch_weight/models/stock.py @@ -4,17 +4,43 @@ from odoo import api, fields, models class StockProductionLot(models.Model): _inherit = 'stock.production.lot' - catch_weight_ratio = fields.Float(string='Catch Weight Ratio', digits=(10, 6), default=1.0) + catch_weight_ratio = fields.Float(string='Catch Weight Ratio', digits=(10, 6), compute='_compute_catch_weight_ratio') + catch_weight = fields.Float(string='Catch Weight', digits=(10, 4)) + catch_weight_uom_id = fields.Many2one('product.uom', related='product_id.catch_weight_uom_id') + + + @api.depends('catch_weight') + def _compute_catch_weight_ratio(self): + for lot in self: + if not lot.catch_weight_uom_id: + lot.catch_weight_ratio = 1.0 + else: + lot.catch_weight_ratio = lot.catch_weight_uom_id._compute_quantity(lot.catch_weight, + lot.product_id.uom_id, + rounding_method='DOWN') + + +class StockMove(models.Model): + _inherit = 'stock.move' + + product_catch_weight_uom_id = fields.Many2one('product.uom', related="product_id.catch_weight_uom_id") + + def _prepare_move_line_vals(self, quantity=None, reserved_quant=None): + vals = super(StockMove, self)._prepare_move_line_vals(quantity=quantity, reserved_quant=reserved_quant) + vals['catch_weight_uom_id'] = self.product_catch_weight_uom_id.id if self.product_catch_weight_uom_id else False + return vals + + def action_show_details(self): + action = super(StockMove, self).action_show_details() + action['context']['show_catch_weight'] = bool(self.product_id.catch_weight_uom_id) + return action class StockMoveLine(models.Model): _inherit = 'stock.move.line' - lot_catch_weight_ratio = fields.Float(string='Catch Weight Ratio', digits=(10, 6), default=1.0) - lot_catch_weight_ratio_related = fields.Float(related='lot_id.catch_weight_ratio') - #lot_catch_weight_ratio = fields.Float(related='lot_id.catch_weight_ratio') - - # def _action_done(self): - # super(StockMoveLine, self)._action_done() - # for ml in self.filtered(lambda l: l.product_id.tracking == 'serial' and l.lot_id): - # ml.lot_id.catch_weight_ratio = ml.lot_catch_weight_ratio + catch_weight_ratio = fields.Float(string='Catch Weight Ratio', digits=(10, 6), default=1.0) + catch_weight = fields.Float(string='Catch Weight', digits=(10,4)) + catch_weight_uom_id = fields.Many2one('product.uom', string='Catch Weight UOM') + lot_catch_weight = fields.Float(related='lot_id.catch_weight') + lot_catch_weight_uom_id = fields.Many2one('product.uom', related='product_id.catch_weight_uom_id') diff --git a/product_catch_weight/models/stock_patch.py b/product_catch_weight/models/stock_patch.py index 8787f4f6..fca04e4f 100644 --- a/product_catch_weight/models/stock_patch.py +++ b/product_catch_weight/models/stock_patch.py @@ -41,8 +41,9 @@ def _action_done(self): # the fly before assigning it to the move line if the user checked both # `use_create_lots` and `use_existing_lots`. if ml.lot_name and not ml.lot_id: + lot_catch_weight = ml.catch_weight_uom_id._compute_quantity(ml.catch_weight, ml.product_id.catch_weight_uom_id, rounding_method='DOWN') lot = self.env['stock.production.lot'].create( - {'name': ml.lot_name, 'product_id': ml.product_id.id, 'catch_weight_ratio': ml.lot_catch_weight_ratio} + {'name': ml.lot_name, 'product_id': ml.product_id.id, 'catch_weight': lot_catch_weight} ) ml.write({'lot_id': lot.id}) elif not picking_type_id.use_create_lots and not picking_type_id.use_existing_lots: diff --git a/product_catch_weight/tests/test_catch_weight.py b/product_catch_weight/tests/test_catch_weight.py index a167e530..1b13cb55 100644 --- a/product_catch_weight/tests/test_catch_weight.py +++ b/product_catch_weight/tests/test_catch_weight.py @@ -9,7 +9,16 @@ _logger = logging.getLogger(__name__) class TestPicking(TransactionCase): def setUp(self): super(TestPicking, self).setUp() + self.nominal_weight = 50.0 self.partner1 = self.env.ref('base.res_partner_2') + self.stock_location = self.env.ref('stock.stock_location_stock') + self.ref_uom_id = self.env.ref('product.product_uom_kgm') + self.product_uom_id = self.env['product.uom'].create({ + 'name': '50 ref', + 'category_id': self.ref_uom_id.category_id.id, + 'uom_type': 'bigger', + 'factor_inv': self.nominal_weight, + }) self.product1 = self.env['product.product'].create({ 'name': 'Product 1', 'type': 'product', @@ -17,13 +26,10 @@ class TestPicking(TransactionCase): 'list_price': 100.0, 'standard_price': 50.0, 'taxes_id': [(5, 0, 0)], + 'uom_id': self.product_uom_id.id, + 'uom_po_id': self.product_uom_id.id, + 'catch_weight_uom_id': self.ref_uom_id.id, }) - #self.product1 = self.env.ref('product.product_order_01') - self.product1.write({ - 'type': 'product', - 'tracking': 'serial', - }) - self.stock_location = self.env.ref('stock.stock_location_stock') # def test_creation(self): @@ -50,12 +56,13 @@ class TestPicking(TransactionCase): # self.env['stock.quant']._update_available_quantity(self.productA, stock_location, 1.0, lot_id=lot) def test_so_invoice(self): - ratio = 0.8 + ref_weight = 45.0 lot = self.env['stock.production.lot'].create({ 'product_id': self.product1.id, 'name': '123456789', - 'catch_weight_ratio': ratio, + 'catch_weight': ref_weight, }) + self.assertAlmostEqual(lot.catch_weight_ratio, ref_weight / self.nominal_weight) self.env['stock.quant']._update_available_quantity(self.product1, self.stock_location, 1.0, lot_id=lot) so = self.env['sale.order'].create({ 'partner_id': self.partner1.id, @@ -75,20 +82,20 @@ class TestPicking(TransactionCase): inv_id = so.action_invoice_create() inv = self.env['account.invoice'].browse(inv_id) - self.assertEqual(inv.amount_total, ratio * self.product1.list_price) + self.assertAlmostEqual(inv.amount_total, lot.catch_weight_ratio * self.product1.list_price) def test_so_invoice2(self): - ratio1 = 0.8 - ratio2 = 1.1 + ref_weight1 = 45.0 + ref_weight2 = 51.0 lot1 = self.env['stock.production.lot'].create({ 'product_id': self.product1.id, 'name': '1-low', - 'catch_weight_ratio': ratio1, + 'catch_weight': ref_weight1, }) lot2 = self.env['stock.production.lot'].create({ 'product_id': self.product1.id, 'name': '1-high', - 'catch_weight_ratio': ratio2, + 'catch_weight': ref_weight2, }) self.env['stock.quant']._update_available_quantity(self.product1, self.stock_location, 1.0, lot_id=lot1) self.env['stock.quant']._update_available_quantity(self.product1, self.stock_location, 1.0, lot_id=lot2) @@ -111,12 +118,12 @@ class TestPicking(TransactionCase): inv_id = so.action_invoice_create() inv = self.env['account.invoice'].browse(inv_id) - self.assertEqual(inv.amount_total, (ratio1 * self.product1.list_price) + (ratio2 * self.product1.list_price)) + self.assertAlmostEqual(inv.amount_total, self.product1.list_price * (lot1.catch_weight_ratio + lot2.catch_weight_ratio)) def test_po_invoice(self): - ratio1 = 0.8 - ratio2 = 1.1 - ratios = (ratio1, ratio2) + ref_weight1 = 45.0 + ref_weight2 = 51.0 + weights = (ref_weight1, ref_weight2) price = self.product1.standard_price po = self.env['purchase.order'].create({ 'partner_id': self.partner1.id, @@ -135,7 +142,7 @@ class TestPicking(TransactionCase): picking = po.picking_ids for i, line in enumerate(picking.move_lines.move_line_ids): - line.write({'lot_name': str(i), 'qty_done': 1.0, 'lot_catch_weight_ratio': ratios[i]}) + line.write({'lot_name': str(i), 'qty_done': 1.0, 'catch_weight': weights[i]}) picking.button_validate() self.assertEqual(picking.state, 'done') @@ -147,6 +154,5 @@ class TestPicking(TransactionCase): inv.purchase_order_change() self.assertEqual(len(inv.invoice_line_ids), 1) self.assertEqual(inv.invoice_line_ids.quantity, 2.0) - self.assertEqual(inv.amount_total, (ratio1 * price) + (ratio2 * price)) - + self.assertAlmostEqual(inv.amount_total, price * sum(w / self.nominal_weight for w in weights)) diff --git a/product_catch_weight/views/account_invoice_views.xml b/product_catch_weight/views/account_invoice_views.xml new file mode 100644 index 00000000..8eb57740 --- /dev/null +++ b/product_catch_weight/views/account_invoice_views.xml @@ -0,0 +1,69 @@ + + + + account.invoice.form.inherit + account.invoice + + + + + + + + + + account.invoice.supplier.form.inherit + account.invoice + + + + + + + + + + + + \ No newline at end of file diff --git a/product_catch_weight/views/stock_views.xml b/product_catch_weight/views/stock_views.xml index cee50753..40e2d9dd 100644 --- a/product_catch_weight/views/stock_views.xml +++ b/product_catch_weight/views/stock_views.xml @@ -7,17 +7,32 @@ + + - - stock.move.line.form.inherit - stock.move.line - + + + + + + + + + + + + stock.move.operations.form.inherit + stock.move + - - + + + + + {'tree_view_ref': 'stock.view_stock_move_line_operation_tree', 'default_product_uom_id': product_uom, 'default_picking_id': picking_id, 'default_move_id': id, 'default_product_id': product_id, 'default_location_id': location_id, 'default_location_dest_id': location_dest_id, 'default_catch_weight_uom_id': product_catch_weight_uom_id} @@ -27,8 +42,20 @@ - - + + + + + + + + + product.template.common.form.inherit + product.template + + + + From 38960f6b10c27d22f6cb867a010ec1e96a8c7e22 Mon Sep 17 00:00:00 2001 From: Kristen Marie Kulha Date: Thu, 14 Jun 2018 11:17:23 -0700 Subject: [PATCH 09/55] Fix minor typos in l10n_us_ca_hr_payroll manifest. --- l10n_us_ca_hr_payroll/__manifest__.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/l10n_us_ca_hr_payroll/__manifest__.py b/l10n_us_ca_hr_payroll/__manifest__.py index 1d2ae749..c038486a 100755 --- a/l10n_us_ca_hr_payroll/__manifest__.py +++ b/l10n_us_ca_hr_payroll/__manifest__.py @@ -9,14 +9,14 @@ USA::California Payroll Rules. ============================== -* Contribution register and partner for California Department of Taxation - Unemployment Insurance Taz +* Contribution register and partner for California Department of Taxation - Unemployment Insurance Tax * Contribution register and partner for California Department of Taxation - Income Tax Withholding * Contribution register and partner for Califronia Department of Taxation - Employee Training Tax * Contribution register and partner for Califronia Department of Taxation - State Disability Insurance * Contract level California Exemptions * Contract level California State Disability Insurance * Company level California Unemployment Insurance Tax -* Company level California Employee Training Taz +* Company level California Employee Training Tax """, 'auto_install': False, From a5b04fa71e03eef29114791596822b47a7c44733 Mon Sep 17 00:00:00 2001 From: Kristen Marie Kulha Date: Thu, 14 Jun 2018 11:38:14 -0700 Subject: [PATCH 10/55] Add README for `l10n_us_ca_hr_payroll` --- l10n_us_ca_hr_payroll/README.rst | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) create mode 100644 l10n_us_ca_hr_payroll/README.rst diff --git a/l10n_us_ca_hr_payroll/README.rst b/l10n_us_ca_hr_payroll/README.rst new file mode 100644 index 00000000..9d5c1a57 --- /dev/null +++ b/l10n_us_ca_hr_payroll/README.rst @@ -0,0 +1,26 @@ +************************************* +Hibou - US Payroll - California State +************************************* + +Calculations and contribution registers for California State Payroll. + +For more information and add-ons, visit `Hibou.io `_. + +============= +Main Features +============= + +* Contribution registers and partners for: + * California Department of Taxation - Unemployment Insurance Tax + * California Department of Taxation - Income Tax Withholding + * California Department of Taxation - Employee Training Tax + * California Department of Taxation - State Disability Insurance + +* Contract level California Exemptions and California State Disability Insurance +* Company level California Unemployment Insurance Tax and California Employee Training Tax + +======= +License +======= +Please see `LICENSE `_. +Copyright Hibou Corp. 2018 From c92ca5b284d22e4c548153cb29cfa434cfba91dd Mon Sep 17 00:00:00 2001 From: Kristen Marie Kulha Date: Fri, 15 Jun 2018 09:14:48 -0700 Subject: [PATCH 11/55] Add README for `l10n_us_ks_hr_payroll` --- l10n_us_ks_hr_payroll/README.rst | 35 ++++++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) create mode 100644 l10n_us_ks_hr_payroll/README.rst diff --git a/l10n_us_ks_hr_payroll/README.rst b/l10n_us_ks_hr_payroll/README.rst new file mode 100644 index 00000000..ec66e18a --- /dev/null +++ b/l10n_us_ks_hr_payroll/README.rst @@ -0,0 +1,35 @@ +********************************* +Hibou - US Payroll - Kansas State +********************************* +Calculations and contribution registers for Kansas State Payroll. + +For more information and add-ons, visit `Hibou.io `_. + +============= +Main Features +============= + +* New Partner and Contribution Register for Kansas Department of Revenue +* Contract level Kansas Income Tax Rate +* Company Level Kansas Unemployment Rate + + +.. image:: https://user-images.githubusercontent.com/15882954/41478253-d1ff4af4-707b-11e8-8ad3-463fb7ed40da.png + :alt: 'Employee Contract Detail' + :width: 988 + :align: left + +USA Kansas Employee Added to Salary Structure Menu + +.. image:: https://user-images.githubusercontent.com/15882954/41478265-e07e4cb0-707b-11e8-84cc-1529baaee1a6.png + :alt: 'Computed Pay Slip Detail' + :width: 988 + :align: left + +New Payslip Categories for Kansas Income Withholding, Kansas Unemployment Insurance Tax, and Kansas Unemployment Insurance Tax - Wages + +======= +License +======= +Please see `LICENSE `_. +Copyright Hibou Corp. 2018 From f25ffbd2c65038617d8b4500ead7323063260047 Mon Sep 17 00:00:00 2001 From: Kristen Marie Kulha Date: Fri, 15 Jun 2018 09:27:32 -0700 Subject: [PATCH 12/55] Add README.rst for `l10n_us_nj_hr_payroll` --- l10n_us_nj_hr_payroll/README.rst | 50 ++++++++++++++++++++++++++++++++ 1 file changed, 50 insertions(+) create mode 100644 l10n_us_nj_hr_payroll/README.rst diff --git a/l10n_us_nj_hr_payroll/README.rst b/l10n_us_nj_hr_payroll/README.rst new file mode 100644 index 00000000..3a2ca116 --- /dev/null +++ b/l10n_us_nj_hr_payroll/README.rst @@ -0,0 +1,50 @@ +************************************* +Hibou - US Payroll - New Jersey State +************************************* + +Calculations and contribution registers for New Jersey State Payroll. + +For more information and add-ons, visit `Hibou.io `_. + +============= +Main Features +============= + +* New Partner and Contribution Register for New Jersey Department of Taxation +* Company level New Jersey Unemployment Rate +* Company level New Jersey State Disability Insurance Rate +* Contract level New Jersey State Disability Insurance Rate +* Contract level New Jersey Family Leave Insurance Rate +* Contract level New Jersey Workforce Development/Supplemental Workforce Funds + +.. image:: https://user-images.githubusercontent.com/15882954/41478628-533a04e6-707d-11e8-8892-88e65aca231e.png + :alt: 'Employee Contract Detail' + :width: 988 + :align: left + +USA New Jersey Employee Added to Salary Structure Menu + +.. image:: https://user-images.githubusercontent.com/15882954/41478648-62840ce4-707d-11e8-9623-9638b6136f31.png + :alt: 'Computed Pay Slip Detail' + :width: 988 + :align: left + +New Payslip Categories for: + +* New Jersey Income Withholding +* New Jersey Family Leave Insurance Tax - Wages +* New Jersey State Disability Insurance Tax - Wages +* New Jersey Work Force Development - Wages +* New Jersey Unemployment Insurance Tax - Wages +* New Jersey State Workforce/Supplemental Tax +* New Jersey Unemployment Insurance Tax - Employee +* New Jersey Unemployment Insurance Tax - Employer +* New Jersey State Disability Insurance - Employee +* New Jersey State Disability Insurance - Employer +* New Jersey State Family Leave Insurance Tax + +======= +License +======= +Please see `LICENSE `_. +Copyright Hibou Corp. 2018 From f7a9911df704a61b9ce149fe1f77b268739041c6 Mon Sep 17 00:00:00 2001 From: Kristen Marie Kulha Date: Fri, 15 Jun 2018 10:00:20 -0700 Subject: [PATCH 13/55] Add README to `l10n_us_ny_hr_payroll` --- l10n_us_ny_hr_payroll/README.rst | 49 ++++++++++++++++++++++++++++++++ 1 file changed, 49 insertions(+) create mode 100644 l10n_us_ny_hr_payroll/README.rst diff --git a/l10n_us_ny_hr_payroll/README.rst b/l10n_us_ny_hr_payroll/README.rst new file mode 100644 index 00000000..d732880c --- /dev/null +++ b/l10n_us_ny_hr_payroll/README.rst @@ -0,0 +1,49 @@ +************************************* +Hibou - US Payroll - New York State +************************************* + +Calculations and contribution registers for New York State Payroll. + +For more information and add-ons, visit `Hibou.io `_. + +============= +Main Features +============= + +* New Partner and Contribution Register for New York Department of Taxation and Finance +* Company level New York Unemployment Rate +* Company level New York Re-employment Service Fund +* Company level New York Metropolitan Commuter Transportation Mobility Tax +* Contract level New York State Income Tax + +.. image:: https://user-images.githubusercontent.com/15882954/41480034-ef3aa5b8-7081-11e8-990d-3231ebfc2c16.png + :alt: 'Employee Contract Detail' + :width: 988 + :align: left + +USA New York Employee Added to Salary Structure Menu + +.. image:: https://user-images.githubusercontent.com/15882954/41480052-fbad372a-7081-11e8-9421-996d26f449ab.png + :alt: 'Computed Pay Slip Detail' + :width: 988 + :align: left + +New Payslip Categories for: + +* New York Income Withholding +* New York Metropolitan Commuter Transportation Mobility Tax +* New York Unemployment Insurance Tax - Wages +* New York Unemployment Insurance Tax +* New York Re-employment Service Fund + +======= +Notes +======= + +This module does not include tax rules for NYC and Yonkers. + +======= +License +======= +Please see `LICENSE `_. +Copyright Hibou Corp. 2018 From 226319f1b5bc1d7c67631c527d8bef4cfe879c85 Mon Sep 17 00:00:00 2001 From: Kristen Marie Kulha Date: Fri, 15 Jun 2018 10:12:45 -0700 Subject: [PATCH 14/55] Add README to `l10n_us_pa_hr_payroll` --- l10n_us_pa_hr_payroll/README.rst | 45 ++++++++++++++++++++++++++++++++ 1 file changed, 45 insertions(+) create mode 100644 l10n_us_pa_hr_payroll/README.rst diff --git a/l10n_us_pa_hr_payroll/README.rst b/l10n_us_pa_hr_payroll/README.rst new file mode 100644 index 00000000..676423a1 --- /dev/null +++ b/l10n_us_pa_hr_payroll/README.rst @@ -0,0 +1,45 @@ +*************************************** +Hibou - US Payroll - Pennsylvania State +*************************************** + +Calculations and contribution registers for Pennsylvania State Payroll. + +For more information and add-ons, visit `Hibou.io `_. + +============= +Main Features +============= + +* New Partner for Pennsylvania Department of Revenue +* New Contribution Registers for: + * Pennsylvania Department of Revenue - Unemployment Insurance + * Pennsylvania Department of Revenue - State Income Tax +* Contract level Pennsylvania Unemployment Rate +* Contract level Pennsylvania State Income Tax Rate +* Company level Pennsylvania Unemployment Rate + + +.. image:: https://user-images.githubusercontent.com/15882954/41480481-7e9c9b16-7083-11e8-83e0-25d8f37fb2c2.png + :alt: 'Employee Contract Detail' + :width: 988 + :align: left + +USA Pennsylvania Employee Added to Contract Salary Structure Menu + +.. image:: https://user-images.githubusercontent.com/15882954/41480499-8a2d88e6-7083-11e8-88a0-2811356bca34.png + :alt: 'Computed Pay Slip Detail' + :width: 988 + :align: left + +New Payslip Categories for: + +* Pennsylvania Unemployment - Wages +* Pennsylvania Income Withholding +* Pennsylvania Unemployment - Employee +* Pennsylvania Unemployment - Employer + +======= +License +======= +Please see `LICENSE `_. +Copyright Hibou Corp. 2018 From 1f4971caa465090f34c5877c99fb75a89f266443 Mon Sep 17 00:00:00 2001 From: Kristen Marie Kulha Date: Fri, 15 Jun 2018 10:22:44 -0700 Subject: [PATCH 15/55] Add README to `l10n_us_tx_hr_payroll` --- l10n_us_tx_hr_payroll/README.rst | 43 ++++++++++++++++++++++++++++++++ 1 file changed, 43 insertions(+) create mode 100644 l10n_us_tx_hr_payroll/README.rst diff --git a/l10n_us_tx_hr_payroll/README.rst b/l10n_us_tx_hr_payroll/README.rst new file mode 100644 index 00000000..ca37e8c7 --- /dev/null +++ b/l10n_us_tx_hr_payroll/README.rst @@ -0,0 +1,43 @@ +*************************************** +Hibou - US Payroll - Texas State +*************************************** + +Calculations and contribution registers for Texas State Payroll. + +For more information and add-ons, visit `Hibou.io `_. + +============= +Main Features +============= + +* New Partner and Contribution Register for Texas Workforce Commission +* Company level Texas Unemployment Rate +* Company level Texas Obligation Assessment Rate +* Company level Texas Employment & Training Investment Assessment + + +.. image:: https://user-images.githubusercontent.com/15882954/41480987-2a50aa14-7085-11e8-99d4-24773869d2e3.png + :alt: 'Employee Contract Detail' + :width: 988 + :align: left + +USA Texas Employee Added to Salary Structure Menu + +.. image:: https://user-images.githubusercontent.com/15882954/41480994-330626f2-7085-11e8-8ca0-d41d621182ff.png + :alt: 'Computed Pay Slip Detail' + :width: 988 + :align: left + +New Payslip Categories for: + +* Texas Unemployment - Wages +* Texas Unemployment +* Texas Obligation Assessment +* Texas Employment and Training Investment Assessment + + +======= +License +======= +Please see `LICENSE `_. +Copyright Hibou Corp. 2018 From 39613d8593310bf4ec7a7ddb986874ba09789348 Mon Sep 17 00:00:00 2001 From: Kristen Marie Kulha Date: Fri, 15 Jun 2018 11:08:17 -0700 Subject: [PATCH 16/55] Add images to README for `l10n_us_ca_hr_payroll` --- l10n_us_ca_hr_payroll/README.rst | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/l10n_us_ca_hr_payroll/README.rst b/l10n_us_ca_hr_payroll/README.rst index 9d5c1a57..f5549551 100644 --- a/l10n_us_ca_hr_payroll/README.rst +++ b/l10n_us_ca_hr_payroll/README.rst @@ -19,6 +19,29 @@ Main Features * Contract level California Exemptions and California State Disability Insurance * Company level California Unemployment Insurance Tax and California Employee Training Tax +.. image:: https://user-images.githubusercontent.com/15882954/41482877-d1311214-708b-11e8-9400-3bc5c134b836.png + :alt: 'Employee Contract Detail' + :width: 988 + :align: left + +USA California Employee Added to Contract Salary Structure Menu + +.. image:: https://user-images.githubusercontent.com/15882954/41482910-ef25bbd0-708b-11e8-8720-d2065149f953.png + :alt: 'Computed Pay Slip Detail' + :width: 988 + :align: left + +New Payslip Categories for: + +* California Income Withholding +* California State Disability Insurance - Wages +* California State Disability Insurance +* California Employee Training Tax - Wages +* California Unemployment Insurance Tax - Wages +* California Unemployment Insurance Tax +* California Employee Training Tax + + ======= License ======= From 59dce9864424f3c488e1ee8d0c0afb70398b879b Mon Sep 17 00:00:00 2001 From: Kristen Marie Kulha Date: Fri, 15 Jun 2018 11:22:49 -0700 Subject: [PATCH 17/55] Add README to `l10n_us_fl_hr_payroll` --- l10n_us_fl_hr_payroll/README.rst | 37 ++++++++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) create mode 100644 l10n_us_fl_hr_payroll/README.rst diff --git a/l10n_us_fl_hr_payroll/README.rst b/l10n_us_fl_hr_payroll/README.rst new file mode 100644 index 00000000..3ba372bc --- /dev/null +++ b/l10n_us_fl_hr_payroll/README.rst @@ -0,0 +1,37 @@ +********************************** +Hibou - US Payroll - Florida State +********************************** + +Calculations and contribution registers for Florida State Payroll. + +For more information and add-ons, visit `Hibou.io `_. + +============= +Main Features +============= + +* New Partner and Contribution Register for Florida Department of Revenue +* Company level Florida Unemployment Rate + +.. image:: https://user-images.githubusercontent.com/15882954/41440232-a2ca8cb0-6fe2-11e8-9640-0bfd61ae6108.png + :alt: 'Employee Contract Detail' + :width: 988 + :align: left + +USA Florida Employee Added to Contract Salary Structure Menu + +.. image:: https://user-images.githubusercontent.com/15882954/41440247-b7b42744-6fe2-11e8-8ffb-d259eb893646.png + :alt: 'Computed Pay Slip Detail' + :width: 988 + :align: left + +New Payslip Categories for: + +* Florida Unemployment +* Florida Unemployment - Wages + +======= +License +======= +Please see `LICENSE `_. +Copyright Hibou Corp. 2018 From 3308a09a3c422dc7f59249c8ebc4e087cfc63758 Mon Sep 17 00:00:00 2001 From: Kristen Marie Kulha Date: Fri, 15 Jun 2018 11:32:42 -0700 Subject: [PATCH 18/55] Add README to `l10n_us_nc_hr_payroll` --- l10n_us_nc_hr_payroll/README.rst | 41 ++++++++++++++++++++++++++++++++ 1 file changed, 41 insertions(+) create mode 100644 l10n_us_nc_hr_payroll/README.rst diff --git a/l10n_us_nc_hr_payroll/README.rst b/l10n_us_nc_hr_payroll/README.rst new file mode 100644 index 00000000..fad4ccf4 --- /dev/null +++ b/l10n_us_nc_hr_payroll/README.rst @@ -0,0 +1,41 @@ +***************************************** +Hibou - US Payroll - North Carolina State +***************************************** + +Calculations and contribution registers for North Carolina State Payroll. + +For more information and add-ons, visit `Hibou.io `_. + +============= +Main Features +============= + +* Contribution registers and partners for: + * North Carolina Department of Revenue - Income Tax withholding + * North Carolina Department of Taxation - Unemployment +* Contract level North Carolina Exemptions +* Company level North Carolina Unemployment Rate + +.. image:: https://user-images.githubusercontent.com/15882954/41436210-cb7f6a52-6fd5-11e8-9095-48799360f4e9.png + :alt: 'Employee Contract Detail' + :width: 988 + :align: left + +USA North Carolina Employee added to Contract Salary Structure menu + +.. image:: https://user-images.githubusercontent.com/15882954/41436282-fcfd3096-6fd5-11e8-8bc8-3c4985908b23.png + :alt: 'Computed Pay Slip Detail' + :width: 988 + :align: left + +New Payslip Categories for: + +* North Carolina Income Withholding +* North Carolina Unemployment - Wages +* North Carolina Unemployment. + +======= +License +======= +Please see `LICENSE `_. +Copyright Hibou Corp. 2018 From 9e22d3dd44f49524ab2e806a1fb611414a88bd5a Mon Sep 17 00:00:00 2001 From: Kristen Marie Kulha Date: Fri, 15 Jun 2018 11:38:00 -0700 Subject: [PATCH 19/55] Add REAME to `l10n_us_mo_hr_payroll` --- l10n_us_mo_hr_payroll/README.rst | 40 ++++++++++++++++++++++++++++++++ 1 file changed, 40 insertions(+) create mode 100644 l10n_us_mo_hr_payroll/README.rst diff --git a/l10n_us_mo_hr_payroll/README.rst b/l10n_us_mo_hr_payroll/README.rst new file mode 100644 index 00000000..16fbf4d7 --- /dev/null +++ b/l10n_us_mo_hr_payroll/README.rst @@ -0,0 +1,40 @@ +*********************************** +Hibou - US Payroll - Missouri State +*********************************** + +Calculations and contribution registers for Missouri State Payroll. + +For more information and add-ons, visit `Hibou.io `_. + +============= +Main Features +============= + +* New Partners and Contribution Registers for: + * Missouri Department of Revenue - Unemployment + * Missouri Department of Revenue - Income Tax Withholding +* Contract level Missouri Exemptions and MO W-4 fields +* Company level Missouri Unemployment Rate + +.. image:: https://user-images.githubusercontent.com/15882954/41440915-40ac183e-6fe5-11e8-966a-7dd2d85bade1.png + :alt: 'Employee Contract Detail' + :width: 988 + :align: left + +USA Missouri Employee added to Contract Salary Structure Menu + +.. image:: https://user-images.githubusercontent.com/15882954/41440928-4b3fc214-6fe5-11e8-84f4-d35b401963c4.png + :alt: 'Computed Pay Slip Detail' + :width: 988 + :align: left + +New Payslip Categories for: + +* Missouri Income Withholding +* Missouri Unemployment and Missouri Unemployment - Wages + +======= +License +======= +Please see `LICENSE `_. +Copyright Hibou Corp. 2018 From 33a48f175e61c71327f971e300522d07f7603c93 Mon Sep 17 00:00:00 2001 From: Kristen Marie Kulha Date: Fri, 15 Jun 2018 11:39:16 -0700 Subject: [PATCH 20/55] Add REAME to `l10n_us_oh_hr_payroll` --- l10n_us_oh_hr_payroll/README.rst | 42 ++++++++++++++++++++++++++++++++ 1 file changed, 42 insertions(+) create mode 100644 l10n_us_oh_hr_payroll/README.rst diff --git a/l10n_us_oh_hr_payroll/README.rst b/l10n_us_oh_hr_payroll/README.rst new file mode 100644 index 00000000..f3ff923e --- /dev/null +++ b/l10n_us_oh_hr_payroll/README.rst @@ -0,0 +1,42 @@ +******************************* +Hibou - US Payroll - Ohio State +******************************* + +Calculations and contribution registers for Ohio State Payroll. + +For more information and add-ons, visit `Hibou.io `_. + +============= +Main Features +============= + +* New Ohio Department of Revenue partner +* Contribution Registers for: + * Ohio Department of Revenue - Unemployment + * Ohio Department of Revenue - Income Tax withholding +* Contract level Ohio Withholding Allowance +* Company level Ohio Unemployment Rate + +.. image:: https://user-images.githubusercontent.com/15882954/41481725-e1cbd3c4-7087-11e8-8bf7-84843bb2f943.png + :alt: 'Employee Contract Detail' + :width: 988 + :align: left + +USA Ohio Employee added to Contract Salary Structure Menu + +.. image:: https://user-images.githubusercontent.com/15882954/41481743-f1eceb4e-7087-11e8-8d09-dd45551a3fa4.png + :alt: 'Computed Pay Slip Detail' + :width: 988 + :align: left + +New Payslip Categories for: + +* Ohio Income Withholding +* Ohio Unemployment - Wages +* Ohio Unemployment + +======= +License +======= +Please see `LICENSE `_. +Copyright Hibou Corp. 2018 From fc1a7ca3b81a75865afc60640a477bb4bfba9822 Mon Sep 17 00:00:00 2001 From: Kristen Marie Kulha Date: Fri, 15 Jun 2018 11:42:42 -0700 Subject: [PATCH 21/55] Add README to `l10n_us_va_hr_payroll` --- l10n_us_va_hr_payroll/README.rst | 41 ++++++++++++++++++++++++++++++++ 1 file changed, 41 insertions(+) create mode 100644 l10n_us_va_hr_payroll/README.rst diff --git a/l10n_us_va_hr_payroll/README.rst b/l10n_us_va_hr_payroll/README.rst new file mode 100644 index 00000000..0cd35a9e --- /dev/null +++ b/l10n_us_va_hr_payroll/README.rst @@ -0,0 +1,41 @@ +*********************************** +Hibou - US Payroll - Virginia State +*********************************** + +Calculations and contribution registers for Virginia State Payroll. + +For more information and add-ons, visit `Hibou.io `_. + +============= +Main Features +============= + +* Contribution Registers and Partners for: + * Virginia Department of Taxation - Unemployment + * Virginia Department of Taxation Income Tax Withholding +* Contract level Virginia Exemptions +* Company level Virginia Unemployment Rate + +.. image:: https://user-images.githubusercontent.com/15882954/41482220-c4c6b6de-7089-11e8-8c32-50d9c32ba05b.png + :alt: 'Employee Contract Detail' + :width: 988 + :align: left + +USA Virginia Employee added to Contract Salary Structure Menu + +.. image:: https://user-images.githubusercontent.com/15882954/41482325-19f4cc0e-708a-11e8-8c68-c9b56ac7302f.png + :alt: 'Computed Pay Slip Detail' + :width: 988 + :align: left + +New Pay slip Categories for: + +* Virginia Income Withholding +* Virginia Unemployment - Wages +* Virginia Unemployment + +======= +License +======= +Please see `LICENSE `_. +Copyright Hibou Corp. 2018 From f51144325a91c1d37342d7734a79f85b601fb32d Mon Sep 17 00:00:00 2001 From: Kristen Marie Kulha Date: Fri, 15 Jun 2018 12:16:24 -0700 Subject: [PATCH 22/55] Add README to `l10n_us_hr_payroll` --- l10n_us_hr_payroll/README.rst | 44 +++++++++++++++++++++++++++++++++++ 1 file changed, 44 insertions(+) create mode 100644 l10n_us_hr_payroll/README.rst diff --git a/l10n_us_hr_payroll/README.rst b/l10n_us_hr_payroll/README.rst new file mode 100644 index 00000000..0ced18a0 --- /dev/null +++ b/l10n_us_hr_payroll/README.rst @@ -0,0 +1,44 @@ +****************** +Hibou - US Payroll +****************** + +Calculations and contribution registers for United States Payroll. + +For more information and add-ons, visit `Hibou.io `_. + +============= +Main Features +============= + +* Contribution registers and partners for: + * The Electronic Federal Tax Payment System (EFTPS) - Form 941 + * The Electronic Federal Tax Payment System (EFTPS) - Form 940 + * The Electronic Federal Tax Payment System (EFTPS) - Form 941 (FICA + Federal Withholding) + * The Electronic Federal Tax Payment System (EFTPS) - Form 940 (FUTA) + +* Contract level FICA Social Security +* Contract level FICA Employee Medicare +* Contract level FICA Employee Medicare Additional +* Contract level Federal Income Withholding +* Company level FICA Social Security +* Company level FICA Medicare +* Company level FUTA Federal Unemployment + + +.. image:: https://user-images.githubusercontent.com/15882954/41485460-76a0060c-7095-11e8-851a-fec562013ce4.png + :alt: 'Employee Contract Detail' + :width: 988 + :align: left + +USA Employee added to Contract Salary Structure Menu + +.. image:: https://user-images.githubusercontent.com/15882954/41485484-880f0816-7095-11e8-9ad0-874b3270c308.png + :alt: 'Computed Pay Slip Detail' + :width: 988 + :align: left + +======= +License +======= +Please see `LICENSE `_. +Copyright Hibou Corp. 2018 From d790834304c51cfc6af1ff26f11a21eb00b8ed9c Mon Sep 17 00:00:00 2001 From: Kristen Marie Kulha Date: Fri, 6 Jul 2018 13:26:05 -0700 Subject: [PATCH 23/55] Add filing status fields to form views for CA, KS and NJ. --- l10n_us_ca_hr_payroll/hr_payroll_view.xml | 1 + l10n_us_ks_hr_payroll/us_ks_hr_payroll_view.xml | 1 + l10n_us_nj_hr_payroll/us_nj_hr_payroll_view.xml | 2 ++ 3 files changed, 4 insertions(+) diff --git a/l10n_us_ca_hr_payroll/hr_payroll_view.xml b/l10n_us_ca_hr_payroll/hr_payroll_view.xml index 87ef4b6b..15707d29 100755 --- a/l10n_us_ca_hr_payroll/hr_payroll_view.xml +++ b/l10n_us_ca_hr_payroll/hr_payroll_view.xml @@ -23,6 +23,7 @@ + diff --git a/l10n_us_ks_hr_payroll/us_ks_hr_payroll_view.xml b/l10n_us_ks_hr_payroll/us_ks_hr_payroll_view.xml index 4138a303..b0099c95 100755 --- a/l10n_us_ks_hr_payroll/us_ks_hr_payroll_view.xml +++ b/l10n_us_ks_hr_payroll/us_ks_hr_payroll_view.xml @@ -24,6 +24,7 @@ + diff --git a/l10n_us_nj_hr_payroll/us_nj_hr_payroll_view.xml b/l10n_us_nj_hr_payroll/us_nj_hr_payroll_view.xml index e8bc9a4f..537a05f8 100755 --- a/l10n_us_nj_hr_payroll/us_nj_hr_payroll_view.xml +++ b/l10n_us_nj_hr_payroll/us_nj_hr_payroll_view.xml @@ -29,6 +29,8 @@ + + From b7096b5187412c80cc2964f84a4464547864ad27 Mon Sep 17 00:00:00 2001 From: Jared Kipe Date: Sat, 30 Jun 2018 14:46:45 -0700 Subject: [PATCH 24/55] Include external OCA Modules needed to run `connector_magento` and `connector_walmart` --- .gitmodules | 21 +++++++++++++++++++++ account_payment_mode | 1 + account_payment_partner | 1 + account_payment_sale | 1 + base_exception | 1 + base_technical_user | 1 + component | 1 + component_event | 1 + connector | 1 + connector_base_product | 1 + connector_ecommerce | 1 + connector_magento | 1 + external/hibou-oca/bank-payment | 1 + external/hibou-oca/connector | 1 + external/hibou-oca/connector-ecommerce | 1 + external/hibou-oca/connector-magento | 1 + external/hibou-oca/product-attribute | 1 + external/hibou-oca/queue | 1 + external/hibou-oca/sale-workflow | 1 + product_multi_category | 1 + queue_job | 1 + sale_automatic_workflow | 1 + sale_automatic_workflow_payment_mode | 1 + sale_exception | 1 + 24 files changed, 44 insertions(+) create mode 120000 account_payment_mode create mode 120000 account_payment_partner create mode 120000 account_payment_sale create mode 120000 base_exception create mode 120000 base_technical_user create mode 120000 component create mode 120000 component_event create mode 120000 connector create mode 120000 connector_base_product create mode 120000 connector_ecommerce create mode 120000 connector_magento create mode 160000 external/hibou-oca/bank-payment create mode 160000 external/hibou-oca/connector create mode 160000 external/hibou-oca/connector-ecommerce create mode 160000 external/hibou-oca/connector-magento create mode 160000 external/hibou-oca/product-attribute create mode 160000 external/hibou-oca/queue create mode 160000 external/hibou-oca/sale-workflow create mode 120000 product_multi_category create mode 120000 queue_job create mode 120000 sale_automatic_workflow create mode 120000 sale_automatic_workflow_payment_mode create mode 120000 sale_exception diff --git a/.gitmodules b/.gitmodules index 9ef0222b..fa929277 100644 --- a/.gitmodules +++ b/.gitmodules @@ -5,3 +5,24 @@ [submodule "external/hibou-oca/server-tools"] path = external/hibou-oca/server-tools url = https://github.com/hibou-io/oca-server-tools.git +[submodule "external/hibou-oca/connector-magento"] + path = external/hibou-oca/connector-magento + url = https://github.com/hibou-io/oca-connector-magento.git +[submodule "external/hibou-oca/product-attribute"] + path = external/hibou-oca/product-attribute + url = https://github.com/hibou-io/oca-product-attribute.git +[submodule "external/hibou-oca/connector-ecommerce"] + path = external/hibou-oca/connector-ecommerce + url = https://github.com/hibou-io/oca-connector-ecommerce.git +[submodule "external/hibou-oca/connector"] + path = external/hibou-oca/connector + url = https://github.com/hibou-io/oca-connector.git +[submodule "external/hibou-oca/sale-workflow"] + path = external/hibou-oca/sale-workflow + url = https://github.com/hibou-io/oca-sale-workflow.git +[submodule "external/hibou-oca/queue"] + path = external/hibou-oca/queue + url = https://github.com/hibou-io/oca-queue.git +[submodule "external/hibou-oca/bank-payment"] + path = external/hibou-oca/bank-payment + url = https://github.com/hibou-io/oca-bank-payment.git diff --git a/account_payment_mode b/account_payment_mode new file mode 120000 index 00000000..abdce163 --- /dev/null +++ b/account_payment_mode @@ -0,0 +1 @@ +./external/hibou-oca/bank-payment/account_payment_mode \ No newline at end of file diff --git a/account_payment_partner b/account_payment_partner new file mode 120000 index 00000000..205774a0 --- /dev/null +++ b/account_payment_partner @@ -0,0 +1 @@ +./external/hibou-oca/bank-payment/account_payment_partner \ No newline at end of file diff --git a/account_payment_sale b/account_payment_sale new file mode 120000 index 00000000..fa446c3c --- /dev/null +++ b/account_payment_sale @@ -0,0 +1 @@ +./external/hibou-oca/bank-payment/account_payment_sale \ No newline at end of file diff --git a/base_exception b/base_exception new file mode 120000 index 00000000..c13e831d --- /dev/null +++ b/base_exception @@ -0,0 +1 @@ +./external/hibou-oca/server-tools/base_exception \ No newline at end of file diff --git a/base_technical_user b/base_technical_user new file mode 120000 index 00000000..4b54222c --- /dev/null +++ b/base_technical_user @@ -0,0 +1 @@ +./external/hibou-oca/server-tools/base_technical_user \ No newline at end of file diff --git a/component b/component new file mode 120000 index 00000000..5456c899 --- /dev/null +++ b/component @@ -0,0 +1 @@ +./external/hibou-oca/connector/component \ No newline at end of file diff --git a/component_event b/component_event new file mode 120000 index 00000000..4d789911 --- /dev/null +++ b/component_event @@ -0,0 +1 @@ +./external/hibou-oca/connector/component_event \ No newline at end of file diff --git a/connector b/connector new file mode 120000 index 00000000..72947cb6 --- /dev/null +++ b/connector @@ -0,0 +1 @@ +./external/hibou-oca/connector/connector \ No newline at end of file diff --git a/connector_base_product b/connector_base_product new file mode 120000 index 00000000..6d720235 --- /dev/null +++ b/connector_base_product @@ -0,0 +1 @@ +./external/hibou-oca/connector/connector_base_product \ No newline at end of file diff --git a/connector_ecommerce b/connector_ecommerce new file mode 120000 index 00000000..214eb361 --- /dev/null +++ b/connector_ecommerce @@ -0,0 +1 @@ +./external/hibou-oca/connector-ecommerce/connector_ecommerce \ No newline at end of file diff --git a/connector_magento b/connector_magento new file mode 120000 index 00000000..9ac6562f --- /dev/null +++ b/connector_magento @@ -0,0 +1 @@ +./external/hibou-oca/connector-magento/connector_magento \ No newline at end of file diff --git a/external/hibou-oca/bank-payment b/external/hibou-oca/bank-payment new file mode 160000 index 00000000..c7630c7f --- /dev/null +++ b/external/hibou-oca/bank-payment @@ -0,0 +1 @@ +Subproject commit c7630c7f853f7ff9431459fff2d8cdede58cc692 diff --git a/external/hibou-oca/connector b/external/hibou-oca/connector new file mode 160000 index 00000000..5c3a125d --- /dev/null +++ b/external/hibou-oca/connector @@ -0,0 +1 @@ +Subproject commit 5c3a125d6a51b241bd2e0a20119824a24f0863e4 diff --git a/external/hibou-oca/connector-ecommerce b/external/hibou-oca/connector-ecommerce new file mode 160000 index 00000000..e17f3ae1 --- /dev/null +++ b/external/hibou-oca/connector-ecommerce @@ -0,0 +1 @@ +Subproject commit e17f3ae1734496c2b0423363b42e0ef47a69da93 diff --git a/external/hibou-oca/connector-magento b/external/hibou-oca/connector-magento new file mode 160000 index 00000000..d1b99167 --- /dev/null +++ b/external/hibou-oca/connector-magento @@ -0,0 +1 @@ +Subproject commit d1b991671d0b0daae301288b47859eabd1a5794e diff --git a/external/hibou-oca/product-attribute b/external/hibou-oca/product-attribute new file mode 160000 index 00000000..6d906de3 --- /dev/null +++ b/external/hibou-oca/product-attribute @@ -0,0 +1 @@ +Subproject commit 6d906de3bd0eb4f808bf37619196305c85bc56fa diff --git a/external/hibou-oca/queue b/external/hibou-oca/queue new file mode 160000 index 00000000..9d3c193e --- /dev/null +++ b/external/hibou-oca/queue @@ -0,0 +1 @@ +Subproject commit 9d3c193e62c8aa64d63496ae79e18b3b53bba95e diff --git a/external/hibou-oca/sale-workflow b/external/hibou-oca/sale-workflow new file mode 160000 index 00000000..5480bfd0 --- /dev/null +++ b/external/hibou-oca/sale-workflow @@ -0,0 +1 @@ +Subproject commit 5480bfd03037f9c4acfb82e1d68cb6f3f1a7e40b diff --git a/product_multi_category b/product_multi_category new file mode 120000 index 00000000..72f5a83d --- /dev/null +++ b/product_multi_category @@ -0,0 +1 @@ +./external/hibou-oca/product-attribute/product_multi_category \ No newline at end of file diff --git a/queue_job b/queue_job new file mode 120000 index 00000000..bf3ebe44 --- /dev/null +++ b/queue_job @@ -0,0 +1 @@ +./external/hibou-oca/queue/queue_job \ No newline at end of file diff --git a/sale_automatic_workflow b/sale_automatic_workflow new file mode 120000 index 00000000..64ec402c --- /dev/null +++ b/sale_automatic_workflow @@ -0,0 +1 @@ +./external/hibou-oca/sale-workflow/sale_automatic_workflow \ No newline at end of file diff --git a/sale_automatic_workflow_payment_mode b/sale_automatic_workflow_payment_mode new file mode 120000 index 00000000..fbfc6dd2 --- /dev/null +++ b/sale_automatic_workflow_payment_mode @@ -0,0 +1 @@ +./external/hibou-oca/sale-workflow/sale_automatic_workflow_payment_mode \ No newline at end of file diff --git a/sale_exception b/sale_exception new file mode 120000 index 00000000..f1797a5a --- /dev/null +++ b/sale_exception @@ -0,0 +1 @@ +./external/hibou-oca/sale-workflow/sale_exception \ No newline at end of file From cfd25c425bf74ff8f5d223ea79dffd71bc07444d Mon Sep 17 00:00:00 2001 From: Jared Kipe Date: Sat, 7 Jul 2018 12:43:35 -0700 Subject: [PATCH 25/55] Initial commit of `connector_walmart` for Odoo 11.0 (using beta version of `connector_ecommerce`) --- connector_walmart/__init__.py | 2 + connector_walmart/__manifest__.py | 29 ++ connector_walmart/components/__init__.py | 6 + connector_walmart/components/api/__init__.py | 1 + connector_walmart/components/api/walmart.py | 407 ++++++++++++++++++ .../components/backend_adapter.py | 67 +++ connector_walmart/components/binder.py | 22 + connector_walmart/components/exporter.py | 313 ++++++++++++++ connector_walmart/components/importer.py | 324 ++++++++++++++ connector_walmart/components/mapper.py | 16 + .../data/connector_walmart_data.xml | 54 +++ connector_walmart/models/__init__.py | 6 + connector_walmart/models/account/__init__.py | 1 + .../models/account/account_fiscal_position.py | 68 +++ connector_walmart/models/delivery/__init__.py | 1 + connector_walmart/models/delivery/common.py | 52 +++ .../models/sale_order/__init__.py | 2 + connector_walmart/models/sale_order/common.py | 209 +++++++++ .../models/sale_order/importer.py | 347 +++++++++++++++ .../models/stock_picking/__init__.py | 2 + .../models/stock_picking/common.py | 95 ++++ .../models/stock_picking/exporter.py | 78 ++++ .../models/walmart_backend/__init__.py | 1 + .../models/walmart_backend/common.py | 128 ++++++ .../models/walmart_binding/__init__.py | 1 + .../models/walmart_binding/common.py | 66 +++ .../security/ir.model.access.csv | 14 + connector_walmart/views/account_views.xml | 15 + .../views/connector_walmart_menu.xml | 15 + connector_walmart/views/delivery_views.xml | 20 + connector_walmart/views/sale_order_views.xml | 53 +++ .../views/walmart_backend_views.xml | 101 +++++ external/hibou-oca/connector-ecommerce | 2 +- external/hibou-oca/sale-workflow | 2 +- 34 files changed, 2518 insertions(+), 2 deletions(-) create mode 100644 connector_walmart/__init__.py create mode 100644 connector_walmart/__manifest__.py create mode 100644 connector_walmart/components/__init__.py create mode 100644 connector_walmart/components/api/__init__.py create mode 100644 connector_walmart/components/api/walmart.py create mode 100644 connector_walmart/components/backend_adapter.py create mode 100644 connector_walmart/components/binder.py create mode 100644 connector_walmart/components/exporter.py create mode 100644 connector_walmart/components/importer.py create mode 100644 connector_walmart/components/mapper.py create mode 100644 connector_walmart/data/connector_walmart_data.xml create mode 100644 connector_walmart/models/__init__.py create mode 100644 connector_walmart/models/account/__init__.py create mode 100644 connector_walmart/models/account/account_fiscal_position.py create mode 100644 connector_walmart/models/delivery/__init__.py create mode 100644 connector_walmart/models/delivery/common.py create mode 100644 connector_walmart/models/sale_order/__init__.py create mode 100644 connector_walmart/models/sale_order/common.py create mode 100644 connector_walmart/models/sale_order/importer.py create mode 100644 connector_walmart/models/stock_picking/__init__.py create mode 100644 connector_walmart/models/stock_picking/common.py create mode 100644 connector_walmart/models/stock_picking/exporter.py create mode 100644 connector_walmart/models/walmart_backend/__init__.py create mode 100644 connector_walmart/models/walmart_backend/common.py create mode 100644 connector_walmart/models/walmart_binding/__init__.py create mode 100644 connector_walmart/models/walmart_binding/common.py create mode 100644 connector_walmart/security/ir.model.access.csv create mode 100644 connector_walmart/views/account_views.xml create mode 100644 connector_walmart/views/connector_walmart_menu.xml create mode 100644 connector_walmart/views/delivery_views.xml create mode 100644 connector_walmart/views/sale_order_views.xml create mode 100644 connector_walmart/views/walmart_backend_views.xml diff --git a/connector_walmart/__init__.py b/connector_walmart/__init__.py new file mode 100644 index 00000000..f24d3e24 --- /dev/null +++ b/connector_walmart/__init__.py @@ -0,0 +1,2 @@ +from . import components +from . import models diff --git a/connector_walmart/__manifest__.py b/connector_walmart/__manifest__.py new file mode 100644 index 00000000..e20bd78e --- /dev/null +++ b/connector_walmart/__manifest__.py @@ -0,0 +1,29 @@ +# © 2017,2018 Hibou Corp. +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +{ + 'name': 'Walmart Connector', + 'version': '11.0.1.0.0', + 'category': 'Connector', + 'depends': [ + 'account', + 'product', + 'delivery', + 'sale_stock', + 'connector_ecommerce', + ], + 'author': "Hibou Corp.", + 'license': 'AGPL-3', + 'website': 'https://hibou.io', + 'data': [ + 'views/walmart_backend_views.xml', + 'views/connector_walmart_menu.xml', + 'views/sale_order_views.xml', + 'views/account_views.xml', + 'views/delivery_views.xml', + 'security/ir.model.access.csv', + 'data/connector_walmart_data.xml', + ], + 'installable': True, + 'application': False, +} diff --git a/connector_walmart/components/__init__.py b/connector_walmart/components/__init__.py new file mode 100644 index 00000000..ad96af21 --- /dev/null +++ b/connector_walmart/components/__init__.py @@ -0,0 +1,6 @@ +from . import api +from . import backend_adapter +from . import binder +from . import importer +from . import exporter +from . import mapper diff --git a/connector_walmart/components/api/__init__.py b/connector_walmart/components/api/__init__.py new file mode 100644 index 00000000..ff014f05 --- /dev/null +++ b/connector_walmart/components/api/__init__.py @@ -0,0 +1 @@ +from . import walmart diff --git a/connector_walmart/components/api/walmart.py b/connector_walmart/components/api/walmart.py new file mode 100644 index 00000000..dfcf41d8 --- /dev/null +++ b/connector_walmart/components/api/walmart.py @@ -0,0 +1,407 @@ +# -*- coding: utf-8 -*- + +# BSD License +# +# Copyright (c) 2016, Fulfil.IO Inc. +# All rights reserved. +# +# Redistribution and use in source and binary forms, with or without modification, +# are permitted provided that the following conditions are met: +# +# * Redistributions of source code must retain the above copyright notice, this +# list of conditions and the following disclaimer. +# +# * Redistributions in binary form must reproduce the above copyright notice, this +# list of conditions and the following disclaimer in the documentation and/or +# other materials provided with the distribution. +# +# * Neither the name of Fulfil nor the names of its +# contributors may be used to endorse or promote products derived from this +# software without specific prior written permission. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +# ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +# WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. +# IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, +# INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, +# BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +# DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY +# OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE +# OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED +# OF THE POSSIBILITY OF SUCH DAMAGE. + +# © 2017 Hibou Corp. - Extended and converted to v3/JSON + + +import requests +import base64 +import time +from uuid import uuid4 +# from lxml import etree +# from lxml.builder import E, ElementMaker +from json import dumps, loads + +from Crypto.Hash import SHA256 +from Crypto.PublicKey import RSA +from Crypto.Signature import PKCS1_v1_5 + +try: + from urllib.parse import urlencode +except ImportError: + from urllib import urlencode + + +class Walmart(object): + + def __init__(self, consumer_id, channel_type, private_key): + self.base_url = 'https://marketplace.walmartapis.com/v3/%s' + self.consumer_id = consumer_id + self.channel_type = channel_type + self.private_key = private_key + self.session = requests.Session() + self.session.headers['Accept'] = 'application/json' + self.session.headers['WM_SVC.NAME'] = 'Walmart Marketplace' + self.session.headers['WM_CONSUMER.ID'] = self.consumer_id + self.session.headers['WM_CONSUMER.CHANNEL.TYPE'] = self.channel_type + + @property + def items(self): + return Items(connection=self) + + @property + def inventory(self): + return Inventory(connection=self) + + @property + def prices(self): + return Prices(connection=self) + + @property + def orders(self): + return Orders(connection=self) + + def get_sign(self, url, method, timestamp): + return self.sign_data( + '\n'.join([self.consumer_id, url, method, timestamp]) + '\n' + ) + + def sign_data(self, data): + rsakey = RSA.importKey(base64.b64decode(self.private_key)) + signer = PKCS1_v1_5.new(rsakey) + digest = SHA256.new() + digest.update(data.encode('utf-8')) + sign = signer.sign(digest) + return base64.b64encode(sign) + + def get_headers(self, url, method): + timestamp = str(int(round(time.time() * 1000))) + headers = { + 'WM_SEC.AUTH_SIGNATURE': self.get_sign(url, method, timestamp), + 'WM_SEC.TIMESTAMP': timestamp, + 'WM_QOS.CORRELATION_ID': str(uuid4()), + } + if method in ('POST', ): + headers['Content-Type'] = 'application/json' + return headers + + def send_request(self, method, url, params=None, body=None): + encoded_url = url + if params: + encoded_url += '?%s' % urlencode(params) + headers = self.get_headers(encoded_url, method) + + if method == 'GET': + return loads(self.session.get(url, params=params, headers=headers).text) + elif method == 'PUT': + return loads(self.session.put(url, params=params, headers=headers).text) + elif method == 'POST': + return loads(self.session.post(url, data=body, headers=headers).text) + + +class Resource(object): + """ + A base class for all Resources to extend + """ + + def __init__(self, connection): + self.connection = connection + + @property + def url(self): + return self.connection.base_url % self.path + + def all(self, **kwargs): + return self.connection.send_request( + method='GET', url=self.url, params=kwargs) + + def get(self, id): + url = self.url + '/%s' % id + return self.connection.send_request(method='GET', url=url) + + def update(self, **kwargs): + return self.connection.send_request( + method='PUT', url=self.url, params=kwargs) + + # def bulk_update(self, items): + # url = self.connection.base_url % 'feeds?feedType=%s' % self.feedType + # return self.connection.send_request( + # method='POST', url=url, data=self.get_payload(items)) + + +class Items(Resource): + """ + Get all items + """ + + path = 'items' + + +class Inventory(Resource): + """ + Retreives inventory of an item + """ + + path = 'inventory' + feedType = 'inventory' + + def get_payload(self, items): + return etree.tostring( + E.InventoryFeed( + E.InventoryHeader(E('version', '1.4')), + *[E( + 'inventory', + E('sku', item['sku']), + E( + 'quantity', + E('unit', 'EACH'), + E('amount', item['quantity']), + ) + ) for item in items], + xmlns='http://walmart.com/' + ) + ) + + +class Prices(Resource): + """ + Retreives price of an item + """ + + path = 'prices' + feedType = 'price' + + def get_payload(self, items): + # root = ElementMaker( + # nsmap={'gmp': 'http://walmart.com/'} + # ) + # return etree.tostring( + # root.PriceFeed( + # E.PriceHeader(E('version', '1.5')), + # *[E.Price( + # E( + # 'itemIdentifier', + # E('sku', item['sku']) + # ), + # E( + # 'pricingList', + # E( + # 'pricing', + # E( + # 'currentPrice', + # E( + # 'value', + # **{ + # 'currency': item['currenctCurrency'], + # 'amount': item['currenctPrice'] + # } + # ) + # ), + # E('currentPriceType', item['priceType']), + # E( + # 'comparisonPrice', + # E( + # 'value', + # **{ + # 'currency': item['comparisonCurrency'], + # 'amount': item['comparisonPrice'] + # } + # ) + # ), + # E( + # 'priceDisplayCode', + # **{ + # 'submapType': item['displayCode'] + # } + # ), + # ) + # ) + # ) for item in items] + # ), xml_declaration=True, encoding='utf-8' + # ) + payload = {} + return + + +class Orders(Resource): + """ + Retrieves Order details + """ + + path = 'orders' + + def all(self, **kwargs): + next_cursor = kwargs.pop('nextCursor', '') + return self.connection.send_request( + method='GET', url=self.url + next_cursor, params=kwargs) + + def released(self, **kwargs): + next_cursor = kwargs.pop('nextCursor', '') + url = self.url + '/released' + return self.connection.send_request( + method='GET', url=url + next_cursor, params=kwargs) + + def acknowledge(self, id): + url = self.url + '/%s/acknowledge' % id + return self.connection.send_request(method='POST', url=url) + + def cancel(self, id, lines): + url = self.url + '/%s/cancel' % id + return self.connection.send_request( + method='POST', url=url, body=self.get_cancel_payload(lines)) + + def get_cancel_payload(self, lines): + """ + { + "orderCancellation": { + "orderLines": { + "orderLine": [ + { + "lineNumber": "1", + "orderLineStatuses": { + "orderLineStatus": [ + { + "status": "Cancelled", + "cancellationReason": "CUSTOMER_REQUESTED_SELLER_TO_CANCEL", + "statusQuantity": { + "unitOfMeasurement": "EA", + "amount": "1" + } + } + ] + } + } + ] + } + } + } + :param lines: + :return: string + """ + payload = { + 'orderCancellation': { + 'orderLines': [{ + 'lineNumber': line['number'], + 'orderLineStatuses': { + 'orderLineStatus': [ + { + 'status': 'Cancelled', + 'cancellationReason': 'CUSTOMER_REQUESTED_SELLER_TO_CANCEL', + 'statusQuantity': { + 'unitOfMeasurement': 'EA', + 'amount': line['amount'], + } + } + ] + } + } for line in lines] + } + } + return dumps(payload) + + + + def ship(self, id, lines): + url = self.url + '/%s/shipping' % id + return self.connection.send_request( + method='POST', + url=url, + body=self.get_ship_payload(lines) + ) + + def get_ship_payload(self, lines): + """ + + :param lines: list[ dict(number, amount, carrier, methodCode, trackingNumber, trackingUrl) ] + :return: + """ + """ + { + "orderShipment": { + "orderLines": { + "orderLine": [ + { + "lineNumber": "1", + "orderLineStatuses": { + "orderLineStatus": [ + { + "status": "Shipped", + "statusQuantity": { + "unitOfMeasurement": "EA", + "amount": "1" + }, + "trackingInfo": { + "shipDateTime": 1488480443000, + "carrierName": { + "otherCarrier": null, + "carrier": "UPS" + }, + "methodCode": "Express", + "trackingNumber": "12345", + "trackingURL": "www.fedex.com" + } + } + ] + } + } + ] + } + } + } + :param lines: + :return: + """ + + payload = { + "orderShipment": { + "orderLines": { + "orderLine": [ + { + "lineNumber": str(line['number']), + "orderLineStatuses": { + "orderLineStatus": [ + { + "status": "Shipped", + "statusQuantity": { + "unitOfMeasurement": "EA", + "amount": str(line['amount']) + }, + "trackingInfo": { + "shipDateTime": line['shipDateTime'], + "carrierName": { + "otherCarrier": None, + "carrier": line['carrier'] + }, + "methodCode": line['methodCode'], + "trackingNumber": line['trackingNumber'], + "trackingURL": line['trackingUrl'] + } + } + ] + } + } + for line in lines] + } + } + } + + return dumps(payload) \ No newline at end of file diff --git a/connector_walmart/components/backend_adapter.py b/connector_walmart/components/backend_adapter.py new file mode 100644 index 00000000..0ab00aee --- /dev/null +++ b/connector_walmart/components/backend_adapter.py @@ -0,0 +1,67 @@ +# © 2017,2018 Hibou Corp. +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from odoo.addons.component.core import AbstractComponent +from odoo.addons.queue_job.exception import RetryableJobError +from odoo.addons.connector.exception import NetworkRetryableError +from .api.walmart import Walmart +from logging import getLogger +from lxml import etree + +_logger = getLogger(__name__) + + +class BaseWalmartConnectorComponent(AbstractComponent): + """ Base Walmart Connector Component + + All components of this connector should inherit from it. + """ + _name = 'base.walmart.connector' + _inherit = 'base.connector' + _collection = 'walmart.backend' + + +class WalmartAdapter(AbstractComponent): + + _name = 'walmart.adapter' + _inherit = ['base.backend.adapter', 'base.walmart.connector'] + + _walmart_model = None + + def search(self, filters=None): + """ Search records according to some criterias + and returns a list of ids """ + raise NotImplementedError + + def read(self, id, attributes=None): + """ Returns the information of a record """ + raise NotImplementedError + + def search_read(self, filters=None): + """ Search records according to some criterias + and returns their information""" + raise NotImplementedError + + def create(self, data): + """ Create a record on the external system """ + raise NotImplementedError + + def write(self, id, data): + """ Update records on the external system """ + raise NotImplementedError + + def delete(self, id): + """ Delete a record on the external system """ + raise NotImplementedError + + @property + def api_instance(self): + try: + walmart_api = getattr(self.work, 'walmart_api') + except AttributeError: + raise AttributeError( + 'You must provide a walmart_api attribute with a ' + 'Walmart instance to be able to use the ' + 'Backend Adapter.' + ) + return walmart_api diff --git a/connector_walmart/components/binder.py b/connector_walmart/components/binder.py new file mode 100644 index 00000000..21d949ff --- /dev/null +++ b/connector_walmart/components/binder.py @@ -0,0 +1,22 @@ +# © 2017,2018 Hibou Corp. +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from odoo.addons.component.core import Component + + +class WalmartModelBinder(Component): + """ Bind records and give odoo/walmart ids correspondence + + Binding models are models called ``walmart.{normal_model}``, + like ``walmart.sale.order`` or ``walmart.product.product``. + They are ``_inherits`` of the normal models and contains + the Walmart ID, the ID of the Walmart Backend and the additional + fields belonging to the Walmart instance. + """ + _name = 'walmart.binder' + _inherit = ['base.binder', 'base.walmart.connector'] + _apply_on = [ + 'walmart.sale.order', + 'walmart.sale.order.line', + 'walmart.stock.picking', + ] diff --git a/connector_walmart/components/exporter.py b/connector_walmart/components/exporter.py new file mode 100644 index 00000000..5b305f69 --- /dev/null +++ b/connector_walmart/components/exporter.py @@ -0,0 +1,313 @@ +# © 2017,2018 Hibou Corp. +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +import logging + +from contextlib import contextmanager +from datetime import datetime + +import psycopg2 + +import odoo +from odoo import _ +from odoo.addons.component.core import AbstractComponent +from odoo.addons.connector.exception import (IDMissingInBackend, + RetryableJobError) + +_logger = logging.getLogger(__name__) + + + + +class WalmartBaseExporter(AbstractComponent): + """ Base exporter for Walmart """ + + _name = 'walmart.base.exporter' + _inherit = ['base.exporter', 'base.walmart.connector'] + _usage = 'record.exporter' + + def __init__(self, working_context): + super(WalmartBaseExporter, self).__init__(working_context) + self.binding = None + self.external_id = None + + def run(self, binding, *args, **kwargs): + """ Run the synchronization + + :param binding: binding record to export + """ + self.binding = binding + self.external_id = self.binder.to_external(self.binding) + + result = self._run(*args, **kwargs) + + self.binder.bind(self.external_id, self.binding) + # Commit so we keep the external ID when there are several + # exports (due to dependencies) and one of them fails. + # The commit will also release the lock acquired on the binding + # record + if not odoo.tools.config['test_enable']: + self.env.cr.commit() # noqa + + self._after_export() + return result + + def _run(self): + """ Flow of the synchronization, implemented in inherited classes""" + raise NotImplementedError + + def _after_export(self): + """ Can do several actions after exporting a record to Walmart """ + pass + + +class WalmartExporter(AbstractComponent): + """ A common flow for the exports to Walmart """ + + _name = 'walmart.exporter' + _inherit = 'walmart.base.exporter' + + def __init__(self, working_context): + super(WalmartExporter, self).__init__(working_context) + self.binding = None + + def _lock(self): + """ Lock the binding record. + + Lock the binding record so we are sure that only one export + job is running for this record if concurrent jobs have to export the + same record. + + When concurrent jobs try to export the same record, the first one + will lock and proceed, the others will fail to lock and will be + retried later. + + This behavior works also when the export becomes multilevel + with :meth:`_export_dependencies`. Each level will set its own lock + on the binding record it has to export. + + """ + sql = ("SELECT id FROM %s WHERE ID = %%s FOR UPDATE NOWAIT" % + self.model._table) + try: + self.env.cr.execute(sql, (self.binding.id, ), + log_exceptions=False) + except psycopg2.OperationalError: + _logger.info('A concurrent job is already exporting the same ' + 'record (%s with id %s). Job delayed later.', + self.model._name, self.binding.id) + raise RetryableJobError( + 'A concurrent job is already exporting the same record ' + '(%s with id %s). The job will be retried later.' % + (self.model._name, self.binding.id)) + + def _has_to_skip(self): + """ Return True if the export can be skipped """ + return False + + @contextmanager + def _retry_unique_violation(self): + """ Context manager: catch Unique constraint error and retry the + job later. + + When we execute several jobs workers concurrently, it happens + that 2 jobs are creating the same record at the same time (binding + record created by :meth:`_export_dependency`), resulting in: + + IntegrityError: duplicate key value violates unique + constraint "walmart_product_product_odoo_uniq" + DETAIL: Key (backend_id, odoo_id)=(1, 4851) already exists. + + In that case, we'll retry the import just later. + + .. warning:: The unique constraint must be created on the + binding record to prevent 2 bindings to be created + for the same Walmart record. + + """ + try: + yield + except psycopg2.IntegrityError as err: + if err.pgcode == psycopg2.errorcodes.UNIQUE_VIOLATION: + raise RetryableJobError( + 'A database error caused the failure of the job:\n' + '%s\n\n' + 'Likely due to 2 concurrent jobs wanting to create ' + 'the same record. The job will be retried later.' % err) + else: + raise + + def _export_dependency(self, relation, binding_model, + component_usage='record.exporter', + binding_field='walmart_bind_ids', + binding_extra_vals=None): + """ + Export a dependency. The exporter class is a subclass of + ``WalmartExporter``. If a more precise class need to be defined, + it can be passed to the ``exporter_class`` keyword argument. + + .. warning:: a commit is done at the end of the export of each + dependency. The reason for that is that we pushed a record + on the backend and we absolutely have to keep its ID. + + So you *must* take care not to modify the Odoo + database during an export, excepted when writing + back the external ID or eventually to store + external data that we have to keep on this side. + + You should call this method only at the beginning + of the exporter synchronization, + in :meth:`~._export_dependencies`. + + :param relation: record to export if not already exported + :type relation: :py:class:`odoo.models.BaseModel` + :param binding_model: name of the binding model for the relation + :type binding_model: str | unicode + :param component_usage: 'usage' to look for to find the Component to + for the export, by default 'record.exporter' + :type exporter: str | unicode + :param binding_field: name of the one2many field on a normal + record that points to the binding record + (default: walmart_bind_ids). + It is used only when the relation is not + a binding but is a normal record. + :type binding_field: str | unicode + :binding_extra_vals: In case we want to create a new binding + pass extra values for this binding + :type binding_extra_vals: dict + """ + if not relation: + return + rel_binder = self.binder_for(binding_model) + # wrap is typically True if the relation is for instance a + # 'product.product' record but the binding model is + # 'walmart.product.product' + wrap = relation._name != binding_model + + if wrap and hasattr(relation, binding_field): + domain = [('odoo_id', '=', relation.id), + ('backend_id', '=', self.backend_record.id)] + binding = self.env[binding_model].search(domain) + if binding: + assert len(binding) == 1, ( + 'only 1 binding for a backend is ' + 'supported in _export_dependency') + # we are working with a unwrapped record (e.g. + # product.category) and the binding does not exist yet. + # Example: I created a product.product and its binding + # walmart.product.product and we are exporting it, but we need to + # create the binding for the product.category on which it + # depends. + else: + bind_values = {'backend_id': self.backend_record.id, + 'odoo_id': relation.id} + if binding_extra_vals: + bind_values.update(binding_extra_vals) + # If 2 jobs create it at the same time, retry + # one later. A unique constraint (backend_id, + # odoo_id) should exist on the binding model + with self._retry_unique_violation(): + binding = (self.env[binding_model] + .with_context(connector_no_export=True) + .sudo() + .create(bind_values)) + # Eager commit to avoid having 2 jobs + # exporting at the same time. The constraint + # will pop if an other job already created + # the same binding. It will be caught and + # raise a RetryableJobError. + if not odoo.tools.config['test_enable']: + self.env.cr.commit() # noqa + else: + # If walmart_bind_ids does not exist we are typically in a + # "direct" binding (the binding record is the same record). + # If wrap is True, relation is already a binding record. + binding = relation + + if not rel_binder.to_external(binding): + exporter = self.component(usage=component_usage, + model_name=binding_model) + exporter.run(binding) + + def _export_dependencies(self): + """ Export the dependencies for the record""" + return + + def _map_data(self): + """ Returns an instance of + :py:class:`~odoo.addons.connector.components.mapper.MapRecord` + + """ + return self.mapper.map_record(self.binding) + + def _validate_create_data(self, data): + """ Check if the values to import are correct + + Pro-actively check before the ``Model.create`` if some fields + are missing or invalid + + Raise `InvalidDataError` + """ + return + + def _validate_update_data(self, data): + """ Check if the values to import are correct + + Pro-actively check before the ``Model.update`` if some fields + are missing or invalid + + Raise `InvalidDataError` + """ + return + + def _create_data(self, map_record, fields=None, **kwargs): + """ Get the data to pass to :py:meth:`_create` """ + return map_record.values(for_create=True, fields=fields, **kwargs) + + def _create(self, data): + """ Create the Walmart record """ + # special check on data before export + self._validate_create_data(data) + return self.backend_adapter.create(data) + + def _update_data(self, map_record, fields=None, **kwargs): + """ Get the data to pass to :py:meth:`_update` """ + return map_record.values(fields=fields, **kwargs) + + def _update(self, data): + """ Update an Walmart record """ + assert self.external_id + # special check on data before export + self._validate_update_data(data) + self.backend_adapter.write(self.external_id, data) + + def _run(self, fields=None): + """ Flow of the synchronization, implemented in inherited classes""" + assert self.binding + + if not self.external_id: + fields = None # should be created with all the fields + + if self._has_to_skip(): + return + + # export the missing linked resources + self._export_dependencies() + + # prevent other jobs to export the same record + # will be released on commit (or rollback) + self._lock() + + map_record = self._map_data() + + if self.external_id: + record = self._update_data(map_record, fields=fields) + if not record: + return _('Nothing to export.') + self._update(record) + else: + record = self._create_data(map_record, fields=fields) + if not record: + return _('Nothing to export.') + self.external_id = self._create(record) + return _('Record exported with ID %s on Walmart.') % self.external_id diff --git a/connector_walmart/components/importer.py b/connector_walmart/components/importer.py new file mode 100644 index 00000000..cb677553 --- /dev/null +++ b/connector_walmart/components/importer.py @@ -0,0 +1,324 @@ +# © 2017,2018 Hibou Corp. +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +""" + +Importers for Walmart. + +An import can be skipped if the last sync date is more recent than +the last update in Walmart. + +They should call the ``bind`` method if the binder even if the records +are already bound, to update the last sync date. + +""" + +import logging +from odoo import fields, _ +from odoo.addons.component.core import AbstractComponent, Component +from odoo.addons.connector.exception import IDMissingInBackend +from odoo.addons.queue_job.exception import NothingToDoJob + + +_logger = logging.getLogger(__name__) + + +class WalmartImporter(AbstractComponent): + """ Base importer for Walmart """ + + _name = 'walmart.importer' + _inherit = ['base.importer', 'base.walmart.connector'] + _usage = 'record.importer' + + def __init__(self, work_context): + super(WalmartImporter, self).__init__(work_context) + self.external_id = None + self.walmart_record = None + + def _get_walmart_data(self): + """ Return the raw Walmart data for ``self.external_id`` """ + return self.backend_adapter.read(self.external_id) + + def _before_import(self): + """ Hook called before the import, when we have the Walmart + data""" + + def _is_uptodate(self, binding): + """Return True if the import should be skipped because + it is already up-to-date in Odoo""" + assert self.walmart_record + if not self.walmart_record.get('updated_at'): + return # no update date on Walmart, always import it. + if not binding: + return # it does not exist so it should not be skipped + sync = binding.sync_date + if not sync: + return + from_string = fields.Datetime.from_string + sync_date = from_string(sync) + walmart_date = from_string(self.walmart_record['updated_at']) + # if the last synchronization date is greater than the last + # update in walmart, we skip the import. + # Important: at the beginning of the exporters flows, we have to + # check if the walmart_date is more recent than the sync_date + # and if so, schedule a new import. If we don't do that, we'll + # miss changes done in Walmart + return walmart_date < sync_date + + def _import_dependency(self, external_id, binding_model, + importer=None, always=False): + """ Import a dependency. + + The importer class is a class or subclass of + :class:`WalmartImporter`. A specific class can be defined. + + :param external_id: id of the related binding to import + :param binding_model: name of the binding model for the relation + :type binding_model: str | unicode + :param importer_component: component to use for import + By default: 'importer' + :type importer_component: Component + :param always: if True, the record is updated even if it already + exists, note that it is still skipped if it has + not been modified on Walmart since the last + update. When False, it will import it only when + it does not yet exist. + :type always: boolean + """ + if not external_id: + return + binder = self.binder_for(binding_model) + if always or not binder.to_internal(external_id): + if importer is None: + importer = self.component(usage='record.importer', + model_name=binding_model) + try: + importer.run(external_id) + except NothingToDoJob: + _logger.info( + 'Dependency import of %s(%s) has been ignored.', + binding_model._name, external_id + ) + + def _import_dependencies(self): + """ Import the dependencies for the record + + Import of dependencies can be done manually or by calling + :meth:`_import_dependency` for each dependency. + """ + return + + def _map_data(self): + """ Returns an instance of + :py:class:`~odoo.addons.connector.components.mapper.MapRecord` + + """ + return self.mapper.map_record(self.walmart_record) + + def _validate_data(self, data): + """ Check if the values to import are correct + + Pro-actively check before the ``_create`` or + ``_update`` if some fields are missing or invalid. + + Raise `InvalidDataError` + """ + return + + def _must_skip(self): + """ Hook called right after we read the data from the backend. + + If the method returns a message giving a reason for the + skipping, the import will be interrupted and the message + recorded in the job (if the import is called directly by the + job, not by dependencies). + + If it returns None, the import will continue normally. + + :returns: None | str | unicode + """ + return + + def _get_binding(self): + return self.binder.to_internal(self.external_id) + + def _create_data(self, map_record, **kwargs): + return map_record.values(for_create=True, **kwargs) + + def _create(self, data): + """ Create the OpenERP record """ + # special check on data before import + self._validate_data(data) + model = self.model.with_context(connector_no_export=True) + binding = model.create(data) + _logger.debug('%d created from walmart %s', binding, self.external_id) + return binding + + def _update_data(self, map_record, **kwargs): + return map_record.values(**kwargs) + + def _update(self, binding, data): + """ Update an OpenERP record """ + # special check on data before import + self._validate_data(data) + binding.with_context(connector_no_export=True).write(data) + _logger.debug('%d updated from walmart %s', binding, self.external_id) + return + + def _after_import(self, binding): + """ Hook called at the end of the import """ + return + + def run(self, external_id, force=False): + """ Run the synchronization + + :param external_id: identifier of the record on Walmart + """ + self.external_id = external_id + lock_name = 'import({}, {}, {}, {})'.format( + self.backend_record._name, + self.backend_record.id, + self.work.model_name, + external_id, + ) + + try: + self.walmart_record = self._get_walmart_data() + except IDMissingInBackend: + return _('Record does no longer exist in Walmart') + + skip = self._must_skip() + if skip: + return skip + + binding = self._get_binding() + + if not force and self._is_uptodate(binding): + return _('Already up-to-date.') + + # Keep a lock on this import until the transaction is committed + # The lock is kept since we have detected that the informations + # will be updated into Odoo + self.advisory_lock_or_retry(lock_name) + self._before_import() + + # import the missing linked resources + self._import_dependencies() + + map_record = self._map_data() + + if binding: + record = self._update_data(map_record) + self._update(binding, record) + else: + record = self._create_data(map_record) + binding = self._create(record) + + self.binder.bind(self.external_id, binding) + + self._after_import(binding) + + +class BatchImporter(AbstractComponent): + """ The role of a BatchImporter is to search for a list of + items to import, then it can either import them directly or delay + the import of each item separately. + """ + + _name = 'walmart.batch.importer' + _inherit = ['base.importer', 'base.walmart.connector'] + _usage = 'batch.importer' + + def run(self, filters=None): + """ Run the synchronization """ + record_ids = self.backend_adapter.search(filters) + for record_id in record_ids: + self._import_record(record_id) + + def _import_record(self, external_id): + """ Import a record directly or delay the import of the record. + + Method to implement in sub-classes. + """ + raise NotImplementedError + + +class DirectBatchImporter(AbstractComponent): + """ Import the records directly, without delaying the jobs. """ + + _name = 'walmart.direct.batch.importer' + _inherit = 'walmart.batch.importer' + + def _import_record(self, external_id): + """ Import the record directly """ + self.model.import_record(self.backend_record, external_id) + + +class DelayedBatchImporter(AbstractComponent): + """ Delay import of the records """ + + _name = 'walmart.delayed.batch.importer' + _inherit = 'walmart.batch.importer' + + def _import_record(self, external_id, job_options=None, **kwargs): + """ Delay the import of the records""" + delayable = self.model.with_delay(**job_options or {}) + delayable.import_record(self.backend_record, external_id, **kwargs) + + +# class SimpleRecordImporter(Component): +# """ Import one Walmart Website """ +# +# _name = 'walmart.simple.record.importer' +# _inherit = 'walmart.importer' +# _apply_on = [ +# 'walmart.res.partner.category', +# ] + + +# class TranslationImporter(Component): +# """ Import translations for a record. +# +# Usually called from importers, in ``_after_import``. +# For instance from the products and products' categories importers. +# """ +# +# _name = 'walmart.translation.importer' +# _inherit = 'walmart.importer' +# _usage = 'translation.importer' +# +# def _get_walmart_data(self, storeview_id=None): +# """ Return the raw Walmart data for ``self.external_id`` """ +# return self.backend_adapter.read(self.external_id, storeview_id) +# +# def run(self, external_id, binding, mapper=None): +# self.external_id = external_id +# storeviews = self.env['walmart.storeview'].search( +# [('backend_id', '=', self.backend_record.id)] +# ) +# default_lang = self.backend_record.default_lang_id +# lang_storeviews = [sv for sv in storeviews +# if sv.lang_id and sv.lang_id != default_lang] +# if not lang_storeviews: +# return +# +# # find the translatable fields of the model +# fields = self.model.fields_get() +# translatable_fields = [field for field, attrs in fields.items() +# if attrs.get('translate')] +# +# if mapper is None: +# mapper = self.mapper +# else: +# mapper = self.component_by_name(mapper) +# +# for storeview in lang_storeviews: +# lang_record = self._get_walmart_data(storeview.external_id) +# map_record = mapper.map_record(lang_record) +# record = map_record.values() +# +# data = dict((field, value) for field, value in record.items() +# if field in translatable_fields) +# +# binding.with_context(connector_no_export=True, +# lang=storeview.lang_id.code).write(data) diff --git a/connector_walmart/components/mapper.py b/connector_walmart/components/mapper.py new file mode 100644 index 00000000..d41c00ab --- /dev/null +++ b/connector_walmart/components/mapper.py @@ -0,0 +1,16 @@ +# © 2017,2018 Hibou Corp. +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from odoo.addons.component.core import AbstractComponent + + +class WalmartImportMapper(AbstractComponent): + _name = 'walmart.import.mapper' + _inherit = ['base.walmart.connector', 'base.import.mapper'] + _usage = 'import.mapper' + + +class WalmartExportMapper(AbstractComponent): + _name = 'walmart.export.mapper' + _inherit = ['base.walmart.connector', 'base.export.mapper'] + _usage = 'export.mapper' diff --git a/connector_walmart/data/connector_walmart_data.xml b/connector_walmart/data/connector_walmart_data.xml new file mode 100644 index 00000000..39d39715 --- /dev/null +++ b/connector_walmart/data/connector_walmart_data.xml @@ -0,0 +1,54 @@ + + + + + + Walmart - Import Sales Orders + + + 1 + days + -1 + + + + + + + + Total Amount differs from Walmart + The amount computed in Odoo doesn't match with the amount in Walmart. + +Cause: +The taxes are probably different between Odoo and Walmart. A fiscal position could have changed the final price. + +Resolution: +Check your taxes and fiscal positions configuration and correct them if necessary. + 30 + sale.order + sale + if sale.walmart_bind_ids and abs(sale.amount_total - sale.walmart_bind_ids[0].total_amount) >= 0.01: + failed = True + + + + + Total Tax Amount differs from Walmart + The tax amount computed in Odoo doesn't match with the tax amount in Walmart. + +Cause: +The taxes are probably different between Odoo and Walmart. A fiscal position could have changed the final price. + +Resolution: +Check your taxes and fiscal positions configuration and correct them if necessary. + 30 + sale.order + sale + # By default, a cent of difference for the tax amount is allowed, feel free to customise it in your own module +if sale.walmart_bind_ids and abs(sale.amount_tax - sale.walmart_bind_ids[0].total_amount_tax) > 0.01: + failed = True + + + + + diff --git a/connector_walmart/models/__init__.py b/connector_walmart/models/__init__.py new file mode 100644 index 00000000..7c54dcde --- /dev/null +++ b/connector_walmart/models/__init__.py @@ -0,0 +1,6 @@ +from . import walmart_backend +from . import walmart_binding +from . import sale_order +from . import stock_picking +from . import delivery +from . import account diff --git a/connector_walmart/models/account/__init__.py b/connector_walmart/models/account/__init__.py new file mode 100644 index 00000000..2637a061 --- /dev/null +++ b/connector_walmart/models/account/__init__.py @@ -0,0 +1 @@ +from . import account_fiscal_position diff --git a/connector_walmart/models/account/account_fiscal_position.py b/connector_walmart/models/account/account_fiscal_position.py new file mode 100644 index 00000000..b8ce30c3 --- /dev/null +++ b/connector_walmart/models/account/account_fiscal_position.py @@ -0,0 +1,68 @@ +# © 2017,2018 Hibou Corp. +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from odoo import api, fields, models +from odoo.exceptions import ValidationError + +from logging import getLogger + +_logger = getLogger(__name__) + + +class AccountFiscalPosition(models.Model): + _inherit = 'account.fiscal.position' + + is_connector_walmart = fields.Boolean(string='Use Walmart Order Item Rate') + + @api.multi + def map_tax(self, taxes, product=None, partner=None, order_line=None): + + if not taxes or not self.is_connector_walmart: + return super(AccountFiscalPosition, self).map_tax(taxes, product=product, partner=partner) + + AccountTax = self.env['account.tax'].sudo() + result = AccountTax.browse() + + for tax in taxes: + if not order_line: + raise ValidationError('Walmart Connector fiscal position requires order item details.') + + if not order_line.walmart_bind_ids: + if order_line.price_unit == 0.0: + continue + else: + raise ValidationError('Walmart Connector fiscal position requires Walmart Order Lines') + + tax_rate = order_line.walmart_bind_ids[0].tax_rate + + if tax_rate == 0.0: + continue + + # step 1: Check if we already have this rate. + tax_line = self.tax_ids.filtered(lambda x: tax_rate == x.tax_dest_id.amount and x.tax_src_id.id == tax.id) + if not tax_line: + #step 2: find or create this tax and tax_line + new_tax = AccountTax.search([ + ('name', 'like', 'Walmart %'), + ('amount', '=', tax_rate), + ('amount_type', '=', 'percent'), + ('type_tax_use', '=', 'sale'), + ], limit=1) + if not new_tax: + new_tax = AccountTax.create({ + 'name': 'Walmart Tax %0.2f %%' % (tax_rate,), + 'amount': tax_rate, + 'amount_type': 'percent', + 'type_tax_use': 'sale', + 'account_id': tax.account_id.id, + 'refund_account_id': tax.refund_account_id.id, + }) + tax_line = self.env['account.fiscal.position.tax'].sudo().create({ + 'position_id': self.id, + 'tax_src_id': tax.id, + 'tax_dest_id': new_tax.id, + }) + + # step 3: map the tax + result |= tax_line.tax_dest_id + return result diff --git a/connector_walmart/models/delivery/__init__.py b/connector_walmart/models/delivery/__init__.py new file mode 100644 index 00000000..e4193cf0 --- /dev/null +++ b/connector_walmart/models/delivery/__init__.py @@ -0,0 +1 @@ +from . import common diff --git a/connector_walmart/models/delivery/common.py b/connector_walmart/models/delivery/common.py new file mode 100644 index 00000000..f1200588 --- /dev/null +++ b/connector_walmart/models/delivery/common.py @@ -0,0 +1,52 @@ +# © 2017,2018 Hibou Corp. +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from odoo import models, fields, api + + +class DeliveryCarrier(models.Model): + """ Adds Walmart specific fields to ``delivery.carrier`` + + ``walmart_code`` + + Code of the carrier delivery method in Walmart. + Example: ``Standard`` + + ``walmart_carrier_code`` + + Walmart specific list of carriers. + + """ + _inherit = "delivery.carrier" + + walmart_code = fields.Selection( + selection=[ + ('Value', 'Value'), + ('Standard', 'Standard'), + ('Express', 'Express'), + ('Oneday', 'Oneday'), + ('Freight', 'Freight'), + ], + string='Walmart Method Code', + required=False, + ) + + # From API: + # UPS, USPS, FedEx, Airborne, OnTrac, DHL, NG, LS, UDS, UPSMI, FDX + walmart_carrier_code = fields.Selection( + selection=[ + ('UPS', 'UPS'), + ('USPS', 'USPS'), + ('FedEx', 'FedEx'), + ('Airborne', 'Airborne'), + ('OnTrac', 'OnTrac'), + ('DHL', 'DHL'), + ('NG', 'NG'), + ('LS', 'LS'), + ('UDS', 'UDS'), + ('UPSMI', 'UPSMI'), + ('FDX', 'FDX'), + ], + string='Walmart Base Carrier Code', + required=False, + ) diff --git a/connector_walmart/models/sale_order/__init__.py b/connector_walmart/models/sale_order/__init__.py new file mode 100644 index 00000000..79ab5dc6 --- /dev/null +++ b/connector_walmart/models/sale_order/__init__.py @@ -0,0 +1,2 @@ +from . import common +from . import importer diff --git a/connector_walmart/models/sale_order/common.py b/connector_walmart/models/sale_order/common.py new file mode 100644 index 00000000..b173ad04 --- /dev/null +++ b/connector_walmart/models/sale_order/common.py @@ -0,0 +1,209 @@ +# © 2017,2018 Hibou Corp. +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +import logging + +import odoo.addons.decimal_precision as dp + +from odoo import models, fields, api +from odoo.addons.queue_job.job import job +from odoo.addons.component.core import Component +from odoo.addons.queue_job.exception import RetryableJobError + +_logger = logging.getLogger(__name__) + + +class WalmartSaleOrder(models.Model): + _name = 'walmart.sale.order' + _inherit = 'walmart.binding' + _description = 'Walmart Sale Order' + _inherits = {'sale.order': 'odoo_id'} + + odoo_id = fields.Many2one(comodel_name='sale.order', + string='Sale Order', + required=True, + ondelete='cascade') + walmart_order_line_ids = fields.One2many( + comodel_name='walmart.sale.order.line', + inverse_name='walmart_order_id', + string='Walmart Order Lines' + ) + customer_order_id = fields.Char(string='Customer Order ID') + total_amount = fields.Float( + string='Total amount', + digits=dp.get_precision('Account') + ) + total_amount_tax = fields.Float( + string='Total amount w. tax', + digits=dp.get_precision('Account') + ) + shipping_method_code = fields.Selection( + selection=[ + ('Value', 'Value'), + ('Standard', 'Standard'), + ('Express', 'Express'), + ('Oneday', 'Oneday'), + ('Freight', 'Freight'), + ], + string='Shipping Method Code', + required=False, + ) + + @job(default_channel='root.walmart') + @api.model + def import_batch(self, backend, filters=None): + """ Prepare the import of Sales Orders from Walmart """ + return super(WalmartSaleOrder, self).import_batch(backend, filters=filters) + + @api.multi + def action_confirm(self): + for order in self: + if order.backend_id.acknowledge_order == 'order_confirm': + self.with_delay().acknowledge_order(order.backend_id, order.external_id) + + @job(default_channel='root.walmart') + @api.model + def acknowledge_order(self, backend, external_id): + with backend.work_on(self._name) as work: + adapter = work.component(usage='backend.adapter') + return adapter.acknowledge_order(external_id) + + +class SaleOrder(models.Model): + _inherit = 'sale.order' + + walmart_bind_ids = fields.One2many( + comodel_name='walmart.sale.order', + inverse_name='odoo_id', + string="Walmart Bindings", + ) + + @api.multi + def action_confirm(self): + res = super(SaleOrder, self).action_confirm() + self.walmart_bind_ids.action_confirm() + return res + + +class WalmartSaleOrderLine(models.Model): + _name = 'walmart.sale.order.line' + _inherit = 'walmart.binding' + _description = 'Walmart Sale Order Line' + _inherits = {'sale.order.line': 'odoo_id'} + + walmart_order_id = fields.Many2one(comodel_name='walmart.sale.order', + string='Walmart Sale Order', + required=True, + ondelete='cascade', + index=True) + odoo_id = fields.Many2one(comodel_name='sale.order.line', + string='Sale Order Line', + required=True, + ondelete='cascade') + backend_id = fields.Many2one( + related='walmart_order_id.backend_id', + string='Walmart Backend', + readonly=True, + store=True, + # override 'walmart.binding', can't be INSERTed if True: + required=False, + ) + tax_rate = fields.Float(string='Tax Rate', + digits=dp.get_precision('Account')) + walmart_number = fields.Char(string='Walmart lineNumber') + # notes = fields.Char() + + @api.model + def create(self, vals): + walmart_order_id = vals['walmart_order_id'] + binding = self.env['walmart.sale.order'].browse(walmart_order_id) + vals['order_id'] = binding.odoo_id.id + binding = super(WalmartSaleOrderLine, self).create(vals) + return binding + + +class SaleOrderLine(models.Model): + _inherit = 'sale.order.line' + + walmart_bind_ids = fields.One2many( + comodel_name='walmart.sale.order.line', + inverse_name='odoo_id', + string="Walmart Bindings", + ) + + + @api.multi + def _compute_tax_id(self): + """ + This overrides core behavior because we need to get the order_line into the order + to be able to compute Walmart taxes. + :return: + """ + for line in self: + fpos = line.order_id.fiscal_position_id or line.order_id.partner_id.property_account_position_id + # If company_id is set, always filter taxes by the company + taxes = line.product_id.taxes_id.filtered(lambda r: not line.company_id or r.company_id == line.company_id) + line.tax_id = fpos.map_tax(taxes, line.product_id, line.order_id.partner_shipping_id, order_line=line) if fpos else taxes + + + +class SaleOrderAdapter(Component): + _name = 'walmart.sale.order.adapter' + _inherit = 'walmart.adapter' + _apply_on = 'walmart.sale.order' + + def search(self, from_date=None, next_cursor=None): + """ + + :param filters: Dict of filters + :param from_date: + :param next_cursor: + :return: List + """ + if next_cursor: + arguments = {'nextCursor': next_cursor} + else: + arguments = {'createdStartDate': from_date.isoformat()} + + api_instance = self.api_instance + orders_response = api_instance.orders.all(**arguments) + _logger.debug(orders_response) + + if not 'list' in orders_response: + return [] + + next = orders_response['list']['meta']['nextCursor'] + if next: + self.env[self._apply_on].with_delay().import_batch( + self.backend_record, + filters={'next_cursor': next} + ) + + orders = orders_response['list']['elements']['order'] + return map(lambda o: o['purchaseOrderId'], orders) + + def read(self, id, attributes=None): + """ Returns the information of a record + + :rtype: dict + """ + api_instance = self.api_instance + record = api_instance.orders.get(id) + if 'order' in record: + order = record['order'] + order['orderLines'] = order['orderLines']['orderLine'] + return order + raise RetryableJobError('Order "' + str(id) + '" did not return an order response.') + + def acknowledge_order(self, id): + """ Returns the order after ack + :rtype: dict + """ + _logger.warn('BEFORE ACK ' + str(id)) + api_instance = self.api_instance + record = api_instance.orders.acknowledge(id) + _logger.warn('AFTER ACK RECORD: ' + str(record)) + if 'order' in record: + return record['order'] + raise RetryableJobError('Acknowledge Order "' + str(id) + '" did not return an order response.') + diff --git a/connector_walmart/models/sale_order/importer.py b/connector_walmart/models/sale_order/importer.py new file mode 100644 index 00000000..adf4c099 --- /dev/null +++ b/connector_walmart/models/sale_order/importer.py @@ -0,0 +1,347 @@ +# © 2017,2018 Hibou Corp. +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +import logging + +from datetime import datetime, timedelta +from copy import deepcopy, copy + +from odoo import _ +from odoo.addons.component.core import Component +from odoo.addons.connector.components.mapper import mapping +from odoo.addons.queue_job.exception import NothingToDoJob, FailedJobError + +_logger = logging.getLogger(__name__) + + +def walk_charges(charges): + item_amount = 0.0 + tax_amount = 0.0 + for charge in charges['charge']: + #charge_details = charge['charge'] + charge_details = charge + charge_amount_details = charge_details['chargeAmount'] + assert charge_amount_details['currency'] == 'USD', ("Invalid currency: " + charge_amount_details['currency']) + tax_details = charge_details['tax'] + tax_amount_details = tax_details['taxAmount'] if tax_details else {'amount': 0.0} + item_amount += float(charge_amount_details['amount']) + tax_amount += float(tax_amount_details['amount']) + return item_amount, tax_amount + + +class SaleOrderBatchImporter(Component): + _name = 'walmart.sale.order.batch.importer' + _inherit = 'walmart.delayed.batch.importer' + _apply_on = 'walmart.sale.order' + + def _import_record(self, external_id, job_options=None, **kwargs): + if not job_options: + job_options = { + 'max_retries': 0, + 'priority': 5, + } + return super(SaleOrderBatchImporter, self)._import_record( + external_id, job_options=job_options) + + def run(self, filters=None): + """ Run the synchronization """ + if filters is None: + filters = {} + from_date = filters.get('from_date') + next_cursor = filters.get('next_cursor') + external_ids = self.backend_adapter.search( + from_date=from_date, + next_cursor=next_cursor, + ) + for external_id in external_ids: + self._import_record(external_id) + + + +class SaleOrderImportMapper(Component): + + _name = 'walmart.sale.order.mapper' + _inherit = 'walmart.import.mapper' + _apply_on = 'walmart.sale.order' + + direct = [('purchaseOrderId', 'external_id'), + ('customerOrderId', 'customer_order_id'), + ] + + children = [('orderLines', 'walmart_order_line_ids', 'walmart.sale.order.line'), + ] + + # def _map_child(self, map_record, from_attr, to_attr, model_name): + # return super(SaleOrderImportMapper, self)._map_child(map_record, from_attr, to_attr, model_name) + + def _add_shipping_line(self, map_record, values): + record = map_record.source + + line_builder = self.component(usage='order.line.builder.shipping') + line_builder.price_unit = 0.0 + + if values.get('carrier_id'): + carrier = self.env['delivery.carrier'].browse(values['carrier_id']) + line_builder.product = carrier.product_id + + line = (0, 0, line_builder.get_line()) + values['order_line'].append(line) + return values + + def finalize(self, map_record, values): + values.setdefault('order_line', []) + self._add_shipping_line(map_record, values) + values.update({ + 'partner_id': self.options.partner_id, + 'partner_invoice_id': self.options.partner_invoice_id, + 'partner_shipping_id': self.options.partner_shipping_id, + }) + onchange = self.component( + usage='ecommerce.onchange.manager.sale.order' + ) + return onchange.play(values, values['walmart_order_line_ids']) + + @mapping + def name(self, record): + name = record['purchaseOrderId'] + prefix = self.backend_record.sale_prefix + if prefix: + name = prefix + name + return {'name': name} + + @mapping + def date_order(self, record): + return {'date_order': datetime.fromtimestamp(record['orderDate'] / 1e3)} + + @mapping + def fiscal_position_id(self, record): + if self.backend_record.fiscal_position_id: + return {'fiscal_position_id': self.backend_record.fiscal_position_id.id} + + @mapping + def team_id(self, record): + if self.backend_record.team_id: + return {'team_id': self.backend_record.team_id.id} + + @mapping + def payment_mode_id(self, record): + assert self.backend_record.payment_mode_id, ("Payment mode must be specified.") + return {'payment_mode_id': self.backend_record.payment_mode_id.id} + + @mapping + def project_id(self, record): + if self.backend_record.analytic_account_id: + return {'project_id': self.backend_record.analytic_account_id.id} + + @mapping + def warehouse_id(self, record): + if self.backend_record.warehouse_id: + return {'warehouse_id': self.backend_record.warehouse_id.id} + + @mapping + def shipping_method(self, record): + method = record['shippingInfo']['methodCode'] + carrier = self.env['delivery.carrier'].search([('walmart_code', '=', method)], limit=1) + if not carrier: + raise ValueError('Delivery Carrier for methodCode "%s", cannot be found.' % (method, )) + return {'carrier_id': carrier.id, 'shipping_method_code': method} + + @mapping + def backend_id(self, record): + return {'backend_id': self.backend_record.id} + + @mapping + def total_amount(self, record): + lines = record['orderLines'] + total_amount = 0.0 + total_amount_tax = 0.0 + for l in lines: + item_amount, tax_amount = walk_charges(l['charges']) + total_amount += item_amount + tax_amount + total_amount_tax += tax_amount + return {'total_amount': total_amount, 'total_amount_tax': total_amount_tax} + + +class SaleOrderImporter(Component): + _name = 'walmart.sale.order.importer' + _inherit = 'walmart.importer' + _apply_on = 'walmart.sale.order' + + def _must_skip(self): + if self.binder.to_internal(self.external_id): + return _('Already imported') + + def _before_import(self): + # @TODO check if the order is released + pass + + def _create_partner(self, values): + return self.env['res.partner'].create(values) + + def _partner_matches(self, partner, values): + for key, value in values.items(): + if key == 'state_id': + if value != partner.state_id.id: + return False + elif key == 'country_id': + if value != partner.country_id.id: + return False + elif bool(value) and value != getattr(partner, key): + return False + return True + + def _get_partner_values(self): + record = self.walmart_record + + # find or make partner with these details. + if 'customerEmailId' not in record: + raise ValueError('Order does not have customerEmailId in : ' + str(record)) + customer_email = record['customerEmailId'] + shipping_info = record['shippingInfo'] + phone = shipping_info.get('phone', '') + postal_address = shipping_info.get('postalAddress', []) + name = postal_address.get('name', 'Undefined') + street = postal_address.get('address1', '') + street2 = postal_address.get('address2', '') + city = postal_address.get('city', '') + state_code = postal_address.get('state', '') + zip_ = postal_address.get('postalCode', '') + country_code = postal_address['country'] + country = self.env['res.country'].search([('code', '=', country_code)], limit=1) + state = self.env['res.country.state'].search([ + ('country_id', '=', country.id), + ('code', '=', state_code) + ], limit=1) + + return { + 'email': customer_email, + 'name': name, + 'phone': phone, + 'street': street, + 'street2': street2, + 'zip': zip_, + 'city': city, + 'state_id': state.id, + 'country_id': country.id, + } + + + def _import_addresses(self): + record = self.walmart_record + + partner_values = self._get_partner_values() + partner = self.env['res.partner'].search([ + ('email', '=', partner_values['email']), + ], limit=1) + + if not partner: + # create partner. + partner = self._create_partner(copy(partner_values)) + + if not self._partner_matches(partner, partner_values): + partner_values['parent_id'] = partner.id + partner_values['active'] = False + shipping_partner = self._create_partner(copy(partner_values)) + else: + shipping_partner = partner + + self.partner = partner + self.shipping_partner = shipping_partner + + def _check_special_fields(self): + assert self.partner, ( + "self.partner should have been defined " + "in SaleOrderImporter._import_addresses") + assert self.shipping_partner, ( + "self.shipping_partner should have been defined " + "in SaleOrderImporter._import_addresses") + + def _create_data(self, map_record, **kwargs): + # non dependencies + self._check_special_fields() + return super(SaleOrderImporter, self)._create_data( + map_record, + partner_id=self.partner.id, + partner_invoice_id=self.shipping_partner.id, + partner_shipping_id=self.shipping_partner.id, + **kwargs + ) + + def _create(self, data): + binding = super(SaleOrderImporter, self)._create(data) + # Without this, it won't map taxes with the fiscal position. + if binding.fiscal_position_id: + binding.odoo_id._compute_tax_id() + + if binding.backend_id.acknowledge_order == 'order_create': + binding.with_delay().acknowledge_order(binding.backend_id, binding.external_id) + + return binding + + + def _import_dependencies(self): + record = self.walmart_record + + self._import_addresses() + + # @TODO Import lines? + # Actually, maybe not, since I'm just going to reference by sku + + + +class SaleOrderLineImportMapper(Component): + + _name = 'walmart.sale.order.line.mapper' + _inherit = 'walmart.import.mapper' + _apply_on = 'walmart.sale.order.line' + + def _finalize_product_values(self, record, values): + # This would be a good place to create a vendor or add a route... + return values + + def _product_values(self, record): + item = record['item'] + sku = item['sku'] + item_amount, _ = walk_charges(record['charges']) + values = { + 'default_code': sku, + 'name': item.get('productName', sku), + 'type': 'product', + 'list_price': item_amount, + 'categ_id': self.backend_record.product_categ_id.id, + } + return self._finalize_product_values(record, values) + + @mapping + def product_id(self, record): + item = record['item'] + sku = item['sku'] + product = self.env['product.template'].search([ + ('default_code', '=', sku) + ], limit=1) + + if not product: + # we could use a record like (0, 0, values) + product = self.env['product.template'].create(self._product_values(record)) + + return {'product_id': product.product_variant_id.id} + + @mapping + def price_unit(self, record): + order_line_qty = record['orderLineQuantity'] + product_uom_qty = int(order_line_qty['amount']) + item_amount, tax_amount = walk_charges(record['charges']) + tax_rate = (tax_amount / item_amount) * 100.0 if item_amount else 0.0 + + price_unit = item_amount / product_uom_qty + + return {'product_uom_qty': product_uom_qty, 'price_unit': price_unit, 'tax_rate': tax_rate} + + @mapping + def walmart_number(self, record): + return {'walmart_number': record['lineNumber']} + + @mapping + def backend_id(self, record): + return {'backend_id': self.backend_record.id} + diff --git a/connector_walmart/models/stock_picking/__init__.py b/connector_walmart/models/stock_picking/__init__.py new file mode 100644 index 00000000..2db3f18c --- /dev/null +++ b/connector_walmart/models/stock_picking/__init__.py @@ -0,0 +1,2 @@ +from . import common +from . import exporter diff --git a/connector_walmart/models/stock_picking/common.py b/connector_walmart/models/stock_picking/common.py new file mode 100644 index 00000000..d1b96b4d --- /dev/null +++ b/connector_walmart/models/stock_picking/common.py @@ -0,0 +1,95 @@ +# © 2017,2018 Hibou Corp. +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +import logging +from odoo import api, models, fields +from odoo.addons.queue_job.job import job, related_action +from odoo.addons.component.core import Component +from odoo.addons.queue_job.exception import RetryableJobError + +_logger = logging.getLogger(__name__) + + +class WalmartStockPicking(models.Model): + _name = 'walmart.stock.picking' + _inherit = 'walmart.binding' + _inherits = {'stock.picking': 'odoo_id'} + _description = 'Walmart Delivery Order' + + odoo_id = fields.Many2one(comodel_name='stock.picking', + string='Stock Picking', + required=True, + ondelete='cascade') + walmart_order_id = fields.Many2one(comodel_name='walmart.sale.order', + string='Walmart Sale Order', + ondelete='set null') + + @job(default_channel='root.walmart') + @related_action(action='related_action_unwrap_binding') + @api.multi + def export_picking_done(self): + """ Export a complete or partial delivery order. """ + self.ensure_one() + with self.backend_id.work_on(self._name) as work: + exporter = work.component(usage='record.exporter') + return exporter.run(self) + + +class StockPicking(models.Model): + _inherit = 'stock.picking' + + walmart_bind_ids = fields.One2many( + comodel_name='walmart.stock.picking', + inverse_name='odoo_id', + string="Walmart Bindings", + ) + +class StockPickingAdapter(Component): + _name = 'walmart.stock.picking.adapter' + _inherit = 'walmart.adapter' + _apply_on = 'walmart.stock.picking' + + def create(self, id, lines): + api_instance = self.api_instance + _logger.warn('BEFORE SHIPPING %s list: %s' % (str(id), str(lines))) + record = api_instance.orders.ship(id, lines) + _logger.warn('AFTER SHIPPING RECORD: ' + str(record)) + if 'order' in record: + return record['order'] + raise RetryableJobError('Shipping Order %s did not return an order response. (lines: %s)' % (str(id), str(lines))) + + +class WalmartBindingStockPickingListener(Component): + _name = 'walmart.binding.stock.picking.listener' + _inherit = 'base.event.listener' + _apply_on = ['walmart.stock.picking'] + + def on_record_create(self, record, fields=None): + record.with_delay().export_picking_done() + + +class WalmartStockPickingListener(Component): + _name = 'walmart.stock.picking.listener' + _inherit = 'base.event.listener' + _apply_on = ['stock.picking'] + + def on_picking_dropship_done(self, record, picking_method): + return self.on_picking_out_done(record, picking_method) + + def on_picking_out_done(self, record, picking_method): + """ + Create a ``walmart.stock.picking`` record. This record will then + be exported to Walmart. + + :param picking_method: picking_method, can be 'complete' or 'partial' + :type picking_method: str + """ + sale = record.sale_id + if not sale: + return + for walmart_sale in sale.walmart_bind_ids: + self.env['walmart.stock.picking'].create({ + 'backend_id': walmart_sale.backend_id.id, + 'odoo_id': record.id, + 'walmart_order_id': walmart_sale.id, + }) diff --git a/connector_walmart/models/stock_picking/exporter.py b/connector_walmart/models/stock_picking/exporter.py new file mode 100644 index 00000000..73fef876 --- /dev/null +++ b/connector_walmart/models/stock_picking/exporter.py @@ -0,0 +1,78 @@ +# © 2017,2018 Hibou Corp. +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from odoo import fields +from odoo.addons.component.core import Component +from odoo.addons.queue_job.exception import NothingToDoJob +from logging import getLogger + +_logger = getLogger(__name__) + + +class WalmartPickingExporter(Component): + _name = 'walmart.stock.picking.exporter' + _inherit = 'walmart.exporter' + _apply_on = ['walmart.stock.picking'] + + def _get_args(self, binding, lines): + sale_binder = self.binder_for('walmart.sale.order') + walmart_sale_id = sale_binder.to_external(binding.walmart_order_id) + return walmart_sale_id, lines + + def _get_lines(self, binding): + """ + Normalizes picking line data into the format to export to Walmart. + :param binding: walmart.stock.picking + :return: list[ dict(number, amount, carrier, methodCode, trackingNumber, trackingUrl=None) ] + """ + ship_date = binding.date_done + # in ms + ship_date_time = int(fields.Datetime.from_string(ship_date).strftime('%s')) * 1000 + lines = [] + for line in binding.move_lines: + sale_line = line.procurement_id.sale_line_id + if not sale_line.walmart_bind_ids: + continue + # this is a particularly interesting way to get this, + walmart_sale_line = next( + (line for line in sale_line.walmart_bind_ids + if line.backend_id.id == binding.backend_id.id), + None + ) + if not walmart_sale_line: + continue + + number = walmart_sale_line.walmart_number + amount = 1 if line.product_qty > 0 else 0 + carrier = binding.carrier_id.walmart_carrier_code + methodCode = binding.walmart_order_id.shipping_method_code + trackingNumber = binding.carrier_tracking_ref + trackingUrl = None + lines.append(dict( + shipDateTime=ship_date_time, + number=number, + amount=amount, + carrier=carrier, + methodCode=methodCode, + trackingNumber=trackingNumber, + trackingUrl=trackingUrl, + )) + + return lines + + def run(self, binding): + """ + Export the picking to Walmart + :param binding: walmart.stock.picking + :return: + """ + + if binding.external_id: + return 'Already exported' + lines = self._get_lines(binding) + if not lines: + raise NothingToDoJob('Cancelled: the delivery order does not contain ' + 'lines from the original sale order.') + args = self._get_args(binding, lines) + external_id = self.backend_adapter.create(*args) + self.binder.bind(external_id, binding) diff --git a/connector_walmart/models/walmart_backend/__init__.py b/connector_walmart/models/walmart_backend/__init__.py new file mode 100644 index 00000000..e4193cf0 --- /dev/null +++ b/connector_walmart/models/walmart_backend/__init__.py @@ -0,0 +1 @@ +from . import common diff --git a/connector_walmart/models/walmart_backend/common.py b/connector_walmart/models/walmart_backend/common.py new file mode 100644 index 00000000..8b18fc25 --- /dev/null +++ b/connector_walmart/models/walmart_backend/common.py @@ -0,0 +1,128 @@ +# © 2017,2018 Hibou Corp. +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + + +from datetime import datetime, timedelta +from logging import getLogger +from contextlib import contextmanager + +from odoo import api, fields, models, _ +from ...components.api.walmart import Walmart + +_logger = getLogger(__name__) + +IMPORT_DELTA_BUFFER = 60 # seconds + + +class WalmartBackend(models.Model): + _name = 'walmart.backend' + _description = 'Walmart Backend' + _inherit = 'connector.backend' + + name = fields.Char(string='Name') + consumer_id = fields.Char( + string='Consumer ID', + required=True, + help='Walmart Consumer ID', + ) + channel_type = fields.Char( + string='Channel Type', + required=True, + help='Walmart Channel Type', + ) + private_key = fields.Char( + string='Private Key', + required=True, + help='Walmart Private Key' + ) + warehouse_id = fields.Many2one( + comodel_name='stock.warehouse', + string='Warehouse', + required=True, + help='Warehouse to use for stock.', + ) + company_id = fields.Many2one( + comodel_name='res.company', + related='warehouse_id.company_id', + string='Company', + readonly=True, + ) + fiscal_position_id = fields.Many2one( + comodel_name='account.fiscal.position', + string='Fiscal Position', + help='Fiscal position to use on orders.', + ) + analytic_account_id = fields.Many2one( + comodel_name='account.analytic.account', + string='Analytic account', + help='If specified, this analytic account will be used to fill the ' + 'field on the sale order created by the connector.' + ) + team_id = fields.Many2one(comodel_name='crm.team', string='Sales Team') + sale_prefix = fields.Char( + string='Sale Prefix', + help="A prefix put before the name of imported sales orders.\n" + "For instance, if the prefix is 'WMT-', the sales " + "order 5571768504079 in Walmart, will be named 'WMT-5571768504079' " + "in Odoo.", + ) + payment_mode_id = fields.Many2one(comodel_name='account.payment.mode', string="Payment Mode") + + # New Product fields. + product_categ_id = fields.Many2one(comodel_name='product.category', string='Product Category', + help='Default product category for newly created products.') + + acknowledge_order = fields.Selection([ + ('never', 'Never'), + ('order_create', 'On Order Import'), + ('order_confirm', 'On Order Confirmation'), + ], string='Acknowledge Order') + + + import_orders_from_date = fields.Datetime( + string='Import sale orders from date', + ) + + @contextmanager + @api.multi + def work_on(self, model_name, **kwargs): + self.ensure_one() + walmart_api = Walmart(self.consumer_id, self.channel_type, self.private_key) + _super = super(WalmartBackend, self) + with _super.work_on(model_name, walmart_api=walmart_api, **kwargs) as work: + yield work + + @api.model + def _scheduler_import_sale_orders(self): + # potential hook for customization (e.g. pad from date or provide its own) + backends = self.search([ + ('consumer_id', '!=', False), + ('channel_type', '!=', False), + ('private_key', '!=', False), + ('import_orders_from_date', '!=', False), + ]) + return backends.import_sale_orders() + + @api.multi + def import_sale_orders(self): + self._import_from_date('walmart.sale.order', 'import_orders_from_date') + return True + + @api.multi + def _import_from_date(self, model_name, from_date_field): + import_start_time = datetime.now() + for backend in self: + from_date = backend[from_date_field] + if from_date: + from_date = fields.Datetime.from_string(from_date) + else: + from_date = None + + self.env[model_name].with_delay().import_batch( + backend, + filters={'from_date': from_date, 'to_date': import_start_time} + ) + # We add a buffer, but won't import them twice. + next_time = import_start_time - timedelta(seconds=IMPORT_DELTA_BUFFER) + next_time = fields.Datetime.to_string(next_time) + self.write({from_date_field: next_time}) diff --git a/connector_walmart/models/walmart_binding/__init__.py b/connector_walmart/models/walmart_binding/__init__.py new file mode 100644 index 00000000..e4193cf0 --- /dev/null +++ b/connector_walmart/models/walmart_binding/__init__.py @@ -0,0 +1 @@ +from . import common diff --git a/connector_walmart/models/walmart_binding/common.py b/connector_walmart/models/walmart_binding/common.py new file mode 100644 index 00000000..01c39d92 --- /dev/null +++ b/connector_walmart/models/walmart_binding/common.py @@ -0,0 +1,66 @@ +# © 2017,2018 Hibou Corp. +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from odoo import api, models, fields +from odoo.addons.queue_job.job import job, related_action + + +class WalmartBinding(models.AbstractModel): + """ Abstract Model for the Bindings. + + All of the models used as bindings between Walmart and Odoo + (``walmart.sale.order``) should ``_inherit`` from it. + """ + _name = 'walmart.binding' + _inherit = 'external.binding' + _description = 'Walmart Binding (abstract)' + + backend_id = fields.Many2one( + comodel_name='walmart.backend', + string='Walmart Backend', + required=True, + ondelete='restrict', + ) + external_id = fields.Char(string='ID in Walmart') + + _sql_constraints = [ + ('walmart_uniq', 'unique(backend_id, external_id)', 'A binding already exists for this Walmart ID.'), + ] + + @job(default_channel='root.walmart') + @related_action(action='related_action_walmart_link') + @api.model + def import_batch(self, backend, filters=None): + """ Prepare the import of records modified on Walmart """ + if filters is None: + filters = {} + with backend.work_on(self._name) as work: + importer = work.component(usage='batch.importer') + return importer.run(filters=filters) + + @job(default_channel='root.walmart') + @related_action(action='related_action_walmart_link') + @api.model + def import_record(self, backend, external_id, force=False): + """ Import a Walmart record """ + with backend.work_on(self._name) as work: + importer = work.component(usage='record.importer') + return importer.run(external_id, force=force) + + # @job(default_channel='root.walmart') + # @related_action(action='related_action_unwrap_binding') + # @api.multi + # def export_record(self, fields=None): + # """ Export a record on Walmart """ + # self.ensure_one() + # with self.backend_id.work_on(self._name) as work: + # exporter = work.component(usage='record.exporter') + # return exporter.run(self, fields) + # + # @job(default_channel='root.walmart') + # @related_action(action='related_action_walmart_link') + # def export_delete_record(self, backend, external_id): + # """ Delete a record on Walmart """ + # with backend.work_on(self._name) as work: + # deleter = work.component(usage='record.exporter.deleter') + # return deleter.run(external_id) diff --git a/connector_walmart/security/ir.model.access.csv b/connector_walmart/security/ir.model.access.csv new file mode 100644 index 00000000..2953189e --- /dev/null +++ b/connector_walmart/security/ir.model.access.csv @@ -0,0 +1,14 @@ +"id","name","model_id:id","group_id:id","perm_read","perm_write","perm_create","perm_unlink" +"access_walmart_backend","walmart_backend connector manager","model_walmart_backend","connector.group_connector_manager",1,1,1,1 +"access_walmart_binding","walmart_binding connector manager","model_walmart_binding","connector.group_connector_manager",1,1,1,1 +"access_walmart_sale_order","walmart_sale_order connector manager","model_walmart_sale_order","connector.group_connector_manager",1,1,1,1 +"access_walmart_sale_order_line","walmart_sale_order_line connector manager","model_walmart_sale_order_line","connector.group_connector_manager",1,1,1,1 +"access_walmart_stock_picking","walmart_stock_picking connector manager","model_walmart_stock_picking","connector.group_connector_manager",1,1,1,1 +"access_walmart_sale_order_sale_salesman","walmart_sale_order","model_walmart_sale_order","sales_team.group_sale_salesman",1,0,0,0 +"access_walmart_sale_order_sale_manager","walmart_sale_order","model_walmart_sale_order","sales_team.group_sale_manager",1,1,1,1 +"access_walmart_sale_order_line_sale_salesman","walmart_sale_order_line","model_walmart_sale_order_line","sales_team.group_sale_salesman",1,0,0,0 +"access_walmart_sale_order_line_sale_manager","walmart_sale_order_line","model_walmart_sale_order_line","sales_team.group_sale_manager",1,1,1,1 +"access_walmart_sale_order_stock_user","walmart_sale_order warehouse user","model_walmart_sale_order","stock.group_stock_user",1,0,0,0 +"access_walmart_sale_order_line_stock_user","walmart_sale_order_line warehouse user","model_walmart_sale_order_line","stock.group_stock_user",1,0,0,0 +"access_walmart_backend_user","walmart_backend user","model_walmart_backend","sales_team.group_sale_salesman",1,0,0,0 +"access_walmart_stock_picking_user","walmart_stock_picking user","model_walmart_stock_picking","sales_team.group_sale_salesman",1,1,1,0 \ No newline at end of file diff --git a/connector_walmart/views/account_views.xml b/connector_walmart/views/account_views.xml new file mode 100644 index 00000000..81726b9d --- /dev/null +++ b/connector_walmart/views/account_views.xml @@ -0,0 +1,15 @@ + + + + + account.fiscal.position.form.inherit + account.fiscal.position + + + + + + + + + diff --git a/connector_walmart/views/connector_walmart_menu.xml b/connector_walmart/views/connector_walmart_menu.xml new file mode 100644 index 00000000..fd946c42 --- /dev/null +++ b/connector_walmart/views/connector_walmart_menu.xml @@ -0,0 +1,15 @@ + + + + + + + + diff --git a/connector_walmart/views/delivery_views.xml b/connector_walmart/views/delivery_views.xml new file mode 100644 index 00000000..d710ce5b --- /dev/null +++ b/connector_walmart/views/delivery_views.xml @@ -0,0 +1,20 @@ + + + + + walmart.delivery.carrier.form + delivery.carrier + + + + + + + + + + + + + + diff --git a/connector_walmart/views/sale_order_views.xml b/connector_walmart/views/sale_order_views.xml new file mode 100644 index 00000000..0184711f --- /dev/null +++ b/connector_walmart/views/sale_order_views.xml @@ -0,0 +1,53 @@ + + + + + sale.order.walmart.form + sale.order + + + + 0 + + + + + + + + + + + walmart.sale.order.form + walmart.sale.order + +
+ + + + + + + + +
+
+
+ + + walmart.sale.order.tree + walmart.sale.order + + + + + + + + + + + +
diff --git a/connector_walmart/views/walmart_backend_views.xml b/connector_walmart/views/walmart_backend_views.xml new file mode 100644 index 00000000..4aa02aa8 --- /dev/null +++ b/connector_walmart/views/walmart_backend_views.xml @@ -0,0 +1,101 @@ + + + + + walmart.backend.form + walmart.backend + +
+
+
+ +
+ + +
+