Files
odoo-at-payroll/addons/l10n_at_hr_payroll_private/models/reisekosten.py
T
fegger 3f307a0688 [IMP] l10n_at_hr_payroll_private: add domestic travel expenses (AP13-A)
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.
2026-09-14 13:42:06 +02:00

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