1286 lines
68 KiB
Python
1286 lines
68 KiB
Python
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
|
|
|
from collections import defaultdict
|
|
from datetime import datetime, date
|
|
from math import floor, ceil
|
|
from dateutil.relativedelta import relativedelta
|
|
|
|
from odoo import api, fields, models, _
|
|
from odoo.exceptions import AccessError, UserError, ValidationError
|
|
from odoo.tools import groupby
|
|
|
|
|
|
PERIODS_PER_YEAR = {
|
|
"daily": 260,
|
|
"weekly": 52,
|
|
"bi-weekly": 26,
|
|
"semi-monthly": 24,
|
|
"monthly": 12,
|
|
"bi-monthly": 6,
|
|
"quarterly": 4,
|
|
"semi-annually": 2,
|
|
"annually": 1,
|
|
}
|
|
|
|
NUMBER_OF_WEEKS = {
|
|
"daily": 1 / 5,
|
|
"weekly": 1,
|
|
"bi-weekly": 2,
|
|
"monthly": 13 / 3,
|
|
"quarterly": 13,
|
|
"annually": 13 * 4,
|
|
}
|
|
|
|
|
|
class HrPayslip(models.Model):
|
|
_inherit = "hr.payslip"
|
|
|
|
l10n_au_income_stream_type = fields.Selection(
|
|
[
|
|
("SAW", "Salary and wages"),
|
|
("CHP", "Closely held payees"),
|
|
("IAA", "Inbound assignees to Australia"),
|
|
("WHM", "Working holiday makers"),
|
|
("SWP", "Seasonal worker programme"),
|
|
("FEI", "Foreign employment income"),
|
|
("JPD", "Joint petroleum development area"),
|
|
("VOL", "Voluntary agreement"),
|
|
("LAB", "Labour hire"),
|
|
("OSP", "Other specified payments"),
|
|
],
|
|
compute="_compute_income_stream_type",
|
|
store=True,
|
|
)
|
|
l10n_au_foreign_tax_withheld = fields.Float(
|
|
string="Foreign Tax Withheld",
|
|
help="Foreign tax withheld for the current financial year")
|
|
l10n_au_exempt_foreign_income = fields.Float(
|
|
string="Exempt Foreign Income",
|
|
help="Exempt foreign income for the current financial year")
|
|
l10n_au_schedule_pay = fields.Selection(related="version_id.schedule_pay", store=True, index=True)
|
|
l10n_au_termination_type = fields.Selection([
|
|
("normal", "Non-Genuine Redundancy"),
|
|
("genuine", "Genuine Redundancy"),
|
|
], string="Termination Type", readonly=True)
|
|
l10n_au_extra_negotiated_super = fields.Float(compute="_compute_l10n_au_extra_negotiated_super", store=True, readonly=True)
|
|
l10n_au_extra_compulsory_super = fields.Float(compute="_compute_l10n_au_extra_compulsory_super", store=True, readonly=True)
|
|
l10n_au_salary_sacrifice_superannuation = fields.Float(compute="_compute_l10n_au_salary_sacrifice_superannuation", store=True, readonly=True)
|
|
l10n_au_salary_sacrifice_other = fields.Float(compute="_compute_l10n_au_salary_sacrifice_other", store=True, readonly=True)
|
|
payslip_ytd_totals = fields.Json(compute="_compute_payslip_ytd_totals")
|
|
l10n_au_other_input_details_ids = fields.One2many(
|
|
comodel_name="l10n_au.hr.input.details",
|
|
inverse_name="payslip_id",
|
|
string="Other Input Details"
|
|
)
|
|
|
|
def _get_data_files_to_update(self):
|
|
# Note: file order should be maintained
|
|
return super()._get_data_files_to_update() + [(
|
|
'l10n_au_hr_payroll', [
|
|
'data/hr_payslip_input_type_data.xml',
|
|
'data/hr_salary_rule_category_data.xml',
|
|
'data/salary_rules/hr_salary_rule_regular_data.xml',
|
|
'data/hr_rule_parameters_data.xml',
|
|
])]
|
|
|
|
def _get_base_local_dict(self):
|
|
res = super()._get_base_local_dict()
|
|
if self.country_code != "AU":
|
|
return res
|
|
slips = self._l10n_au_get_year_to_date_slips()
|
|
res.update({
|
|
"year_slips": slips,
|
|
"ytd_total": self._l10n_au_get_year_to_date_totals(),
|
|
"ytd_gross": slips._get_line_values(['GROSS'], compute_sum=True)['GROSS']['sum']['total'],
|
|
"ytd_inputs": self._l10n_au_get_ytd_inputs(),
|
|
})
|
|
return res
|
|
|
|
def _get_daily_wage(self):
|
|
schedule_pay = self.version_id.schedule_pay
|
|
wage = self.version_id.wage
|
|
if self.version_id.wage_type == 'hourly':
|
|
wage *= (1 + self.version_id.l10n_au_casual_loading)
|
|
|
|
if schedule_pay == "daily":
|
|
return wage
|
|
elif schedule_pay == "weekly":
|
|
return wage / 5
|
|
elif schedule_pay == "bi-weekly":
|
|
return wage / 10
|
|
elif schedule_pay == "monthly":
|
|
return wage * 3 / 13 / 5
|
|
elif schedule_pay == "quarterly":
|
|
return wage / 13 / 5
|
|
else:
|
|
return wage
|
|
|
|
def _compute_input_line_ids(self):
|
|
super()._compute_input_line_ids()
|
|
for payslip in self:
|
|
if not payslip.struct_id or payslip.company_id.country_id.code != "AU":
|
|
continue
|
|
# this only works if the payslip is saved after struct type is changed, because it depends on the structure
|
|
# type that was selected before.
|
|
new_types = payslip.struct_id.type_id.l10n_au_default_input_type_ids
|
|
# Remove the lines not default for new structure and keep user defined allowances
|
|
to_remove_lines = payslip.input_line_ids.filtered(lambda i: i.input_type_id not in new_types and i.l10n_au_is_default_allowance)
|
|
to_remove_vals = [(2, line.id) for line in to_remove_lines]
|
|
to_add_vals = []
|
|
# Add default lines not already on the payslip
|
|
for default_allowance in new_types.filtered(lambda x: x not in payslip.input_line_ids.input_type_id):
|
|
to_add_vals.append((0, 0, {
|
|
'amount': default_allowance.l10n_au_default_amount,
|
|
'input_type_id': default_allowance.id,
|
|
'l10n_au_is_default_allowance': True,
|
|
}))
|
|
input_line_vals = to_remove_vals + to_add_vals
|
|
payslip.update({'input_line_ids': input_line_vals})
|
|
# automatic description for other types
|
|
for line in payslip.input_line_ids.filtered(lambda line: line.code == "OD"):
|
|
line.name = line.input_type_id.name.split("-")[1].strip()
|
|
|
|
# Computed here to keep individual values for each payslip without a separate salary rule
|
|
# Only recompute when payslip is recomputed or when contract is changed
|
|
@api.depends('version_id')
|
|
def _compute_l10n_au_extra_negotiated_super(self):
|
|
for payslip in self:
|
|
if payslip.country_code != "AU":
|
|
continue
|
|
if payslip.state != 'draft':
|
|
continue
|
|
payslip.l10n_au_extra_negotiated_super = payslip.version_id.l10n_au_extra_negotiated_super
|
|
|
|
@api.depends('version_id')
|
|
def _compute_l10n_au_extra_compulsory_super(self):
|
|
for payslip in self:
|
|
if payslip.country_code != "AU":
|
|
continue
|
|
if payslip.state != 'draft':
|
|
continue
|
|
payslip.l10n_au_extra_compulsory_super = payslip.version_id.l10n_au_extra_compulsory_super
|
|
|
|
@api.depends('version_id')
|
|
def _compute_l10n_au_salary_sacrifice_superannuation(self):
|
|
for payslip in self:
|
|
if payslip.country_code != "AU":
|
|
continue
|
|
if payslip.state != 'draft':
|
|
continue
|
|
payslip.l10n_au_salary_sacrifice_superannuation = payslip.version_id.l10n_au_salary_sacrifice_superannuation
|
|
|
|
@api.depends('version_id')
|
|
def _compute_l10n_au_salary_sacrifice_other(self):
|
|
for payslip in self:
|
|
if payslip.country_code != "AU":
|
|
continue
|
|
if payslip.state != 'draft':
|
|
continue
|
|
payslip.l10n_au_salary_sacrifice_other = payslip.version_id.l10n_au_salary_sacrifice_other
|
|
|
|
@api.depends("version_id.l10n_au_income_stream_type")
|
|
def _compute_income_stream_type(self):
|
|
for payslip in self:
|
|
payslip.l10n_au_income_stream_type = payslip.version_id.l10n_au_income_stream_type
|
|
|
|
@api.model
|
|
def _template_payslip_ytd_totals(self, with_inputs=False):
|
|
return {
|
|
"slip_lines": defaultdict(lambda: defaultdict(float)),
|
|
"worked_days": defaultdict(lambda: {"amount": 0.0, "is_leave": False, "payroll_code": False}),
|
|
"periods": 0,
|
|
"fields": defaultdict(float),
|
|
"input_lines": defaultdict(lambda: {
|
|
"amount": 0.0, "code": "", "payroll_code": "", "payment_type": "", "payroll_code_description": "",
|
|
}) if with_inputs else {},
|
|
}
|
|
|
|
@api.model
|
|
def _clean_payslip_ytd_totals(self, totals):
|
|
""" Json converts integer keys to string and drops 'defaultdict'.
|
|
This method converts the keys back to integer and adds the 'defaultdict'.
|
|
"""
|
|
if not totals:
|
|
return {}
|
|
clean_totals = {}
|
|
for income_stream, income_stream_totals in totals.items():
|
|
clean_totals[income_stream] = self._template_payslip_ytd_totals(with_inputs=True)
|
|
for category, categ_values in income_stream_totals["slip_lines"].items():
|
|
for code, value in categ_values.items():
|
|
if value:
|
|
clean_totals[income_stream]["slip_lines"][category][code] = value
|
|
for entry_type, values in income_stream_totals["worked_days"].items():
|
|
if values["amount"]:
|
|
clean_totals[income_stream]["worked_days"][int(entry_type)] = values
|
|
clean_totals[income_stream]["periods"] = income_stream_totals["periods"]
|
|
for field, value in income_stream_totals["fields"].items():
|
|
clean_totals[income_stream]["fields"][field] = value
|
|
for input_type, values in income_stream_totals["input_lines"].items():
|
|
if values["amount"]:
|
|
clean_totals[income_stream]["input_lines"][int(input_type)] = values
|
|
return clean_totals
|
|
|
|
@api.depends_context("l10n_au_include_current_slip")
|
|
@api.depends("employee_id", "employee_id.slip_ids", "date_from", "date_to", "state", "line_ids", "input_line_ids")
|
|
def _compute_payslip_ytd_totals(self):
|
|
""" Dict with all the year to date totals for the payslip.
|
|
Each slip has the YTD computed based on all the previous slips. These are grouped
|
|
by income stream type.
|
|
|
|
Force include current slip using `l10n_au_include_current_slip`
|
|
Not stored to allow recomputing if a new slip is added to a previous period.
|
|
|
|
Example: (All slips from same employee)
|
|
Slip 1: ytd = Slip 1
|
|
Slip 2: ytd = Slip 1 + Slip 2
|
|
Slip 3: ytd = Slip 1 + Slip 2 + Slip 3
|
|
"""
|
|
self.update({"payslip_ytd_totals": {}})
|
|
au_slips = self.filtered(lambda x: x.country_code == "AU")
|
|
if not au_slips:
|
|
return
|
|
# Groups the ytd slips for each payslip
|
|
slips_to_read = self.browse()
|
|
ytd_slips = defaultdict(lambda: self.env["hr.payslip"])
|
|
for slip in au_slips:
|
|
ytd_slips[slip.id] = slip._l10n_au_get_year_to_date_slips(l10n_au_include_current_slip=self.env.context.get("l10n_au_include_current_slip"))
|
|
slips_to_read |= ytd_slips[slip.id]
|
|
|
|
fields_to_compute = [
|
|
"l10n_au_foreign_tax_withheld",
|
|
"l10n_au_exempt_foreign_income",
|
|
"l10n_au_salary_sacrifice_other",
|
|
"l10n_au_salary_sacrifice_superannuation",
|
|
"l10n_au_extra_negotiated_super",
|
|
"l10n_au_extra_compulsory_super",
|
|
]
|
|
# Prefetch the data to avoid multiple reads
|
|
slips_to_read.fetch(fields_to_compute + ["l10n_au_income_stream_type"])
|
|
slip_lines_data = self.env["hr.payslip.line"].search_fetch(
|
|
[("slip_id", "in", slips_to_read.ids)],
|
|
["slip_id", "category_id", "code", "total"],
|
|
)
|
|
worked_days_data = self.env["hr.payslip.worked_days"].search_fetch(
|
|
[("payslip_id", "in", slips_to_read.ids)],
|
|
["payslip_id", "code", "amount", "work_entry_type_id"],
|
|
)
|
|
input_lines_data = self.env["hr.payslip.input"].search_fetch(
|
|
[("payslip_id", "in", slips_to_read.ids)],
|
|
["payslip_id", "code", "amount", "l10n_au_payroll_code_description", "l10n_au_payroll_code", "l10n_au_payment_type"],
|
|
)
|
|
|
|
for payslip in au_slips.sorted(key=lambda x: x.date_from):
|
|
year_slips = ytd_slips[payslip.id]
|
|
totals = {
|
|
income_stream: self._template_payslip_ytd_totals(with_inputs=True)
|
|
for income_stream in set((year_slips + payslip).mapped("l10n_au_income_stream_type"))
|
|
}
|
|
|
|
for line in slip_lines_data.filtered(lambda x: x.slip_id.id in year_slips.ids):
|
|
totals[line.slip_id.l10n_au_income_stream_type]["slip_lines"][line.category_id.code][line.code] += line.total
|
|
totals[line.slip_id.l10n_au_income_stream_type]["slip_lines"][line.category_id.code]["total"] += line.total
|
|
|
|
for line in worked_days_data.filtered(lambda x: x.payslip_id.id in year_slips.ids):
|
|
totals[line.payslip_id.l10n_au_income_stream_type]["worked_days"][line.work_entry_type_id.id]["amount"] += line.amount
|
|
totals[line.payslip_id.l10n_au_income_stream_type]["worked_days"][line.work_entry_type_id.id]["is_leave"] = line.work_entry_type_id.is_leave
|
|
totals[line.payslip_id.l10n_au_income_stream_type]["worked_days"][line.work_entry_type_id.id]["payroll_code"] = line.work_entry_type_id.l10n_au_work_stp_code
|
|
|
|
for income_stream, slips in groupby(year_slips, lambda x: x.l10n_au_income_stream_type):
|
|
for field in fields_to_compute:
|
|
totals[income_stream]["fields"][field] = sum(slip[field] for slip in slips)
|
|
|
|
for input_line in input_lines_data.filtered(lambda x: x.payslip_id.id in year_slips.ids):
|
|
if input_line.input_type_id.id not in totals[input_line.payslip_id.l10n_au_income_stream_type]["input_lines"]:
|
|
totals[input_line.payslip_id.l10n_au_income_stream_type]["input_lines"][input_line.input_type_id.id] = {
|
|
"amount": 0.0,
|
|
"code": input_line.code,
|
|
"payroll_code": input_line.l10n_au_payroll_code,
|
|
"payment_type": input_line.l10n_au_payment_type,
|
|
"payroll_code_description": input_line.l10n_au_payroll_code_description,
|
|
}
|
|
totals[input_line.payslip_id.l10n_au_income_stream_type]["input_lines"][input_line.input_type_id.id]["amount"] += input_line.amount
|
|
payslip.payslip_ytd_totals = totals
|
|
|
|
@api.model
|
|
def _issues_dependencies(self):
|
|
return [
|
|
'input_line_ids',
|
|
'input_line_ids.input_type_id',
|
|
'input_line_ids.name',
|
|
'input_line_ids.input_type_id.l10n_au_payment_type',
|
|
'l10n_au_termination_type',
|
|
'employee_id.birthday',
|
|
]
|
|
|
|
def _get_errors_by_slip(self):
|
|
errors_by_slip = super()._get_errors_by_slip()
|
|
draft_slips = self.filtered(lambda ps: ps.state == 'draft')
|
|
|
|
missing_birthday = draft_slips.filtered_domain([
|
|
('employee_id.birthday', '=', False),
|
|
('l10n_au_termination_type', '!=', False),
|
|
('input_line_ids', 'any',
|
|
[('input_type_id.l10n_au_payment_type', '=', 'etp')]
|
|
),
|
|
])
|
|
for employee, slips in missing_birthday.grouped('employee_id').items():
|
|
error = {
|
|
'message': _('Missing birth date on employee'),
|
|
'action_text': _('Employee'),
|
|
'action': employee._get_records_action(),
|
|
'level': 'danger',
|
|
}
|
|
for slip in slips:
|
|
errors_by_slip[slip].append(error)
|
|
|
|
return errors_by_slip
|
|
|
|
@api.constrains('input_line_ids', 'employee_id')
|
|
def _check_input_lines(self):
|
|
for payslip in self:
|
|
if payslip.country_code != "AU":
|
|
continue
|
|
version = payslip.version_id
|
|
input_director_fees = self.env.ref("l10n_au_hr_payroll.input_gross_director_fee")
|
|
if input_director_fees in payslip.input_line_ids.mapped("input_type_id") \
|
|
and version.l10n_au_income_stream_type in ["OSP", "WHM", "LAB", "VOL", "SWP"]:
|
|
raise ValidationError(_(
|
|
"Director fees are not allowed for income stream type '%s'.",
|
|
version.l10n_au_income_stream_type,
|
|
))
|
|
|
|
if (
|
|
payslip.version_id.l10n_au_salary_sacrifice_superannuation or payslip.version_id.l10n_au_salary_sacrifice_other) \
|
|
and version.l10n_au_income_stream_type in ["OSP", "LAB", "VOL"]:
|
|
raise ValidationError(_(
|
|
"Salary sacrifice is not allowed for income stream type '%s'.",
|
|
self.version_id.l10n_au_income_stream_type,
|
|
))
|
|
|
|
if payslip.input_line_ids.filtered(
|
|
lambda x: x.code == "BACKPAY.INPUT") and version.l10n_au_income_stream_type == "OSP":
|
|
raise ValidationError(_("Bonuses and Commissions are not allowed for income stream type 'OSP'."))
|
|
|
|
# handle separately
|
|
overtime_lines = payslip.worked_days_line_ids.filtered(lambda l: l.work_entry_type_id.l10n_au_work_stp_code == "T")
|
|
overtime_inputs = payslip.input_line_ids.filtered(lambda l: l.l10n_au_payroll_code == "Overtime")
|
|
if (overtime_lines or overtime_inputs) and version.l10n_au_income_stream_type in ["OSP", "LAB", "VOL"]:
|
|
raise ValidationError(
|
|
_("Overtime is not allowed for income stream type '%s'.", version.l10n_au_income_stream_type))
|
|
|
|
if payslip.l10n_au_foreign_tax_withheld and version.l10n_au_income_stream_type != "FEI":
|
|
raise ValidationError(_("Foreign Income tax Witholding is only allowed for income stream type 'FEI'."))
|
|
if payslip.l10n_au_exempt_foreign_income and version.l10n_au_income_stream_type != "SAW":
|
|
raise ValidationError(_("Exempt Foreign Income is only allowed for income stream type 'SAW'."))
|
|
|
|
@api.model_create_multi
|
|
def create(self, vals_list):
|
|
payslips = super().create(vals_list)
|
|
payslips._add_unused_leaves_to_payslip()
|
|
return payslips
|
|
|
|
def compute_sheet(self):
|
|
# Update the contribution amounts from the contract
|
|
self.env.add_to_compute(self._fields['l10n_au_extra_negotiated_super'], self)
|
|
self.env.add_to_compute(self._fields['l10n_au_extra_compulsory_super'], self)
|
|
self.env.add_to_compute(self._fields['l10n_au_salary_sacrifice_superannuation'], self)
|
|
self.env.add_to_compute(self._fields['l10n_au_salary_sacrifice_other'], self)
|
|
self.env.add_to_compute(self._fields['l10n_au_income_stream_type'], self)
|
|
self.l10n_au_other_input_details_ids._check_input_details()
|
|
return super().compute_sheet()
|
|
|
|
def action_refresh_from_work_entries(self):
|
|
# Force Recompute gross unused leaves amounts
|
|
self.sudo()._add_unused_leaves_to_payslip()
|
|
super().action_refresh_from_work_entries()
|
|
|
|
def _l10n_au_get_year_to_date_slips(self, l10n_au_include_current_slip=False):
|
|
""" Returns a list of all payslips in the same fiscal year as the current payslip.
|
|
Current slip is included if it is validated. Else it can be forced to be included using
|
|
'l10n_au_include_current_slip=True'
|
|
"""
|
|
if not self:
|
|
return self.env["hr.payslip"]
|
|
start_year = self.version_id._l10n_au_get_financial_year_start(self.date_from)
|
|
year_slips = self.env["hr.payslip"].search([
|
|
("employee_id", "=", self.employee_id.id),
|
|
("company_id", "=", self.company_id.id),
|
|
("state", "in", ["paid", "validated"]),
|
|
("date_from", ">=", start_year),
|
|
("date_from", "<=", self.date_from),
|
|
], order="date_from")
|
|
# To include the current slip while its not validated
|
|
if l10n_au_include_current_slip:
|
|
year_slips |= self
|
|
return year_slips
|
|
|
|
def _l10n_au_get_year_to_date_totals(self, fields_to_compute=None, l10n_au_include_current_slip=False):
|
|
""" Uses the 'payslip_ytd_totals' field to return YTD values. Can be computed once and manipulated
|
|
to get the desired values according to the arguments.
|
|
"""
|
|
fields_to_compute = fields_to_compute or []
|
|
# Add as a method arg in master
|
|
group_income_stream_types = self.env.context.get("group_income_stream_types", False)
|
|
payslip_ytd_totals = self.with_context(l10n_au_include_current_slip=l10n_au_include_current_slip).payslip_ytd_totals
|
|
payslip_ytd_totals = self._clean_payslip_ytd_totals(payslip_ytd_totals)
|
|
|
|
if group_income_stream_types:
|
|
totals = payslip_ytd_totals
|
|
else:
|
|
# Sum all income stream types from payslip_ytd_totals
|
|
totals = self._template_payslip_ytd_totals()
|
|
for income_stream, income_stream_totals in payslip_ytd_totals.items():
|
|
for category, slip_line in income_stream_totals["slip_lines"].items():
|
|
for code, value in slip_line.items():
|
|
totals["slip_lines"][category][code] += value
|
|
for entry_type, worked_day_total in income_stream_totals["worked_days"].items():
|
|
totals["worked_days"][entry_type]["amount"] += worked_day_total["amount"]
|
|
totals["worked_days"][entry_type]["is_leave"] = worked_day_total["is_leave"]
|
|
totals["worked_days"][entry_type]["payroll_code"] = worked_day_total["payroll_code"]
|
|
totals["periods"] += income_stream_totals["periods"]
|
|
for field, value in income_stream_totals["fields"].items():
|
|
totals["fields"][field] += value
|
|
if field in fields_to_compute:
|
|
fields_to_compute.remove(field)
|
|
|
|
# Add any remaining fields to compute
|
|
if fields_to_compute:
|
|
year_slips = self._l10n_au_get_year_to_date_slips(l10n_au_include_current_slip=l10n_au_include_current_slip)
|
|
for income_stream, slips in groupby(year_slips, lambda x: x.l10n_au_income_stream_type):
|
|
for field in fields_to_compute:
|
|
if group_income_stream_types:
|
|
totals[income_stream]["fields"][field] = sum(slip[field] for slip in slips)
|
|
else:
|
|
totals["fields"][field] += sum(slip[field] for slip in slips)
|
|
|
|
return totals
|
|
|
|
def _l10n_au_get_ytd_inputs(self, l10n_au_include_current_slip=False):
|
|
payslip_ytd_totals = self.with_context(l10n_au_include_current_slip=l10n_au_include_current_slip).payslip_ytd_totals
|
|
payslip_ytd_totals = self._clean_payslip_ytd_totals(payslip_ytd_totals)
|
|
group_income_stream_types = self.env.context.get("group_income_stream_types", False)
|
|
if group_income_stream_types:
|
|
input_totals = {}
|
|
for income_stream, income_stream_totals in payslip_ytd_totals.items():
|
|
input_totals[income_stream] = income_stream_totals["input_lines"]
|
|
else:
|
|
input_totals = self._template_payslip_ytd_totals(with_inputs=True)["input_lines"]
|
|
for income_stream, income_stream_totals in payslip_ytd_totals.items():
|
|
for input_type, input_line in income_stream_totals["input_lines"].items():
|
|
if input_type not in input_totals:
|
|
input_totals[input_type] = input_line
|
|
else:
|
|
input_totals[input_type]["amount"] += input_line["amount"]
|
|
return input_totals
|
|
|
|
@api.model
|
|
def _l10n_au_compute_weekly_earning(self, amount, period):
|
|
""" Given an amount and a pay schedule, calculate the weekly earning used to calculate withholding amounts.
|
|
The amount given should already include any allowances that are subject to withholding.
|
|
|
|
The calculation, recommended by the legislation, is as follows:
|
|
|
|
Example:
|
|
Weekly income $ 467.59
|
|
Add allowance subject to withholding $ 9.50
|
|
Total earnings (ignore cents) $ 477.00
|
|
Add 99 cents $ 0.99
|
|
Weekly earnings $ 477.99
|
|
|
|
If the period is other than weekly, for withholding purposes, we should calculate the equivalent weekly earnings
|
|
and use it for the computation.
|
|
|
|
:param amount: The amount paid in the period.
|
|
:param period: The pay schedule in use (weekly, fortnightly or monthly)
|
|
:return: the weekly earnings subject to withholding
|
|
"""
|
|
if period == "monthly" and round(amount % 1, 2) == 0.33:
|
|
amount += 0.01
|
|
weekly_amount = self._l10n_au_convert_amount(amount, period, "weekly")
|
|
return floor(weekly_amount) + 0.99
|
|
|
|
@api.model
|
|
def _l10n_au_convert_amount(self, amount, period_from, period_to):
|
|
"""
|
|
Convert an amount from a period to another.
|
|
https://www.ato.gov.au/tax-rates-and-codes/payg-withholding-schedule-1-statement-of-formulas-for-calculating-amounts-to-be-withheld/working-out-the-weekly-earnings
|
|
"""
|
|
if period_to == "weekly":
|
|
return amount / NUMBER_OF_WEEKS[period_from]
|
|
coefficient = PERIODS_PER_YEAR[period_from] / PERIODS_PER_YEAR[period_to]
|
|
return amount * coefficient
|
|
|
|
def _l10n_au_compute_withholding_amount(self, period_earning, period, coefficients, unused_leaves=False):
|
|
"""
|
|
Compute the withholding amount for the given period.
|
|
|
|
:param period_earning: The gross earning (after allowances subjects to withholding)
|
|
:param period: The type of pay schedule (weekly, fortnightly, or monthly)
|
|
:param coefficients: The scale that should be applied to this employee. It will depend on their schedule.
|
|
"""
|
|
self.ensure_one()
|
|
# if custom withholding rate
|
|
if self.version_id.l10n_au_withholding_variation != "none" and not unused_leaves \
|
|
or self.version_id.l10n_au_withholding_variation == "leaves":
|
|
return period_earning * self.version_id.l10n_au_withholding_variation_amount / 100
|
|
|
|
# Compute the weekly earning as per government legislation.
|
|
# They recommend to calculate the weekly equivalent of the earning, if using another pay schedule.
|
|
# https://www.ato.gov.au/tax-rates-and-codes/payg-withholding-schedule-1-statement-of-formulas-for-calculating-amounts-to-be-withheld/using-a-formula?anchor=Usingaformula#Usingaformula
|
|
weekly_earning = self._l10n_au_compute_weekly_earning(period_earning, period)
|
|
weekly_withhold = 0.0
|
|
|
|
# For scale 4 (no tfn provided), cents are ignored when applying the rate.
|
|
# Final withhold amount is rounded up
|
|
if (self.version_id.l10n_au_tax_treatment_category == "N"
|
|
or self.version_id.l10n_au_tfn_declaration == '000000000'):
|
|
weekly_withhold = floor(weekly_earning) * (coefficients / 100)
|
|
return ceil(self._l10n_au_convert_amount(weekly_withhold, "weekly", period))
|
|
|
|
# Categories with flat rate withholding
|
|
if (
|
|
self.version_id.l10n_au_tax_treatment_category in ["C", "V"]
|
|
or (
|
|
self.version_id.l10n_au_tax_treatment_category == "W"
|
|
and self.company_id.l10n_au_registered_for_palm
|
|
)
|
|
or (
|
|
self.version_id.l10n_au_tax_treatment_category == "A"
|
|
and self.version_id.l10n_au_tax_treatment_option_actor == "P"
|
|
)
|
|
):
|
|
weekly_withhold = weekly_earning * (coefficients / 100)
|
|
return self._l10n_au_convert_amount(weekly_withhold, "weekly", period)
|
|
# The formula to compute the withholding amount is:
|
|
# y = a * x - b, where:
|
|
# y is the weekly amount to withhold
|
|
# x is the number of whole dollars in the weekly earning + 99 cents
|
|
# a and b are the coefficient defined in the data.
|
|
for coef in coefficients:
|
|
if weekly_earning < float(coef[0]):
|
|
weekly_withhold = coef[1] * weekly_earning - coef[2]
|
|
break
|
|
|
|
amount = round(weekly_withhold)
|
|
period_amount = self._l10n_au_convert_amount(amount, "weekly", period)
|
|
if period in ["daily", "monthly"]:
|
|
period_amount = round(period_amount)
|
|
return period_amount
|
|
|
|
def _l10n_au_compute_medicare_adjustment(self, period_earning, period, params: dict):
|
|
""" Compute the Medicare adjustment for the given period.
|
|
Medicare has 3 components:
|
|
- Medicare Levy Exemption: This can be half or full exemption. The standard rate is 2% 'ML' key in rule params.
|
|
This is positive amount that reduces total withhold.
|
|
- Medicare Levy Reduction: This is a reduction in the Medicare levy based on the family situation. This reduces
|
|
the total withhold.
|
|
- Medicare Levy Surcharge: This is a surcharge for high-income earners without private health insurance. This
|
|
increases the total withhold.
|
|
"""
|
|
self.ensure_one()
|
|
# Medicare Exemption
|
|
# This is part of the tax schedule and not a separate calculation post July 2024
|
|
exemption = 0
|
|
if self.version_id.l10n_au_medicare_exemption != "X" and self.date_from < date(2024, 7, 1):
|
|
exemption = period_earning * params["ML"]
|
|
|
|
# Medicare Surcharge
|
|
surcharge = 0
|
|
if self.version_id.l10n_au_medicare_surcharge != "X":
|
|
rate = {"1": 0.01, "2": 0.0125, "3": 0.015}
|
|
surcharge = period_earning * rate[self.version_id.l10n_au_medicare_surcharge]
|
|
|
|
# Medicare reduction
|
|
adjustment = 0
|
|
if self.version_id.l10n_au_tax_free_threshold and self.version_id.l10n_au_medicare_reduction != "X":
|
|
weekly_earning = self._l10n_au_compute_weekly_earning(period_earning, period)
|
|
child_reduction = params["ADDC"] * int(self.version_id.l10n_au_medicare_reduction)
|
|
weekly_family_threshold = (child_reduction + self.version_id.l10n_au_yearly_wage) / 52
|
|
|
|
shading_out_point = floor(weekly_family_threshold * params["SOPD"] / params["SOPM"])
|
|
weekly_levy_adjustment = 0
|
|
if weekly_earning < params["WEST"]:
|
|
weekly_levy_adjustment = (weekly_earning - params["WLA"]) * params["SOPM"]
|
|
elif weekly_earning >= params["WEST"] and weekly_earning < weekly_family_threshold:
|
|
weekly_levy_adjustment = weekly_earning * params["ML"]
|
|
elif weekly_earning >= weekly_family_threshold and weekly_earning < shading_out_point:
|
|
weekly_levy_adjustment = (weekly_family_threshold * params["ML"]) - ((weekly_earning - weekly_family_threshold) * params["SOPD"])
|
|
amount = round(weekly_levy_adjustment)
|
|
adjustment = self._l10n_au_convert_amount(amount, "weekly", period)
|
|
if period in ["daily", "monthly"]:
|
|
adjustment = round(adjustment)
|
|
|
|
return adjustment + exemption - surcharge
|
|
|
|
def _l10n_au_compute_lumpsum_withhold(self, lumpsum):
|
|
'''
|
|
Withholding for back payments is calculated by apportioning it over the number of periods it applies for and
|
|
summing the difference in withholding over every period.
|
|
'''
|
|
self.ensure_one()
|
|
return lumpsum * 0.47
|
|
|
|
def _l10n_au_compute_loan_withhold(self, period_earning, period, coefficients):
|
|
self.ensure_one()
|
|
# STSL withholding from 24 sept 2025 onwards to use schedule 1 formula
|
|
# https://softwaredevelopers.ato.gov.au/2025-pay-you-go-payg-withholding-tax-tables
|
|
if self.date_from >= date(2025, 9, 24):
|
|
return self._l10n_au_compute_withholding_amount(period_earning, period, coefficients)
|
|
|
|
weekly_earning = self._l10n_au_compute_weekly_earning(period_earning, period)
|
|
weekly_withhold = 0.0
|
|
if weekly_earning <= coefficients[0][1]:
|
|
return 0.0
|
|
|
|
for coef in coefficients:
|
|
if coef[1] == "inf" or weekly_earning <= coef[1]:
|
|
weekly_withhold = coef[2] / 100 * weekly_earning
|
|
break
|
|
|
|
amount = round(weekly_withhold)
|
|
period_amount = self._l10n_au_convert_amount(amount, "weekly", period)
|
|
if period in ["daily", "monthly"]:
|
|
period_amount = round(period_amount)
|
|
|
|
return period_amount
|
|
|
|
@api.model
|
|
def _l10n_au_get_tax_free_etp_types(self):
|
|
return self.env["hr.payslip.input.type"].concat(
|
|
self.env.ref("l10n_au_hr_payroll.input_genuine_redundancy"),
|
|
self.env.ref("l10n_au_hr_payroll.input_early_retirement_scheme"),
|
|
self.env.ref("l10n_au_hr_payroll.input_in_lieu_of_notice_genuine"),
|
|
self.env.ref("l10n_au_hr_payroll.input_severance_pay_genuine"),
|
|
)
|
|
|
|
def _l10n_au_compute_termination_withhold(self, ytd_total):
|
|
"""
|
|
Compute the withholding amount for the termination payment.
|
|
|
|
It is done in x steps:
|
|
- We first work out the smallest cap that will apply to this withholding computation
|
|
- We use this cap to work out the withholding amount
|
|
|
|
Currently missing feature in the computation:
|
|
- Multiple payments for a single termination. This could happen and will affect the cap.
|
|
- Death benefits. The withholding amount is impacted by a number of factor, like the beneficiary of the payment.
|
|
- Foreign residents tax treaties, which should exempt a foreign resident from a country with a treaty from the withholding tax.
|
|
- Tax free component.
|
|
An ETP has a tax-free component if part of the payment relate to invalidity or employment before 1 July 1983
|
|
- Handling delayed withholding.
|
|
|
|
The withholding amount is rounded up to the nearest dollar.
|
|
If no TFN is provided, the cents are ignored when calculating the withholding amount.
|
|
"""
|
|
self.ensure_one()
|
|
|
|
# 1) Working out the smallest cap.
|
|
# ================================
|
|
# We first calculate the whole-of-income cap by subtracting the sum of taxable payments made to the employee from $180000
|
|
# We then compare the whole-of-income cap with the ETP cap amount. This amount changes every year.
|
|
# If both caps are equal, we use the whole-of-income cap. Otherwise, we use the smallest of the two caps.
|
|
|
|
whole_of_income_cap = (self._rule_parameter("l10n_au_whoic_cap_schedule_11")
|
|
- ytd_total["slip_lines"]["GROSS"]["total"])
|
|
etp_cap = self._rule_parameter("l10n_au_etp_cap_schedule_11")
|
|
smallest_withholding_cap = min(whole_of_income_cap, etp_cap)
|
|
|
|
# 2) Working out the withholding amount
|
|
# =====================================
|
|
# An ETP can be made up of a tax-free component and a taxable component from which we much withheld an amount.
|
|
# The tax-free component is exempt from any withholding.
|
|
|
|
# The withheld will be different if the employee as given its TFN to the employer.
|
|
# In this case, we apply the amount calculated by applying the table rounded up to the nearest dollar.
|
|
|
|
# For a foreign resident, it will depend on whether there is a tax treaty with their country of residence.
|
|
# If the ETP is only assessable in the other country, no withholding is required.
|
|
# It the ETP is assessable in Australia, the withholding is using the same table but requires to exclude the Medicare levy of 2%
|
|
|
|
# When a TFN has not been provided, you must withhold 47% to a resident and 45% to a foreign resident.
|
|
# =====================================
|
|
|
|
# a) Compute the preservation age.
|
|
# The withholding amount varies depending on whether the employee has reached their preservation age by the
|
|
# end of the income year in which the payment is made.
|
|
employee_id = self.employee_id
|
|
tfn_provided = self.version_id.l10n_au_tfn_declaration != "000000000"
|
|
is_non_resident = self.version_id.is_non_resident
|
|
life_benefits_etp_rates = self._rule_parameter("l10n_au_etp_withholding_life_benefits_schedule_11")
|
|
over_the_cap_rate = life_benefits_etp_rates['over_cap']
|
|
no_tfn_rate = life_benefits_etp_rates['no_tfn']
|
|
|
|
# These payments are subjects to a tax-free limit.
|
|
tax_free_types = self._l10n_au_get_tax_free_etp_types()
|
|
|
|
preservation_ages = self._rule_parameter("l10n_au_preservation_age_schedule_11")
|
|
# The preservation age is determined based on the financial year in which the employee was born.
|
|
birth_financial_year = self.version_id._l10n_au_get_financial_year_start(employee_id.birthday).year
|
|
years_list = list(preservation_ages['years'].values())
|
|
if birth_financial_year < years_list[0]:
|
|
preservation_age = preservation_ages['before']
|
|
elif birth_financial_year > years_list[-1]:
|
|
preservation_age = preservation_ages['after']
|
|
else:
|
|
preservation_age = preservation_ages['years'][str(birth_financial_year)]
|
|
|
|
is_of_or_over_preservation_age = relativedelta(date.today(), employee_id.birthday).years >= preservation_age
|
|
|
|
# b) Some payments have a tax free limit, which depends on the completed years of services.
|
|
complete_years_of_service = relativedelta(self.date_to, employee_id._get_first_version_date()).years
|
|
base_tax_free_limit = self._rule_parameter("l10n_au_tax_free_base_schedule_11")
|
|
tax_free_limit = base_tax_free_limit + (complete_years_of_service * self._rule_parameter("l10n_au_tax_free_yearly_schedule_11"))
|
|
|
|
# c) tax-free component.
|
|
tax_free_amount = 0.0
|
|
|
|
# d) Compute the withholding.
|
|
withholding_amount = 0.0
|
|
# We need to always deal with the payment subject to the ETP cap first.
|
|
for input_line in self.input_line_ids.sorted(key=lambda i: 0 if i.input_type_id.l10n_au_etp_type == 'excluded' else 1):
|
|
if input_line.input_type_id.l10n_au_payment_type != 'etp':
|
|
continue
|
|
taxable_amount = input_line.amount
|
|
applicable_tax_free_limit = 0
|
|
# We check if the payment is subject to a tax-free limit.
|
|
if input_line.input_type_id in tax_free_types:
|
|
applicable_tax_free_limit = tax_free_limit
|
|
|
|
tax_free_amount += min(applicable_tax_free_limit, input_line.amount)
|
|
taxable_amount = max(0, taxable_amount - applicable_tax_free_limit)
|
|
# Besides that, the remaining taxable amounts are all subjects to withholding.
|
|
# If no tfn has been provided, the rate will be fixed to 47% (for residents) or 45% (for non-residents)
|
|
if not tfn_provided:
|
|
applicable_rate = no_tfn_rate
|
|
else:
|
|
age_group = 'over' if is_of_or_over_preservation_age else 'under'
|
|
applicable_rate = life_benefits_etp_rates[input_line.input_type_id.l10n_au_etp_type][age_group]
|
|
|
|
# If a foreign resident's ETP is assessable in Australia, Adjust the rate to exclude the Medicare levy of 2%.
|
|
if is_non_resident:
|
|
applicable_rate -= 2
|
|
# Depending on the type of payment, we either use the ETP cap, or the smallest ETP cap computed earlier.
|
|
applicable_cap = etp_cap if input_line.input_type_id.l10n_au_etp_type == 'excluded' else smallest_withholding_cap
|
|
# Separate between the amount below the cap, and the amount above the cap (if any)
|
|
taxable_amount_under_cap = min(applicable_cap, taxable_amount)
|
|
taxable_amount_over_cap = taxable_amount - taxable_amount_under_cap
|
|
# Then apply the rates accordingly.
|
|
if tfn_provided:
|
|
withholding_amount += taxable_amount_under_cap * (applicable_rate / 100)
|
|
withholding_amount += taxable_amount_over_cap * (over_the_cap_rate / 100)
|
|
else: # When no tfn is provided, ignore the cents when computing the withholding amount.
|
|
withholding_amount += int(taxable_amount_under_cap * (applicable_rate / 100))
|
|
withholding_amount += int(taxable_amount_over_cap * (over_the_cap_rate / 100))
|
|
etp_cap -= taxable_amount
|
|
|
|
return round(withholding_amount), tax_free_amount
|
|
|
|
def _l10n_au_get_unused_leave_by_type(self):
|
|
""" Returns the amount of unused leaves by type and totals of each type
|
|
"""
|
|
cutoff_dates = [datetime(1978, 8, 16).date(), datetime(1993, 8, 17).date()]
|
|
leaves_by_date = defaultdict(lambda:
|
|
{
|
|
"annual": {
|
|
"pre_1978": 0.0,
|
|
"pre_1993": 0.0,
|
|
"post_1993": 0.0,
|
|
},
|
|
"long_service": {
|
|
"pre_1978": 0.0,
|
|
"pre_1993": 0.0,
|
|
"post_1993": 0.0,
|
|
},
|
|
}
|
|
)
|
|
allocations = self.env["hr.leave.allocation"].search([
|
|
("state", "=", "validate"),
|
|
("holiday_status_id.l10n_au_leave_type", "in", ['annual', 'long_service']),
|
|
("employee_id", "in", self.employee_id.ids),
|
|
])
|
|
for payslip in self:
|
|
for allocation in allocations.filtered(
|
|
lambda x:
|
|
x.employee_id == payslip.employee_id and
|
|
x.date_from <= (payslip.version_id.date_end or payslip.date_to)
|
|
):
|
|
leave_type = leaves_by_date[payslip.id][allocation.holiday_status_id.l10n_au_leave_type]
|
|
unused = allocation.number_of_days - allocation.leaves_taken
|
|
if allocation.date_from < cutoff_dates[0]:
|
|
leave_type["pre_1978"] += unused
|
|
elif allocation.date_from < cutoff_dates[1]:
|
|
leave_type["pre_1993"] += unused
|
|
else:
|
|
leave_type["post_1993"] += unused
|
|
return leaves_by_date
|
|
|
|
def _l10n_au_get_unused_leave_totals(self):
|
|
leaves_by_date = self._l10n_au_get_unused_leave_by_type()
|
|
# Payslips without any matching allocation are absent from leaves_by_date,
|
|
# the default keeps them resolvable when this runs on a batch of payslips.
|
|
leave_totals = defaultdict(lambda: {"annual": 0.0, "long_service": 0.0})
|
|
leave_totals.update({
|
|
payslip: {
|
|
'annual': sum(leaves_data['annual'].values()),
|
|
'long_service': sum(leaves_data['long_service'].values()),
|
|
} for payslip, leaves_data in leaves_by_date.items()
|
|
})
|
|
return leave_totals
|
|
|
|
def _l10n_au_get_leaves_for_withhold(self):
|
|
self.ensure_one()
|
|
leaves_by_date = self._l10n_au_get_unused_leave_by_type()
|
|
leave_totals = self._l10n_au_get_unused_leave_totals()
|
|
# Precomputed values or user input
|
|
gross_totals = {
|
|
"annual": self._get_input_line_amount('AL'),
|
|
"long_service": self._get_input_line_amount('LSL'),
|
|
}
|
|
leave_amounts = defaultdict(lambda: {
|
|
"pre_1978": 0.0,
|
|
"pre_1993": 0.0,
|
|
"post_1993": 0.0,
|
|
})
|
|
unused_leaves_total = sum(gross_totals.values())
|
|
# Total gross amount is split evenly on the number of day of leaves unused
|
|
for leave_type, periods in leaves_by_date[self.id].items():
|
|
if not leave_totals[self.id][leave_type]:
|
|
continue
|
|
for period, amount in periods.items():
|
|
amount *= gross_totals[leave_type] / leave_totals[self.id][leave_type]
|
|
leave_amounts[leave_type][period] = amount
|
|
return leave_amounts, unused_leaves_total
|
|
|
|
def _add_unused_leaves_to_payslip(self):
|
|
""" Add the unused leaves to the payslip as input lines """
|
|
if not (self.env.is_superuser() or self.env.user.has_group('hr_payroll.group_hr_payroll_user')):
|
|
raise AccessError(_(
|
|
"You don't have the access rights to link an expense report to a payslip. You need to be a payroll officer to do that.")
|
|
)
|
|
termination_slips = self.filtered(
|
|
lambda p: p.country_code == "AU"
|
|
and p.state == 'draft'
|
|
and p.l10n_au_termination_type
|
|
)
|
|
termination_slips.input_line_ids.filtered(lambda x: x.code in ['AL', 'LSL']).unlink()
|
|
|
|
input_vals = []
|
|
leaves_totals = termination_slips._l10n_au_get_unused_leave_totals()
|
|
for payslip in termination_slips:
|
|
daily_wage = payslip._get_daily_wage()
|
|
annual_gross = leaves_totals[payslip.id]['annual'] * daily_wage
|
|
long_service_gross = leaves_totals[payslip.id]['long_service'] * daily_wage
|
|
if payslip.version_id.l10n_au_leave_loading == 'regular':
|
|
annual_gross *= 1 + payslip.version_id.l10n_au_leave_loading_rate / 100
|
|
if annual_gross:
|
|
input_vals.append({
|
|
'name': _('Gross Unused Annual Leaves'),
|
|
'amount': annual_gross,
|
|
'input_type_id': self.env.ref('l10n_au_hr_payroll.input_unused_leave_annual').id,
|
|
'payslip_id': payslip.id,
|
|
})
|
|
if long_service_gross:
|
|
input_vals.append({
|
|
'name': _('Gross Unused Long Service Leaves'),
|
|
'amount': long_service_gross,
|
|
'input_type_id': self.env.ref('l10n_au_hr_payroll.input_unused_leave_long_service').id,
|
|
'payslip_id': payslip.id,
|
|
})
|
|
self.env['hr.payslip.input'].create(input_vals)
|
|
|
|
def _l10n_au_get_unused_leave_hours(self):
|
|
# Only annual and long service leaves are to be taken into account for termination payments
|
|
leaves = self.env["hr.leave.allocation"].search([
|
|
("state", "=", "validate"),
|
|
("holiday_status_id.l10n_au_leave_type", "in", ['annual', 'long_service']),
|
|
("employee_id", "=", self.employee_id.id),
|
|
("date_from", "<=", self.version_id.date_end or self.date_to),
|
|
])
|
|
unused_days = sum([(leave.number_of_days - leave.leaves_taken) for leave in leaves])
|
|
return unused_days * self.version_id.resource_calendar_id.hours_per_day
|
|
|
|
def _l10n_au_calculate_marginal_withhold(self, leave_amount, coefficients, basic_amount):
|
|
self.ensure_one()
|
|
|
|
# For scale 4 (no tfn provided), cents are ignored when applying the rate.
|
|
if self.version_id.l10n_au_tax_treatment_category == "N" or \
|
|
self.version_id.l10n_au_tfn_declaration == '000000000':
|
|
withhold = leave_amount * (coefficients / 100)
|
|
return withhold
|
|
|
|
# TFN provided, we apply the normal table to the amount / period.
|
|
period = self.version_id.schedule_pay
|
|
amount_per_period = leave_amount / PERIODS_PER_YEAR[period]
|
|
|
|
normal_withhold = self._l10n_au_compute_withholding_amount(basic_amount, period, coefficients, unused_leaves=True)
|
|
leave_withhold = self._l10n_au_compute_withholding_amount(basic_amount + amount_per_period, period, coefficients, unused_leaves=True)
|
|
|
|
extra_withhold = (leave_withhold - normal_withhold) * PERIODS_PER_YEAR[period]
|
|
# <300 apply min(tax_table or 32%)
|
|
if leave_amount < self._rule_parameter('rule_parameter_leaves_low_threshold_schedule_7'):
|
|
return min(extra_withhold, leave_amount * self._rule_parameter('rule_parameter_leaves_low_withhold_schedule_7'))
|
|
return extra_withhold
|
|
|
|
def _l10n_au_calculate_long_service_leave_withholding(self, leave_withholding_rate, long_service_leaves, basic_amount):
|
|
self.ensure_one()
|
|
coefficients = self._l10n_au_tax_schedule_parameters()
|
|
pre_1978 = long_service_leaves["pre_1978"]
|
|
pre_1993 = long_service_leaves["pre_1993"]
|
|
post_1993 = long_service_leaves["post_1993"]
|
|
|
|
flat_part = pre_1993
|
|
marginal_part = pre_1978 * 0.05
|
|
|
|
if self.l10n_au_termination_type == "normal":
|
|
marginal_part += post_1993
|
|
else:
|
|
flat_part += post_1993
|
|
|
|
marginal_withhold = round(self._l10n_au_calculate_marginal_withhold(marginal_part, coefficients, basic_amount))
|
|
flat_withhold = round(flat_part * float(leave_withholding_rate) / 100)
|
|
return flat_withhold + marginal_withhold
|
|
|
|
def _l10n_au_calculate_annual_leave_withholding(self, leave_withholding_rate, annual_leaves, basic_amount):
|
|
self.ensure_one()
|
|
coefficients = self._l10n_au_tax_schedule_parameters()
|
|
pre_1993 = annual_leaves["pre_1993"]
|
|
post_1993 = annual_leaves["post_1993"]
|
|
|
|
flat_part = pre_1993
|
|
marginal_part = 0.0
|
|
|
|
if self.l10n_au_termination_type == "normal":
|
|
marginal_part += post_1993
|
|
else:
|
|
flat_part += post_1993
|
|
|
|
marginal_withhold = round(self._l10n_au_calculate_marginal_withhold(marginal_part, coefficients, basic_amount))
|
|
flat_withhold = round(flat_part * float(leave_withholding_rate) / 100)
|
|
|
|
return flat_withhold + marginal_withhold
|
|
|
|
def _l10n_au_compute_unused_leaves_withhold(self):
|
|
self.ensure_one()
|
|
basic_amount = self.version_id.wage
|
|
leaves, leaves_total = self._l10n_au_get_leaves_for_withhold()
|
|
if self.version_id.l10n_au_withholding_variation == 'leaves':
|
|
l10n_au_leave_withholding = self.version_id.l10n_au_withholding_variation_amount
|
|
return leaves_total * l10n_au_leave_withholding / 100
|
|
|
|
l10n_au_leave_withholding = self._rule_parameter("l10n_au_leave_withholding_schedule_7")
|
|
withholding = 0.0
|
|
# 2. Calculate long service leave withholding
|
|
withholding = self._l10n_au_calculate_long_service_leave_withholding(l10n_au_leave_withholding,
|
|
leaves["long_service"], basic_amount)
|
|
# 3. Calculate annual leave withholding
|
|
withholding += self._l10n_au_calculate_annual_leave_withholding(l10n_au_leave_withholding, leaves["annual"], basic_amount)
|
|
return withholding
|
|
|
|
def _get_pea_amount(self):
|
|
# Computed differently than the salary amounts
|
|
# https://www.servicesaustralia.gov.au/protected-earnings-amount-when-deducting-child-support?context=23156#a1
|
|
self.ensure_one()
|
|
pea = self._rule_parameter("l10n_au_pea")
|
|
schedule_pay = self.l10n_au_schedule_pay
|
|
if schedule_pay == "monthly":
|
|
pea = pea / 7 * 30.4375
|
|
if schedule_pay == "bi-weekly":
|
|
pea = pea * 2
|
|
if schedule_pay == "daily":
|
|
pea = pea / 7
|
|
return ceil(pea * 100) / 100 # Round up 2 decimal places
|
|
|
|
def _l10n_au_compute_child_support(self, net_earnings):
|
|
"""
|
|
net_earnings: Net salary after tax withhold and garnishee child support.
|
|
Compute the child support amount deducted from the net salary after tax withhold.
|
|
The child support has two components:
|
|
- A garnishee child support:
|
|
This can be a periodic % or fixed amount, or a one time lump sum. Deducted with
|
|
CHILD.SUPPORT.GARNISHEE rule.
|
|
- A child support deduction:
|
|
Amount set by ATO, this can only be withheld, if the remaining
|
|
net earnings are above the Protected Earnings Amount.
|
|
"""
|
|
self.ensure_one()
|
|
pea = self._get_pea_amount()
|
|
withhold = 0.0
|
|
if net_earnings > pea:
|
|
net_over_pea = net_earnings - pea
|
|
withhold += min(net_over_pea, self.version_id.l10n_au_child_support_deduction)
|
|
return withhold
|
|
|
|
def _l10n_au_has_extra_pay(self):
|
|
self.ensure_one()
|
|
return self.version_id._l10n_au_get_weeks_amount(self.date_to) == 53
|
|
|
|
def _l10n_au_compute_backpay_withhold(self, net_salary, backpay, salary_withhold):
|
|
"""Compute withhold for back payments
|
|
Args:
|
|
net_salary (float): Net salary including the backpayments
|
|
backpay (float): Backpay amount
|
|
salary_withhold (float): Withholding amount for the salary
|
|
"""
|
|
if self.version_id.l10n_au_tax_treatment_category == "H":
|
|
return 0
|
|
backpay_per_period = round(backpay / PERIODS_PER_YEAR[self.version_id.schedule_pay])
|
|
coefficients = self._l10n_au_tax_schedule_parameters()
|
|
total_withhold = self._l10n_au_compute_withholding_amount(net_salary + backpay_per_period, self.version_id.schedule_pay, coefficients)
|
|
|
|
backpay_withhold = total_withhold - abs(salary_withhold)
|
|
backpay_withhold *= PERIODS_PER_YEAR[self.version_id.schedule_pay]
|
|
backpay_withhold = -min(backpay_withhold, backpay * self._rule_parameter('l10n_au_withholding_backpay') / 100)
|
|
|
|
# Backpay HELP Withholding
|
|
coefficients = self._rule_parameter("l10n_au_stsl")[
|
|
"tax-free"
|
|
if self.version_id.l10n_au_tax_free_threshold
|
|
or self.version_id.is_non_resident
|
|
else "no-tax-free"
|
|
]
|
|
backpay_stsl_per_period = -self._l10n_au_compute_loan_withhold(net_salary + backpay_per_period, self.version_id.schedule_pay, coefficients)
|
|
salary_stsl = -self._l10n_au_compute_loan_withhold(net_salary, self.version_id.schedule_pay, coefficients)
|
|
backpay_stsl = (backpay_stsl_per_period - salary_stsl) * PERIODS_PER_YEAR[self.version_id.schedule_pay]
|
|
|
|
return backpay_withhold, backpay_stsl
|
|
|
|
def _l10n_au_tax_schedule_parameters(self) -> float | list[tuple[float]]:
|
|
self.ensure_one()
|
|
employee = self.employee_id
|
|
version = self.version_id
|
|
match version.l10n_au_tax_treatment_category:
|
|
case "R": # Regular
|
|
rates = self._rule_parameter("l10n_au_withholding_schedule_1")
|
|
# Foreign or no TFN
|
|
if version.l10n_au_tfn_declaration != "000000000" and version.is_non_resident:
|
|
return rates["foreign"]
|
|
if version.l10n_au_tfn_declaration == "000000000":
|
|
residence = "foreign" if version.is_non_resident else "resident"
|
|
return rates["no-tfn"][residence]
|
|
# TFN provided
|
|
if version.l10n_au_medicare_exemption == "F":
|
|
return rates["full-exemption"]
|
|
elif version.l10n_au_medicare_exemption == "H":
|
|
return rates["half-exemption"]
|
|
# No exemption
|
|
elif version.l10n_au_medicare_exemption == "X":
|
|
tax_threshold = "tax-free" if version.l10n_au_tax_free_threshold else "no-tax-free"
|
|
return rates[tax_threshold]
|
|
case "A": # Actors
|
|
rates = self._rule_parameter("l10n_au_withholding_schedule_3")
|
|
if version.l10n_au_tax_treatment_option_actor == "P":
|
|
if not employee.birthday:
|
|
raise ValidationError(_("In order to process this payslip, a birth date should be set on the private information tab of the employee's form view."))
|
|
# if age less than 18, use underage
|
|
if relativedelta(date.today(), employee.birthday).years < 18:
|
|
if self.version_id.schedule_pay not in ["weekly", "fortnightly", "monthly"]:
|
|
raise ValidationError(_("The pay schedule for this employee is not supported for underage actors."))
|
|
return rates["promotional"]["underage"][self.version_id.schedule_pay]
|
|
# if age greater than 18
|
|
tfn_status = 'tfn' if version.l10n_au_tfn_declaration != "000000000" else 'no-tfn'
|
|
return rates["promotional"][tfn_status]
|
|
elif version.l10n_au_tax_treatment_option_actor == "D":
|
|
# Foreigner or no tfn
|
|
if version.is_non_resident and version.l10n_au_tfn_declaration != "000000000":
|
|
return rates["foreigner"]
|
|
elif version.is_non_resident and version.l10n_au_tfn_declaration == "000000000":
|
|
return rates["no-tfn"]["foreign"]
|
|
elif not version.is_non_resident and version.l10n_au_tfn_declaration == "000000000":
|
|
return rates["no-tfn"]["resident"]
|
|
# Resident with tfn
|
|
tax_threshold = "tax-free" if version.l10n_au_tax_free_threshold else "no-tax-free"
|
|
return rates[tax_threshold]
|
|
case "C": # Horticulture & Shearing
|
|
rates = self._rule_parameter("l10n_au_withholding_schedule_2")
|
|
tfn_status = 'tfn' if version.l10n_au_tfn_declaration != "000000000" else 'no-tfn'
|
|
if version.is_non_resident:
|
|
return rates["foreign"][tfn_status]
|
|
else:
|
|
return rates["resident"][tfn_status]
|
|
case "S": # Seniors & Pensioners
|
|
rates = self._rule_parameter("l10n_au_withholding_schedule_9")
|
|
# No TFN provided
|
|
if version.l10n_au_tfn_declaration == "000000000":
|
|
return rates["no-tfn"]["foreign"] if version.is_non_resident else rates["no-tfn"]["resident"]
|
|
# TFN provided
|
|
if version.l10n_au_tfn_declaration != "000000000":
|
|
option = {
|
|
"S": "single",
|
|
"M": "couple",
|
|
"I": "illness-separated",
|
|
}[version.l10n_au_tax_treatment_option_seniors]
|
|
if self.date_from >= fields.Date.from_string("2024-07-01") and version.l10n_au_medicare_exemption == "F":
|
|
return rates["full-exemption"][option] # Only available 2024+
|
|
if self.date_from >= fields.Date.from_string("2024-07-01") and version.l10n_au_medicare_exemption == "H":
|
|
return rates["half-exemption"][option] # Only available 2024+
|
|
return rates[option] # Works for both pre-2024 and 2024+
|
|
case "H": # Working Holiday Makers
|
|
rates = self._rule_parameter("l10n_au_withholding_schedule_15")
|
|
# No TFN
|
|
if version.l10n_au_tfn_declaration == "000000000":
|
|
return rates["no-tfn"]
|
|
# TFN Provided
|
|
if self.company_id.l10n_au_registered_for_whm:
|
|
return rates["registered"]
|
|
else:
|
|
return rates["unregistered"]
|
|
case "W": # Seasonal Worker Program
|
|
rate = self._rule_parameter("l10n_au_withholding_schedule_palm")
|
|
if self.company_id.l10n_au_registered_for_palm:
|
|
return rate['registered']
|
|
return rate['unregistered']
|
|
case "F": # Foreign Resident
|
|
return self._rule_parameter("l10n_au_withholding_foreign_resident")['foreign']
|
|
case "N":
|
|
rates = self._rule_parameter("l10n_au_withholding_no_tfn")
|
|
return rates["foreign"] if version.is_non_resident else rates["resident"]
|
|
case "D":
|
|
raise ValidationError(_("The tax treatment category 'D' is not yet supported."))
|
|
case "V":
|
|
rate = self._rule_parameter("l10n_au_withholding_schedule_10")
|
|
if version.l10n_au_tax_treatment_option_voluntary == "C":
|
|
return version.l10n_au_comissioners_installment_rate or rate
|
|
elif version.l10n_au_tax_treatment_option_voluntary == "O":
|
|
return rate
|
|
|
|
# In case no option satisfied. Config issue
|
|
raise UserError(_(
|
|
"The Employee '%(employee)s' with tax treatment category '%(category)s' has no valid tax schedule.",
|
|
employee=employee.name, category=version.l10n_au_tax_treatment_category
|
|
))
|
|
|
|
def _get_regular_worked_hours(self):
|
|
"""
|
|
Get the worked hours for the payslip except for the overtime hours.
|
|
"""
|
|
self.ensure_one()
|
|
overtime_days = self.worked_days_line_ids.filtered(lambda d: d.work_entry_type_id.l10n_au_work_stp_code == 'T')
|
|
return self.sum_worked_hours - sum(overtime_days.mapped("number_of_hours"))
|
|
|
|
def _l10n_au_compute_special_allowance_amounts(self, special_allowances=None):
|
|
""" Computes the total amounts allowances with l10n_au_paygw_treatment == "special"
|
|
Args:
|
|
special_allowances (recordset): Optional recordset of allowances to compute.
|
|
If not provided, it will filter the input lines of the payslip.
|
|
"""
|
|
self.ensure_one()
|
|
if special_allowances is None:
|
|
special_allowances = self.input_line_ids.filtered(
|
|
lambda x: x.l10n_au_payment_type == 'allowance' and x.input_type_id.l10n_au_paygw_treatment == "special"
|
|
)
|
|
taxable_amount, tax_free_amount = 0.0, 0.0
|
|
inputs_ytd = self._l10n_au_get_ytd_inputs()
|
|
for allowance in special_allowances:
|
|
allowance_ytd_amount = inputs_ytd[allowance.input_type_id.id]["amount"]
|
|
# Cents Per Kilometre Allowance
|
|
if allowance.code == "ALW.CPK":
|
|
if not allowance.l10n_au_input_details_id.quantity:
|
|
raise UserError(_("Cents Per Kilometre Allowances require the Number of kilometres on the input details."))
|
|
|
|
# YTD number of kms
|
|
ytd_kms_details = dict(self.env["l10n_au.hr.input.details"]._read_group([
|
|
("payslip_id", "in", self._l10n_au_get_year_to_date_slips().ids),
|
|
("input_type_id", "=", allowance.input_type_id.id),
|
|
], ["input_type_id"], ["quantity:sum"]))
|
|
ytd_kms = ytd_kms_details[allowance.input_type_id] if ytd_kms_details else 0
|
|
|
|
min_claimable_rate = self._rule_parameter("l10n_au_allowance_cpk")["claimable"]
|
|
km_limit = self._rule_parameter("l10n_au_allowance_cpk")["limit"]
|
|
current_rate = allowance.l10n_au_input_details_id.rate
|
|
if current_rate < min_claimable_rate:
|
|
raise UserError(_(
|
|
"Cents Per Kilometre Allowance amount must be greater than or equal to the minimum rate of %.2f for the current pay period.",
|
|
min_claimable_rate
|
|
))
|
|
km_limit -= ytd_kms
|
|
# Over the limit, is charged as taxable
|
|
if km_limit < 0:
|
|
taxable_amount += current_rate * allowance.l10n_au_input_details_id.quantity
|
|
else:
|
|
# If the allowance exceeds the km limit, the excess is taxable
|
|
tax_free_qty = min(allowance.l10n_au_input_details_id.quantity, km_limit)
|
|
taxable_qty = max(0, allowance.l10n_au_input_details_id.quantity - km_limit)
|
|
# If the rate for cpk is greater than the minimum claimable rate, the tax-free qty
|
|
# is taxed for the additional rate.
|
|
tax_free_amount += min(allowance.amount, min_claimable_rate) * tax_free_qty
|
|
taxable_amount += max(0, current_rate - min_claimable_rate) * tax_free_qty
|
|
# All of the taxable qty is taxed at the allowance rate
|
|
taxable_amount += current_rate * taxable_qty
|
|
|
|
# Domestic Travel Allowance
|
|
elif allowance.code == "ALW.DTA":
|
|
city = allowance.l10n_au_input_details_id.city_id
|
|
if not city or not allowance.l10n_au_input_details_id.quantity:
|
|
raise UserError(_("Domestic Travel Allowance requires a City and Number of days to be set."))
|
|
|
|
salary_level = self._get_salary_level()
|
|
city_code = city.get_external_id()[city.id].split(".")[-1]
|
|
|
|
common_rule = self._rule_parameter("l10n_au_allowance_daily_totals")[salary_level]
|
|
daily_rule = common_rule.get(city_code, 0)
|
|
# Use the flat claimable amount for common cities
|
|
if daily_rule:
|
|
daily_claimable_amount = daily_rule
|
|
# If not found in common cities, check for cities with variable claimable amounts
|
|
else:
|
|
# For Level 1 and Level 2, check for high cost centers
|
|
if salary_level != "Level3":
|
|
daily_rule = self._rule_parameter("l10n_au_variable_allowance_high_cost_centres").get(city_code, 0)
|
|
if daily_rule:
|
|
# High-cost centres table_4 amount + l10n_au_variable_allowance_high_cost_centres
|
|
daily_claimable_amount = daily_rule + self._rule_parameter("l10n_au_variable_allowance_coef")[salary_level]
|
|
else:
|
|
# If also not found in high cost centers, it is other cities
|
|
daily_claimable_amount = common_rule.get("other")
|
|
# For Level 3, we use the variable amount for high cost centres and other cities
|
|
else:
|
|
daily_rule = self._rule_parameter("l10n_au_variable_allowance_high_cost_centres").get(city_code, 0)
|
|
daily_claimable_amount = max(207, daily_rule) + self._rule_parameter("l10n_au_variable_allowance_coef")[salary_level]
|
|
total_claimable_amount = daily_claimable_amount * allowance.l10n_au_input_details_id.quantity
|
|
tax_free_amount += min(allowance.amount, total_claimable_amount)
|
|
taxable_amount += max(0, allowance.amount - total_claimable_amount)
|
|
# Overseas Travel Allowance
|
|
elif allowance.code == "ALW.OTA":
|
|
country = allowance.l10n_au_input_details_id.country_id
|
|
if not country or country.code == "AU" or not allowance.l10n_au_input_details_id.quantity:
|
|
raise UserError(_("Overseas Travel Allowance requires an overseas Country and Number of days to be set."))
|
|
salary_level = self._get_salary_level()
|
|
# Get the country group for the overseas country
|
|
country_group = self._rule_parameter("l10n_au_country_groups").get(country.code, False)
|
|
if not country_group:
|
|
raise UserError(_("%s is not a valid country for Overseas Travel Allowance.", country.name))
|
|
# Claimable amount is based on the salary level and country group
|
|
daily_claimable_amount = self._rule_parameter("l10n_au_allowance_country_groups_salary_tiers")[salary_level][str(country_group)]
|
|
total_claimable_amount = daily_claimable_amount * allowance.l10n_au_input_details_id.quantity
|
|
tax_free_amount += min(allowance.amount, total_claimable_amount)
|
|
taxable_amount += max(0, allowance.amount - total_claimable_amount)
|
|
|
|
# Overtime Meal Allowance and Laundry Allowance
|
|
elif allowance.code in ["ALW.OMA", "ALW.LA"]:
|
|
if allowance.code == "ALW.OMA":
|
|
claimable_amount = self._rule_parameter("l10n_au_allowance_overtime_meal")["claimable"]
|
|
else:
|
|
claimable_amount = self._rule_parameter("l10n_au_allowance_laundry")["claimable"]
|
|
allowance_ytd_amount = inputs_ytd[allowance.input_type_id.id]["amount"]
|
|
# If the allowance YTD amount is greater than the claimable amount, it is taxable
|
|
# Otherwise, upto the claimable amount is tax free, rest is tax-free
|
|
claimable_amount -= allowance_ytd_amount
|
|
if claimable_amount < 0:
|
|
taxable_amount += allowance.amount
|
|
else:
|
|
tax_free_amount += min(allowance.amount, claimable_amount)
|
|
taxable_amount += max(0, allowance.amount - claimable_amount)
|
|
else:
|
|
raise UserError(_("The allowance '%s' is not supported for special PAYG treatment.", allowance.name))
|
|
|
|
return taxable_amount, tax_free_amount
|
|
|
|
def _get_salary_level(self):
|
|
self.ensure_one()
|
|
salary_level = self._rule_parameter("l10n_au_variable_allowance_salary_limits")
|
|
if self.version_id.l10n_au_yearly_wage < salary_level["Level1"]:
|
|
return "Level1"
|
|
elif self.version_id.l10n_au_yearly_wage < salary_level["Level2"]:
|
|
return "Level2"
|
|
return "Level3"
|