3f307a0688
Use Odoo's payroll expense reconciliation flow so documented employee-paid travel expenses remain linked to their payslip and payable settlement. Classify only the excess over the verified domestic tax-free limits as running remuneration, avoiding both unpaid expense reimbursements and double payment.
228 lines
11 KiB
Python
228 lines
11 KiB
Python
# Part of the odoo-at-payroll project. License: LGPL-3.
|
|
"""AP13-A: documented domestic travel reimbursements.
|
|
|
|
The standard ``hr_payroll_expense`` module keeps the employee expense and
|
|
payslip payable reconciliation intact. It pays the full documented expense
|
|
through ``EXPENSES``. Only the portion above the Austrian tax-free limit is
|
|
added as running remuneration; a neutral payroll deduction prevents this
|
|
classification line from reimbursing the expense a second time.
|
|
"""
|
|
from datetime import date, timedelta
|
|
from math import ceil
|
|
|
|
from odoo import _, Command, fields, models
|
|
from odoo.exceptions import UserError
|
|
|
|
|
|
class HrExpense(models.Model):
|
|
_inherit = 'hr.expense'
|
|
|
|
l10n_at_reise_art = fields.Selection(
|
|
[('keine', 'Keine AT-Reisekostenklassifikation'),
|
|
('taggeld', 'Taggeld Inland'),
|
|
('nachtigung', 'Nächtigung Inland'),
|
|
('kilometergeld', 'Kilometergeld Dienstfahrt'),
|
|
('oeffentlich', 'Öffentliche Verkehrsmittel Dienstreise'),
|
|
('familienheimfahrt', 'Familienheimfahrt')],
|
|
string='AT Reisekostenart', default='keine')
|
|
l10n_at_reise_datum = fields.Date(string='AT Reisetag')
|
|
l10n_at_reise_nachweis = fields.Boolean(
|
|
string='AT Reisekostenaufzeichnung/Nachweis vorhanden',
|
|
help='Beleg bzw. zeitnah geführte Reisekostenaufzeichnung als '
|
|
'Voraussetzung der abgabenfreien Behandlung.')
|
|
l10n_at_reise_stunden = fields.Float(string='AT Reisedauer (Stunden)')
|
|
l10n_at_reise_naechtigungen = fields.Float(string='AT Nächtigungen')
|
|
l10n_at_reise_naechtigung_pauschale = fields.Boolean(
|
|
string='AT Nächtigungspauschale',
|
|
help='Ohne Aktivierung wird ein belegter tatsächlicher Nächtigungs-'
|
|
'aufwand erstattet; mit Aktivierung gilt die Pauschale.')
|
|
l10n_at_reise_kilometer = fields.Float(string='AT Dienstkilometer')
|
|
l10n_at_reise_fahrzeug = fields.Selection(
|
|
[('pkw', 'Pkw/Kombi'), ('motorrad', 'Motorrad'),
|
|
('fahrrad', 'Eigenes Fahrrad/E-Bike'),
|
|
('dienstfahrrad', 'Dienstfahrrad'), ('fuss', 'Zu Fuß')],
|
|
string='AT Fahrzeugart')
|
|
l10n_at_reise_mitfahrer = fields.Integer(
|
|
string='AT Dienstlich notwendige Mitfahrer')
|
|
l10n_at_reise_familienheimfahrt = fields.Boolean(
|
|
string='AT Familienheimfahrt an arbeitsfreiem Tag',
|
|
help='Nur für die eigene Reisekostenart Familienheimfahrt: eine '
|
|
'Fahrt je Woche und bei unzumutbarer täglicher Rückkehr.')
|
|
l10n_at_reise_kein_taggeld = fields.Boolean(
|
|
string='AT Kein steuerfreies Taggeld für diesen arbeitsfreien Tag',
|
|
help='Familienheimfahrten dürfen nicht mit steuerfreiem Taggeld für '
|
|
'denselben arbeitsfreien Tag kumulieren.')
|
|
|
|
def _l10n_at_reise_validate(self):
|
|
for expense in self:
|
|
art = expense.l10n_at_reise_art
|
|
if art == 'keine':
|
|
continue
|
|
if not expense.l10n_at_reise_datum:
|
|
raise UserError(_('AT travel expenses require a travel date.'))
|
|
if not expense.l10n_at_reise_nachweis:
|
|
raise UserError(_(
|
|
'AT travel expenses require a receipt or travel expense record.'))
|
|
if art == 'taggeld' and expense.l10n_at_reise_stunden <= 0:
|
|
raise UserError(_('Domestic daily allowance requires a travel duration.'))
|
|
if art == 'nachtigung' and expense.l10n_at_reise_naechtigungen <= 0:
|
|
raise UserError(_('Overnight reimbursement requires the number of overnights.'))
|
|
if (art in ('kilometergeld', 'familienheimfahrt')
|
|
and (expense.l10n_at_reise_kilometer <= 0
|
|
or not expense.l10n_at_reise_fahrzeug)):
|
|
raise UserError(_(
|
|
'Mileage reimbursement requires kilometres and a vehicle type.'))
|
|
if art == 'familienheimfahrt':
|
|
if not expense.l10n_at_reise_familienheimfahrt:
|
|
raise UserError(_(
|
|
'Family home travel must be recorded as a trip on a non-working day.'))
|
|
if not expense.l10n_at_reise_kein_taggeld:
|
|
raise UserError(_(
|
|
'Family home travel cannot be combined with tax-free daily allowance for the same non-working day.'))
|
|
monday = expense.l10n_at_reise_datum - timedelta(
|
|
days=expense.l10n_at_reise_datum.weekday())
|
|
sunday = monday + timedelta(days=6)
|
|
weekly_trips = self.search_count([
|
|
('id', '!=', expense.id),
|
|
('employee_id', '=', expense.employee_id.id),
|
|
('l10n_at_reise_art', '=', 'familienheimfahrt'),
|
|
('l10n_at_reise_datum', '>=', monday),
|
|
('l10n_at_reise_datum', '<=', sunday),
|
|
('state', 'not in', ('draft', 'refused', 'cancel')),
|
|
])
|
|
if weekly_trips:
|
|
raise UserError(_(
|
|
'Only one reimbursed family home trip per calendar week is tax-free.'))
|
|
|
|
def action_submit(self):
|
|
self._l10n_at_reise_validate()
|
|
return super().action_submit()
|
|
|
|
def _l10n_at_reise_km_rate(self, datum):
|
|
self.ensure_one()
|
|
werte = self.env['hr.rule.parameter']._get_parameter_from_code(
|
|
'at_km_geld', datum)
|
|
rate = float(werte.get(self.l10n_at_reise_fahrzeug, 0.0))
|
|
passenger = (float(werte.get('mitfahrer', 0.0))
|
|
* max(self.l10n_at_reise_mitfahrer, 0))
|
|
return rate, passenger, werte
|
|
|
|
def _l10n_at_reise_split(self, km_ytd=0.0, niedriger_satz_ytd=0.0):
|
|
"""Return the tax-free and taxable portions of one expense.
|
|
|
|
``km_ytd`` is the confirmed annual mileage before this expense. The
|
|
caller processes current payslip expenses chronologically, so the
|
|
annual 30,000-km cap is applied deterministically.
|
|
"""
|
|
self.ensure_one()
|
|
amount = max(self.total_amount or 0.0, 0.0)
|
|
art = self.l10n_at_reise_art
|
|
if not art:
|
|
art = 'keine'
|
|
if art == 'keine':
|
|
return amount, 0.0, km_ytd, niedriger_satz_ytd
|
|
datum = self.l10n_at_reise_datum
|
|
if not datum:
|
|
raise UserError(_('AT travel expenses require a travel date.'))
|
|
if art in ('oeffentlich',):
|
|
return amount, 0.0, km_ytd, niedriger_satz_ytd
|
|
if art == 'taggeld':
|
|
werte = self.env['hr.rule.parameter']._get_parameter_from_code(
|
|
'at_reise_inland', datum)
|
|
hours = self.l10n_at_reise_stunden
|
|
if hours <= 3.0:
|
|
cap = 0.0
|
|
else:
|
|
full_days, rest_hours = divmod(hours, 24.0)
|
|
cap = full_days * float(werte['taggeld_tag'])
|
|
if rest_hours:
|
|
cap += ceil(rest_hours) * float(werte['taggeld_tag']) / 12.0
|
|
frei = min(amount, cap)
|
|
return frei, amount - frei, km_ytd, niedriger_satz_ytd
|
|
if art == 'nachtigung':
|
|
if not self.l10n_at_reise_naechtigung_pauschale:
|
|
return amount, 0.0, km_ytd, niedriger_satz_ytd
|
|
werte = self.env['hr.rule.parameter']._get_parameter_from_code(
|
|
'at_reise_inland', datum)
|
|
frei = min(amount, self.l10n_at_reise_naechtigungen
|
|
* float(werte['naechtigung_pauschale']))
|
|
return frei, amount - frei, km_ytd, niedriger_satz_ytd
|
|
rate, passenger, werte = self._l10n_at_reise_km_rate(datum)
|
|
remaining_km = max(float(werte['km_jahr_max']) - km_ytd, 0.0)
|
|
eligible_km = min(self.l10n_at_reise_kilometer, remaining_km)
|
|
# The annual kilometre cap covers all vehicles and family home trips.
|
|
# A service bicycle has no official kilometre allowance.
|
|
cap = eligible_km * (rate + passenger)
|
|
# For a lower official vehicle rate, § 26 Z 4 also limits the
|
|
# annual base allowance to €12,600; passenger supplements do not
|
|
# enlarge that amount.
|
|
niedriger = rate < float(werte['pkw'])
|
|
if niedriger:
|
|
rest_betrag = max(
|
|
float(werte['niedriger_satz_jahr_max']) - niedriger_satz_ytd,
|
|
0.0)
|
|
cap = min(cap, rest_betrag + eligible_km * passenger)
|
|
frei = min(amount, cap)
|
|
return (frei, amount - frei,
|
|
km_ytd + self.l10n_at_reise_kilometer,
|
|
niedriger_satz_ytd + (eligible_km * rate if niedriger else 0.0))
|
|
|
|
|
|
class HrPayslip(models.Model):
|
|
_inherit = 'hr.payslip'
|
|
|
|
def _l10n_at_reise_km_ytd(self):
|
|
self.ensure_one()
|
|
jahr_start = date(self.date_from.year, 1, 1)
|
|
expenses = self.env['hr.expense'].search([
|
|
('employee_id', '=', self.employee_id.id),
|
|
('l10n_at_reise_art', 'in', ('kilometergeld', 'familienheimfahrt')),
|
|
('l10n_at_reise_datum', '>=', jahr_start),
|
|
('l10n_at_reise_datum', '<', self.date_from),
|
|
('payslip_id.state', 'not in', ('draft', 'cancel')),
|
|
])
|
|
kilometres = sum(expenses.mapped('l10n_at_reise_kilometer'))
|
|
niedriger_betrag = 0.0
|
|
for expense in expenses:
|
|
rate, _passenger, werte = expense._l10n_at_reise_km_rate(
|
|
expense.l10n_at_reise_datum)
|
|
if rate < float(werte['pkw']):
|
|
niedriger_betrag += expense.l10n_at_reise_kilometer * rate
|
|
return kilometres, niedriger_betrag
|
|
|
|
def _l10n_at_reise_steuerpflichtig(self):
|
|
self.ensure_one()
|
|
km_ytd, niedriger_satz_ytd = self._l10n_at_reise_km_ytd()
|
|
taxable = 0.0
|
|
expenses = self.expense_ids.filtered(
|
|
lambda expense: expense.l10n_at_reise_art != 'keine').sorted(
|
|
key=lambda expense: (expense.l10n_at_reise_datum or date.min,
|
|
expense.id))
|
|
for expense in expenses:
|
|
_frei, ueberhang, km_ytd, niedriger_satz_ytd = \
|
|
expense._l10n_at_reise_split(km_ytd, niedriger_satz_ytd)
|
|
taxable += ueberhang
|
|
return taxable
|
|
|
|
def _update_expense_input_line_ids(self, search_new_valid_expenses=False):
|
|
super()._update_expense_input_line_ids(
|
|
search_new_valid_expenses=search_new_valid_expenses)
|
|
taxable_input = self.env.ref(
|
|
'l10n_at_hr_payroll_private.input_type_atp_reise_steuerpflichtig')
|
|
offset_input = self.env.ref(
|
|
'l10n_at_hr_payroll_private.input_type_atp_reise_nettoausgleich')
|
|
for payslip in self:
|
|
obsolete = payslip.input_line_ids.filtered(
|
|
lambda line: line.input_type_id in (taxable_input, offset_input))
|
|
commands = [Command.delete(line.id) for line in obsolete]
|
|
taxable = payslip._l10n_at_reise_steuerpflichtig()
|
|
if taxable:
|
|
commands += [
|
|
Command.create({'amount': taxable,
|
|
'input_type_id': taxable_input.id}),
|
|
Command.create({'amount': taxable,
|
|
'input_type_id': offset_input.id}),
|
|
]
|
|
if commands:
|
|
payslip.input_line_ids = commands
|