From 3f307a0688d66c65c1aff0e7af6105bb8072bc9c Mon Sep 17 00:00:00 2001 From: fegger Date: Mon, 14 Sep 2026 13:42:06 +0200 Subject: [PATCH] [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. --- .agents/MEMORY.md | 28 +++ .../__manifest__.py | 7 +- .../data/input_types_private.xml | 11 + .../rule_parameters_reisekosten_private.xml | 29 +++ .../data/salary_rules_reisekosten_private.xml | 56 +++++ .../docs/rechenfaelle/reisekosten.md | 60 +++++ .../models/__init__.py | 1 + .../models/account_chart_private.py | 4 + .../models/reisekosten.py | 227 ++++++++++++++++++ .../tests/test_reisekosten_private.py | 146 +++++++++++ .../views/reisekosten_views.xml | 40 +++ 11 files changed, 607 insertions(+), 2 deletions(-) create mode 100644 addons/l10n_at_hr_payroll_private/data/rule_parameters_reisekosten_private.xml create mode 100644 addons/l10n_at_hr_payroll_private/data/salary_rules_reisekosten_private.xml create mode 100644 addons/l10n_at_hr_payroll_private/docs/rechenfaelle/reisekosten.md create mode 100644 addons/l10n_at_hr_payroll_private/models/reisekosten.py create mode 100644 addons/l10n_at_hr_payroll_private/tests/test_reisekosten_private.py create mode 100644 addons/l10n_at_hr_payroll_private/views/reisekosten_views.xml diff --git a/.agents/MEMORY.md b/.agents/MEMORY.md index 7b8e153..bd21476 100644 --- a/.agents/MEMORY.md +++ b/.agents/MEMORY.md @@ -2,6 +2,34 @@ ## Current focus +**AP13-A Reisekosten Inland umgesetzt — 114 Tests grün** +(2026-09-14, nach Nutzerfreigabe): + +- Privatmodul **19.0.10.0.0** verwendet das Odoo-19-Standardmodul + `hr_payroll_expense`: genehmigte, eigenfinanzierte Spesen werden über + `EXPENSES` mit dem Payslip verbunden und gegen das Verbindlichkeitskonto + abgestimmt. `rule_expenses` bucht auf 3850; die AT-Buchungsmatrix ergänzt + diese Zuordnung. +- Neu `models/reisekosten.py`, Parameter `at_reise_inland`/`at_km_geld`, + Regeln `EXPENSES`/`REISE_STEUERPFLICHTIG`/`REISE_NETTOAUSGLEICH`, + Spesen-View und Rechenfälle `docs/rechenfaelle/reisekosten.md`. + Abgedeckt: dokumentiertes Inland-Taggeld, Nächtigung (belegt oder + Pauschale), Kilometergeld, ÖV-Dienstreise und Familienheimfahrten. + Kilometergrenzen: 30.000 km über Dienst-/Heimfahrten sowie 12.600 € bei + niedrigem Satz; Familienheimfahrt: eine je Woche und keine Kumulation mit + steuerfreiem Taggeld. +- Der vollständige Spesenbetrag wird genau einmal über `EXPENSES` ersetzt. + Nur der §-26-Überhang wird als laufender Bezug mit LSt/SV/LNK behandelt; + die neutrale Nettoausgleichsregel verhindert eine Doppelersetzung. +- Tests `test_reisekosten_private.py`: Taggeld-Cap, Kilometergrenze, + abgabenpflichtiger Überhang/Nettoausgleich. Vollständiger Odoo-Lauf in + `ap13a_test`: **114 Tests, 0 Fehler**; Python/XML-Checks grün. +- Ausdrücklich noch offen in AP13-B: Pendlerpauschale, Pendlereuro und + Jobticket. Nicht im AP13-A-Scope: Auslandssätze, §3 Abs1 Z16b-KV-Sätze + und die detaillierte Einsatzortdauer-Verfolgung. +- Vorheriger Dokumentations-/Ignore-Commit: **`91ac9e5`**. AP13-A ist + derzeit **noch uncommittet**. + **AP 12b wertgebundene Sachbezüge umgesetzt — 166/166 Tests grün** (2026-09-14, freigegeben): diff --git a/addons/l10n_at_hr_payroll_private/__manifest__.py b/addons/l10n_at_hr_payroll_private/__manifest__.py index 7408df9..392f8af 100644 --- a/addons/l10n_at_hr_payroll_private/__manifest__.py +++ b/addons/l10n_at_hr_payroll_private/__manifest__.py @@ -1,7 +1,7 @@ # Part of the odoo-at-payroll project. License: LGPL-3. { 'name': 'Österreich - Privatwirtschaft Personalverrechnung (General-AT)', - 'version': '19.0.9.7.0', + 'version': '19.0.10.0.0', 'author': 'Florian Egger', 'website': 'https://gem360.at', 'category': 'Human Resources/Payroll', @@ -91,7 +91,7 @@ Kein VRV-Bezug (Gemeinde-Schiene bleibt ``l10n_at_gemeinde_payroll`` + gem360-Brücke). """, 'depends': ['l10n_at_hr_payroll', 'hr_payroll_account', - 'l10n_at', 'hr_payroll_account_iso20022', + 'hr_payroll_expense', 'l10n_at', 'hr_payroll_account_iso20022', 'hr_holidays', 'hr_work_entry_holidays'], 'data': [ 'security/ir.model.access.csv', @@ -112,6 +112,7 @@ gem360-Brücke). 'data/salary_rules_pfaendung_private.xml', 'data/salary_rules_sb_private.xml', 'data/salary_rules_sb2_private.xml', + 'data/salary_rules_reisekosten_private.xml', 'data/salary_rules_sv_private.xml', 'data/salary_rules_bvg_private.xml', 'data/salary_rules_bvg_private.xml', @@ -123,6 +124,7 @@ gem360-Brücke). 'data/rule_parameters_pfaendung_private.xml', 'data/rule_parameters_sb_private.xml', 'data/rule_parameters_sb2_private.xml', + 'data/rule_parameters_reisekosten_private.xml', 'data/bv_kasse_seed.xml', 'views/kv_views.xml', 'views/bv_views.xml', @@ -132,6 +134,7 @@ gem360-Brücke). 'views/aeb_views.xml', 'views/pfaendung_views.xml', 'views/sachbezuege_views.xml', + 'views/reisekosten_views.xml', 'report/gp8_reports.xml', 'report/buchungsbeleg_report.xml', 'report/bv_report.xml', diff --git a/addons/l10n_at_hr_payroll_private/data/input_types_private.xml b/addons/l10n_at_hr_payroll_private/data/input_types_private.xml index 7e8ad59..71376a8 100644 --- a/addons/l10n_at_hr_payroll_private/data/input_types_private.xml +++ b/addons/l10n_at_hr_payroll_private/data/input_types_private.xml @@ -50,4 +50,15 @@ Sachbezug Bonusmeilen — Einlösungswert (manuell) ATP_SB_BONUSMEILEN + + + + Reisekosten — steuerpflichtiger Überhang + ATP_REISE_STEUERPFLICHTIG + + + Reisekosten — Nettoausgleich Überhang + ATP_REISE_NETTOAUSGLEICH + \ No newline at end of file diff --git a/addons/l10n_at_hr_payroll_private/data/rule_parameters_reisekosten_private.xml b/addons/l10n_at_hr_payroll_private/data/rule_parameters_reisekosten_private.xml new file mode 100644 index 0000000..80ada31 --- /dev/null +++ b/addons/l10n_at_hr_payroll_private/data/rule_parameters_reisekosten_private.xml @@ -0,0 +1,29 @@ + + + + + Reisekosten Inland + at_reise_inland + + + + + 2026-01-01 + {'taggeld_tag': 30.0, 'naechtigung_pauschale': 17.0} + + + + Amtliches Kilometergeld + at_km_geld + + + + + 2026-01-01 + {'pkw': 0.50, 'motorrad': 0.25, 'fahrrad': 0.25, 'dienstfahrrad': 0.0, 'fuss': 0.25, 'mitfahrer': 0.15, 'km_jahr_max': 30000.0, 'niedriger_satz_jahr_max': 12600.0} + + diff --git a/addons/l10n_at_hr_payroll_private/data/salary_rules_reisekosten_private.xml b/addons/l10n_at_hr_payroll_private/data/salary_rules_reisekosten_private.xml new file mode 100644 index 0000000..b7710ed --- /dev/null +++ b/addons/l10n_at_hr_payroll_private/data/salary_rules_reisekosten_private.xml @@ -0,0 +1,56 @@ + + + + + Spesenersatz (über Reisekostenabrechnung) + EXPENSES + + + + python + result = 'EXPENSES' in inputs and inputs['EXPENSES'].amount + neutral + keine + code + + + + + + Reisekosten — steuerpflichtiger Überhang + REISE_STEUERPFLICHTIG + + + + python + result = 'ATP_REISE_STEUERPFLICHTIG' in inputs and inputs['ATP_REISE_STEUERPFLICHTIG'].amount + laufend + code + + + + Reisekosten — Nettoausgleich Überhang + REISE_NETTOAUSGLEICH + + + + python + result = 'ATP_REISE_NETTOAUSGLEICH' in inputs and inputs['ATP_REISE_NETTOAUSGLEICH'].amount + neutral + keine + code + + + diff --git a/addons/l10n_at_hr_payroll_private/docs/rechenfaelle/reisekosten.md b/addons/l10n_at_hr_payroll_private/docs/rechenfaelle/reisekosten.md new file mode 100644 index 0000000..93e46e0 --- /dev/null +++ b/addons/l10n_at_hr_payroll_private/docs/rechenfaelle/reisekosten.md @@ -0,0 +1,60 @@ +# Reisekosten — Rechenfälle AP13-A + +Stand: 2026-09. AP13-A verarbeitet belegte **inländische** +Reisekosten über Odoos `hr_payroll_expense`-Ablauf. Die Reisekostenabrechnung +wird dem nächsten Payslip zugeordnet; die Standardregel `EXPENSES` trägt den +gesamten Ersatz und die Verbindlichkeitsabstimmung. + +## Rechts- und Systemgrenze + +- § 26 Z 4 EStG: Inland-Taggeld maximal 30,00 € je 24 Stunden, + bei mehr als drei Stunden 1/12 je angefangener Stunde; Nächtigungspauschale + 17,00 € oder belegte tatsächliche Kosten. +- Amtliches Kilometergeld: Pkw/Kombi 0,50 €, Motorrad 0,25 €, + Fahrrad/Fußweg 0,25 €, notwendiger Mitfahrer 0,15 €; insgesamt maximal + 30.000 km im Kalenderjahr einschließlich Familienheimfahrten. +- Übersteigende oder nicht dokumentierte Beträge sind laufender Arbeitslohn + und damit LSt-, SV- und lohnnebenkostenpflichtig. +- Auslandssätze, § 3 Abs. 1 Z 16b EStG und die detaillierte Einsatzortdauer + sind nicht Bestandteil von AP13-A. + +## RF-REI-1 — Taggeld im Höchstausmaß + +Dienstreise 10 Stunden, Auslagenersatz 25,00 €: + +- Höchstausmaß: 10 × 30/12 = 25,00 €; +- `EXPENSES`: 25,00 € netto ersetzt; +- keine LSt-, SV- oder LNK-Wirkung. + +## RF-REI-2 — Taggeld-Überhang + +Dienstreise 10 Stunden, Auslagenersatz 40,00 €: + +- steuerfrei: 25,00 €; +- Überhang: 15,00 €; +- `EXPENSES` ersetzt 40,00 €, `REISE_STEUERPFLICHTIG` erhöht die laufende + Bemessungsgrundlage um 15,00 €, `REISE_NETTOAUSGLEICH` verhindert einen + zweiten Ersatz dieser 15,00 €; +- die darauf entfallende DN-SV und LSt vermindern die Nettauszahlung. + +## RF-REI-3 — Kilometergeld und Jahresgrenze + +500 km mit Pkw, Anspruch 250,00 €: + +- 500 × 0,50 € = 250,00 € steuerfrei bei Reisekostenaufzeichnung; +- nach 29.800 bestätigten Jahreskilometern sind nur 200 km bzw. 100,00 € + steuerfrei, 150,00 € sind laufender Arbeitslohn. + +## RF-REI-4 — Familienheimfahrt + +Eine dokumentierte Heimfahrt am arbeitsfreien Tag wird als eigene Reiseart +geführt und zählt in dieselbe 30.000-km-Jahresgrenze. Eine zweite erstattete +Heimfahrt in derselben Kalenderwoche wird vor Einreichung abgewiesen. +Für denselben arbeitsfreien Tag darf kein steuerfreies Taggeld kumulieren. + +## Buchungsanker + +`EXPENSES` muss auf ein Verbindlichkeitskonto buchen. Die AT- +Buchungsmatrix ordnet es dem abstimmbaren Lohnverbindlichkeitskonto 3850 zu; +`hr_payroll_expense` gleicht dieses gegen die aus der Spesenabrechnung +entstandene Mitarbeiterverbindlichkeit ab. diff --git a/addons/l10n_at_hr_payroll_private/models/__init__.py b/addons/l10n_at_hr_payroll_private/models/__init__.py index ee15a35..7505f84 100644 --- a/addons/l10n_at_hr_payroll_private/models/__init__.py +++ b/addons/l10n_at_hr_payroll_private/models/__init__.py @@ -18,3 +18,4 @@ from . import aeb from . import pfaendung from . import pfaendung_report from . import sachbezuege_private +from . import reisekosten diff --git a/addons/l10n_at_hr_payroll_private/models/account_chart_private.py b/addons/l10n_at_hr_payroll_private/models/account_chart_private.py index dff1358..09d8d83 100644 --- a/addons/l10n_at_hr_payroll_private/models/account_chart_private.py +++ b/addons/l10n_at_hr_payroll_private/models/account_chart_private.py @@ -52,6 +52,10 @@ L10N_AT_BUCHUNGSMATRIX = { 'rule_ue_sofei': {'debit': ('6310',)}, 'rule_ue_sofei_nacht': {'debit': ('6310',)}, 'rule_pram': {'debit': ('6230',)}, + # hr_payroll_expense requires a payable debit account on EXPENSES; + # use the reconcilable payroll payable account for its automatic + # expense-to-payslip reconciliation. + 'rule_expenses': {'debit': ('3850',)}, 'rule_b13': {'debit': ('6240',)}, 'rule_b14': {'debit': ('6240',)}, 'rule_b14_rueck': {'debit': ('6240',)}, diff --git a/addons/l10n_at_hr_payroll_private/models/reisekosten.py b/addons/l10n_at_hr_payroll_private/models/reisekosten.py new file mode 100644 index 0000000..2515f5c --- /dev/null +++ b/addons/l10n_at_hr_payroll_private/models/reisekosten.py @@ -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 diff --git a/addons/l10n_at_hr_payroll_private/tests/test_reisekosten_private.py b/addons/l10n_at_hr_payroll_private/tests/test_reisekosten_private.py new file mode 100644 index 0000000..9d1929f --- /dev/null +++ b/addons/l10n_at_hr_payroll_private/tests/test_reisekosten_private.py @@ -0,0 +1,146 @@ +# Part of the odoo-at-payroll project. License: LGPL-3. +"""AP13-A: domestic travel expenses and taxable-overhang payroll split.""" +from datetime import date, timedelta + +from odoo.tests import TransactionCase, tagged + + +@tagged('post_install', '-at_install') +class ReisekostenPrivateTest(TransactionCase): + + def setUp(self): + super().setUp() + self.structure = self.env.ref( + 'l10n_at_hr_payroll_private.structure_at_privat') + self.kv2203 = self.env.ref('l10n_at_hr_payroll_private.kv_si_2203') + self.gruppe_ii = self.env.ref( + 'l10n_at_hr_payroll_private.kv2203_grp_ii') + self.calendar = self.env['resource.calendar'].create({ + 'name': 'AT Test Reise 40h Mo-Fr', + 'tz': 'Europe/Vienna', + 'hours_per_day': 8, + 'attendance_ids': [ + (0, 0, {'name': 'Tag %d' % weekday, + 'dayofweek': str(weekday), + 'hour_from': 8.0, 'hour_to': 16.0}) + for weekday in range(5) + ], + }) + self.env.company.resource_calendar_id = self.calendar + + def _employee(self): + employee = self.env['hr.employee'].create({'name': 'Test Reise privat'}) + version = self.env['hr.version'].search( + [('employee_id', '=', employee.id)], limit=1) + if not version: + version = self.env['hr.version'].create({ + 'employee_id': employee.id, 'date_start': date(2020, 1, 1)}) + version.write({ + 'contract_date_start': date(2020, 1, 1), + 'kv_id': self.kv2203.id, + 'kv_gruppe_id': self.gruppe_ii.id, + 'erfahrungsstufe': 1, + 'resource_calendar_id': self.calendar.id, + }) + return employee + + def _slip(self, employee, month, year=2026): + first = date(year, month, 1) + next_month = (first + timedelta(days=32)).replace(day=1) + slip = self.env['hr.payslip'].create({ + 'name': 'Test-Reise-Privat %d/%d' % (month, year), + 'employee_id': employee.id, + 'struct_id': self.structure.id, + 'date_from': first, + 'date_to': next_month - timedelta(days=1), + }) + slip.compute_sheet() + return slip + + def _expense(self, values): + """An in-memory expense is sufficient for the legal split helper.""" + return self.env['hr.expense'].new({ + 'name': 'Testreise', + 'employee_id': self._employee().id, + 'total_amount_currency': 0.0, + 'unit_amount': 0.0, + **values, + }) + + def _input(self, slip, xmlid, amount): + input_type = self.env.ref(xmlid) + self.env['hr.payslip.input'].create({ + 'payslip_id': slip.id, + 'input_type_id': input_type.id, + 'amount': amount, + }) + + def _total(self, slip, code): + return sum(slip.line_ids.filtered(lambda line: line.code == code).mapped('total')) + + def test_01_inland_taggeld_cap(self): + """10 h domestic travel: 10 × 30/12 = 25 €; the 15 € excess is + ordinary running remuneration.""" + expense = self._expense({ + 'total_amount': 40.0, + 'l10n_at_reise_art': 'taggeld', + 'l10n_at_reise_datum': date(2026, 1, 15), + 'l10n_at_reise_stunden': 10.0, + 'l10n_at_reise_nachweis': True, + }) + frei, taxable, km_ytd, niedriger_satz_ytd = expense._l10n_at_reise_split() + self.assertAlmostEqual(frei, 25.0, places=2) + self.assertAlmostEqual(taxable, 15.0, places=2) + self.assertEqual(km_ytd, 0.0) + self.assertEqual(niedriger_satz_ytd, 0.0) + + def test_02_kilometergeld_annual_cap(self): + """After 29,800 confirmed annual kilometres, 500 private-car + kilometres yield only 100 € tax-free; 150 € are taxable.""" + expense = self._expense({ + 'total_amount': 250.0, + 'l10n_at_reise_art': 'kilometergeld', + 'l10n_at_reise_datum': date(2026, 11, 15), + 'l10n_at_reise_kilometer': 500.0, + 'l10n_at_reise_fahrzeug': 'pkw', + 'l10n_at_reise_nachweis': True, + }) + frei, taxable, km_ytd, niedriger_satz_ytd = expense._l10n_at_reise_split(29800.0) + self.assertAlmostEqual(frei, 100.0, places=2) + self.assertAlmostEqual(taxable, 150.0, places=2) + self.assertAlmostEqual(km_ytd, 30300.0, places=2) + self.assertEqual(niedriger_satz_ytd, 0.0) + + def test_03_taxable_overhang_is_not_reimbursed_twice(self): + """EXPENSES refunds the actual 40 €. The 15 € classification line + is offset in the net while its SV and LSt remain deductible.""" + employee_with = self._employee() + employee_without = self._employee() + with_expense = self._slip(employee_with, 1) + without_expense = self._slip(employee_without, 1) + self._input(with_expense, 'hr_payroll_expense.expense_other_input', 40.0) + self._input(with_expense, + 'l10n_at_hr_payroll_private.input_type_atp_reise_steuerpflichtig', + 15.0) + self._input(with_expense, + 'l10n_at_hr_payroll_private.input_type_atp_reise_nettoausgleich', + 15.0) + with_expense.compute_sheet() + + self.assertAlmostEqual(self._total(with_expense, 'EXPENSES'), 40.0, places=2) + self.assertAlmostEqual( + self._total(with_expense, 'REISE_STEUERPFLICHTIG'), 15.0, places=2) + self.assertAlmostEqual( + self._total(with_expense, 'REISE_NETTOAUSGLEICH'), 15.0, places=2) + svdn_difference = (with_expense._l10n_at_svdn_gesamt() + - without_expense._l10n_at_svdn_gesamt()) + self.assertAlmostEqual( + with_expense._l10n_at_bemessung_laufen() + - without_expense._l10n_at_bemessung_laufen() + + svdn_difference, 15.0, places=2) + net_difference = (self._total(with_expense, 'NET') + - self._total(without_expense, 'NET')) + tax_difference = self._total(with_expense, 'LST_LFD') \ + - self._total(without_expense, 'LST_LFD') + self.assertAlmostEqual(net_difference, 40.0 - svdn_difference - tax_difference, + places=2) diff --git a/addons/l10n_at_hr_payroll_private/views/reisekosten_views.xml b/addons/l10n_at_hr_payroll_private/views/reisekosten_views.xml new file mode 100644 index 0000000..01e9440 --- /dev/null +++ b/addons/l10n_at_hr_payroll_private/views/reisekosten_views.xml @@ -0,0 +1,40 @@ + + + + hr.expense.form.l10n.at.reisekosten + hr.expense + + + + + + + + + + + + + + + + + + + + + + + +