[IMP] l10n_at_hr_payroll_private: add Pendlerpauschale, Pendlereuro and job ticket data (AP13-B)

Model commuter declarations as effective-dated L 34 records so monthly changes, L 34/Pendlerrechner evidence and historical payslip reproduction stay auditable. PP enters as an aliquoted § 66 freibetrag of the running tax base while PE joins the annual absetzbetraege, matching the cumulative engine's rollup. Ticket handling follows § 26 Z 5: employer ticket costs reduce PP pro rata (lit i), Werkverkehr is data-driven via the residual route and salary conversion does not block PP/PE. Also register the AP13-A tests that were accidentally left out of the previous commit.
This commit is contained in:
2026-09-14 14:47:23 +02:00
parent 3f307a0688
commit 9ff977e0d4
12 changed files with 549 additions and 4 deletions
@@ -15,3 +15,5 @@ from . import test_aeb_private
from . import test_pfaendung_private
from . import test_sachbezuege_private
from . import test_sachbezuege2_private
from . import test_reisekosten_private
from . import test_pendler_private
@@ -0,0 +1,212 @@
# Part of the odoo-at-payroll project. License: LGPL-3.
"""AP13-B: Pendlerpauschale (Freibetrag) und Pendlereuro (Absetzbetrag)
auf der kumulativen LSt-Engine — L-34-gestützte, effektiv datierte
Erklärungen (lb-pen-01/02/04/06/09)."""
from datetime import date, timedelta
from odoo.tests import TransactionCase, tagged
@tagged('post_install', '-at_install')
class PendlerPrivateTest(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 Pendler 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, contract_start=date(2020, 1, 1)):
employee = self.env['hr.employee'].create({'name': 'Test Pendler 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': contract_start})
version.write({
'contract_date_start': contract_start,
'kv_id': self.kv2203.id,
'kv_gruppe_id': self.gruppe_ii.id,
'erfahrungsstufe': 1,
'resource_calendar_id': self.calendar.id,
})
return employee
def _erklaerung(self, employee, **vals):
werte = {
'employee_id': employee.id,
'date_start': date(2026, 1, 1),
'strecke_km': 25,
'pauschale_art': 'klein',
'fahrten_stufe': 'voll',
'pendlerrechner_datum': date(2025, 12, 15),
}
werte.update(vals)
return self.env['l10n.at.payroll.pendler.erklaerung'].create(werte)
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-Pendler %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 test_01_pp_freibetrag_voller_monat(self):
"""Kleines PP 25 km voll: 696/12 = 58 € als Freibetrag der
laufenden LSt-Bemessung; kein SV- und kein LNK-Effekt."""
emp_mit = self._employee()
emp_ohne = self._employee()
self._erklaerung(emp_mit)
slip_mit = self._slip(emp_mit, 1)
slip_ohne = self._slip(emp_ohne, 1)
pp, pe = slip_mit._l10n_at_pendler_werte()
self.assertAlmostEqual(pp, 58.0, places=2)
self.assertAlmostEqual(pe, 150.0, places=2)
self.assertAlmostEqual(
slip_ohne._l10n_at_bemessung_laufen()
- slip_mit._l10n_at_bemessung_laufen(), 58.0, places=2)
# PP = LSt-Freibetrag: SV- und LNK-Basis unverändert.
self.assertAlmostEqual(
slip_mit._l10n_at_sv_basis_roh()
- slip_ohne._l10n_at_sv_basis_roh(), 0.0, places=2)
self.assertAlmostEqual(
slip_mit._at_lnk_basis('flaf')
- slip_ohne._at_lnk_basis('flaf'), 0.0, places=2)
def test_02_pe_absetzbetrag(self):
"""Pendlereuro 2026: 6 €/km/Jahr → 25 km × 6 = 150 €/Jahr
zusätzlich zum Verkehrsabsetzbetrag (496 €)."""
emp = self._employee()
self._erklaerung(emp)
slip = self._slip(emp, 1)
absetz = self._l10n_at_vab()
self.assertAlmostEqual(
slip._l10n_at_absetzbetraege(100000.0), absetz + 150.0, places=2)
def _l10n_at_vab(self):
absetz = self.env['hr.rule.parameter']._get_parameter_from_code(
'at_lst_absetzbetraege', date(2026, 1, 31))
return float(absetz['verkehrsabsetzbetrag'])
def test_03_stufen_und_grosses_pp(self):
"""Staffel „mehr als": 40 km → klein 696 (nicht 1.356);
großes PP 2 km → 372/12 = 31 €, PE 2 km × 6 = 12 €/J."""
emp_40 = self._employee()
self._erklaerung(emp_40, strecke_km=40)
pp_40, pe_40 = self._slip(emp_40, 1)._l10n_at_pendler_werte()
self.assertAlmostEqual(pp_40, 58.0, places=2)
self.assertAlmostEqual(pe_40, 240.0, places=2)
emp_gross = self._employee()
self._erklaerung(emp_gross, strecke_km=2,
pauschale_art='gross')
pp, pe = self._slip(emp_gross, 1)._l10n_at_pendler_werte()
self.assertAlmostEqual(pp, 31.0, places=2)
self.assertAlmostEqual(pe, 12.0, places=2)
def test_04_fahrten_stufen(self):
"""lb-pen-09: 20 km klein, 4 Fahrten (1/3): PP 19,33 €. PE
wird als Jahreswert mit Stufenfaktor zurückgegeben: (20 × 6) ×
1/3 = 40 €/Jahr (Monatswert 3,33 €, lb-pen-04)."""
emp = self._employee()
self._erklaerung(emp, strecke_km=20, fahrten_stufe='drittel')
pp, pe = self._slip(emp, 1)._l10n_at_pendler_werte()
self.assertAlmostEqual(pp, 19.33, places=2)
self.assertAlmostEqual(pe, 120.0 * (1.0 / 3.0), places=2)
def test_05_untermonatig_lb_pen_09(self):
"""lb-pen-09-Quellenbeispiel: Beginn 25.4., 5 Pendeltage, 25 km,
klein 1/3 → 696 × 1/3 / 360 × 6 = 3,87 €."""
emp = self._employee(contract_start=date(2026, 4, 25))
self._erklaerung(emp, date_start=date(2026, 4, 1),
fahrten_stufe='drittel')
slip = self._slip(emp, 4)
pp, _pe = slip._l10n_at_pendler_werte()
self.assertAlmostEqual(pp, 3.87, places=2)
def test_06_arbeitgeber_kfz_sperre(self):
"""Arbeitgebereigenes Kfz für die Pendelstrecke: PP und PE
entfallen (§ 16 Abs 1 Z 6 lit b)."""
emp = self._employee()
self._erklaerung(emp, arbeitgeber_kfz=True)
slip = self._slip(emp, 1)
self.assertEqual(slip._l10n_at_pendler_werte(), (0.0, 0.0))
self.assertAlmostEqual(
slip._l10n_at_absetzbetraege(100000.0),
self._l10n_at_vab(), places=2)
def test_07_oeffi_ticket_kostenreduktion(self):
"""§ 26 Z 5 lit b: AG-Ticketkosten mindern das PP anteilig;
fällt das PP auf 0, entfällt auch der Fixkopplungs-PE."""
emp = self._employee()
self._erklaerung(emp, ticket_art='oeffi',
ticket_kosten_monatlich=30.0)
pp, pe = self._slip(emp, 1)._l10n_at_pendler_werte()
self.assertAlmostEqual(pp, 28.0, places=2)
self.assertAlmostEqual(pe, 150.0, places=2)
emp_null = self._employee()
self._erklaerung(emp_null, ticket_art='oeffi',
ticket_kosten_monatlich=100.0)
pp_null, pe_null = self._slip(emp_null, 1)._l10n_at_pendler_werte()
self.assertEqual(pp_null, 0.0)
self.assertEqual(pe_null, 0.0)
def test_08_bezugsumwandlung_sperrt_nicht(self):
"""Bezugsumwandlung: Ticket ist steuerpflichtiger laufender
Bezug (DN trägt die Kosten) — PP/PE laufen normal."""
emp = self._employee()
self._erklaerung(emp, ticket_art='oeffi',
ticket_bezugsumwandlung=True)
pp, pe = self._slip(emp, 1)._l10n_at_pendler_werte()
self.assertAlmostEqual(pp, 58.0, places=2)
self.assertAlmostEqual(pe, 150.0, places=2)
def test_09_effektive_datierte_auswahl(self):
"""Neuere Erklärung überschreibt die ältere im Gültigkeits-
fenster; historische Läufe behalten die damals gültige."""
emp = self._employee()
self._erklaerung(emp, strecke_km=25)
neu = self._erklaerung(
emp, date_start=date(2026, 3, 1), strecke_km=61)
m2 = self._slip(emp, 2)
m4 = self._slip(emp, 4)
self.assertAlmostEqual(m2._l10n_at_pendler_werte()[0], 58.0, places=2)
self.assertAlmostEqual(m4._l10n_at_pendler_werte()[0], 168.0, places=2)
self.assertEqual(
m4._l10n_at_pendler_erklaerung(), neu)
def test_10_e2e_lst_wirkung(self):
"""E2E: PP mindert die LSt-Bemessung und damit die LSt, PE die
Steuer direkt — LST_LFD sinkt gegen den Basellauf."""
emp_mit = self._employee()
emp_ohne = self._employee()
self._erklaerung(emp_mit)
slip_mit = self._slip(emp_mit, 1)
slip_ohne = self._slip(emp_ohne, 1)
lst_mit = sum(slip_mit.line_ids.filtered(
lambda line: line.code == 'LST_LFD').mapped('total'))
lst_ohne = sum(slip_ohne.line_ids.filtered(
lambda line: line.code == 'LST_LFD').mapped('total'))
self.assertLess(lst_mit, lst_ohne)
@@ -63,7 +63,7 @@ class ReisekostenPrivateTest(TransactionCase):
'name': 'Testreise',
'employee_id': self._employee().id,
'total_amount_currency': 0.0,
'unit_amount': 0.0,
'quantity': 1.0,
**values,
})