mirror of
http://100.103.83.12:3003/fegger/odoo-at-payroll.git
synced 2026-09-17 08:52:45 +00:00
[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.
This commit is contained in:
@@ -0,0 +1,227 @@
|
||||
# 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
|
||||
Reference in New Issue
Block a user