# 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