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
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+