[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
+41 -2
View File
@@ -2,8 +2,47 @@
## Current focus
**AP13-A Reisekosten Inland umgesetzt — 114 Tests grün**
(2026-09-14, nach Nutzerfreigabe):
**AP13-B Pendlerförderung umgesetzt — 127 Tests grün**
(2026-09-14, uncommittet bis Nutzerfreigabe):
- Neu `models/pendler.py`: Modell `l10n.at.payroll.pendler.erklaerung`
(effektiv datiert, L-34-/Pendlerrechner-Nachweis als Attachment,
pauschale_art klein/gross, fahrten_stufe drittel/zwei_drittel/voll,
arbeitgeber_kfz, ticket_art keines/werkverkehr/oeffi,
ticket_kosten_monatlich, bezugsumwandlung). Company-Related OHNE
store=True (Muster pfaendung — store+required bricht den Insert mit
NotNullViolation), ACL + Company-Record-Rule + Views/Menu.
- Parameter `at_pendler` 2026: klein [[20,696],[41,1356],[61,2016]],
gross [[2,372],[21,1476],[41,2568],[61,3672]], pendlereuro_km_jahr 6.0.
Staffel „nächst-höhere Stufe erst bei MEHR als dem unteren km-Wert“ →
ganzzahlige Grenzen 20/41/61 bzw. 2/21/41/61.
- Engine-Anbindung (Privat-Overrides):
`_l10n_at_bemessung_laufen` PP (LZP = Jahreswert × Stufe/360 ×
Lohnsteuertage, lb-pen-09); `_l10n_at_absetzbetraege` + PE als
**Jahreswert × Stufe** (km × 6 € × Stufe) — kumulative Engine verbraucht
Absetzbeträge über die Rollung, NICHT pe × 12. PP/PE ohne SV-/LNK-
Wirkung (getestet).
- §-26-Z5-Regeln: oeffi-Ticketkosten mindern PP (§ 16 Abs 1 Z 6 lit i);
PP → 0 ⇒ PE = 0 (Fixkopplung lb-pen-04). Werkverkehr datengetrieben
über die L-34-Reststrecke. Bezugsumwandlung sperrt PP/PE NICHT (Ticket =
steuerpflichtiger laufender Bezug). Kfz sperrt beide.
- Tests test_pendler_private.py (10): voller Monat 58/150 €, Staffeln,
1/3-Stufe, untermonatig 3,87 € (lb-pen-09-Beispiel), Kfz-Sperre,
Ticket-Reduktion/PE-Entfall, Bezugsumwandlung, effektiv datierte
Auswahl, E2E-LSt-Wirkung. Zwei Testfixes beim ersten echten Lauf:
`unit_amount` existiert nicht auf hr.expense (→ quantity),
PE-Erwartungswert 40 € (20 km × 6 × 1/3) statt 50 €.
- **Fund:** die AP13-A-Tests waren im AP13-A-Commit NICHT registriert
(tests/__init__.py fehlte) — die „114 Tests grün“ liefen ohne die
Reisekosten-Tests. Jetzt registriert; Suite 127/127 grün. Beim ersten
echten Reisekosten-Testlauf: `hr.expense` hat kein `unit_amount`-Feld
(Odoo 19: quantity/price_unit/total_amount) — Test-Helfer korrigiert.
- Doku docs/rechenfaelle/pendler.md (RF-PEN-1…5 + offene Punkte: erhöhter
VAB 853 €/Zuschlag 804 € als Quer-LSt-Rest, L-34-1-Monats-Meldeprozess).
- Version **19.0.11.0.0**. Commit ausstehend (Nutzerfreigabe).
**AP13-A Reisekosten Inland committed — 127-er Suite grün**
(2026-09-14, Commit **`3f307a0`**):
- Privatmodul **19.0.10.0.0** verwendet das Odoo-19-Standardmodul
`hr_payroll_expense`: genehmigte, eigenfinanzierte Spesen werden über
@@ -1,7 +1,7 @@
# Part of the odoo-at-payroll project. License: LGPL-3.
{
'name': 'Österreich - Privatwirtschaft Personalverrechnung (General-AT)',
'version': '19.0.10.0.0',
'version': '19.0.11.0.0',
'author': 'Florian Egger',
'website': 'https://gem360.at',
'category': 'Human Resources/Payroll',
@@ -125,6 +125,7 @@ gem360-Brücke).
'data/rule_parameters_sb_private.xml',
'data/rule_parameters_sb2_private.xml',
'data/rule_parameters_reisekosten_private.xml',
'data/rule_parameters_pendler_private.xml',
'data/bv_kasse_seed.xml',
'views/kv_views.xml',
'views/bv_views.xml',
@@ -135,6 +136,7 @@ gem360-Brücke).
'views/pfaendung_views.xml',
'views/sachbezuege_views.xml',
'views/reisekosten_views.xml',
'views/pendler_views.xml',
'report/gp8_reports.xml',
'report/buchungsbeleg_report.xml',
'report/bv_report.xml',
@@ -0,0 +1,18 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- AP13-B: verifizierte Pendlerwerte 2026 (lb-pen-01/02/04/09, -->
<!-- Budgetbegleitgesetz 2025). Staffel: nächsthöhere Stufe erst bei -->
<!-- MEHR als dem unteren km-Wert (klein: 20/41/61, gross 2/21/41/61 -->
<!-- — km ganzzahlig aus dem Pendlerrechner/L 34). -->
<record id="hr_rule_parameter_at_pendler" model="hr.rule.parameter">
<field name="name">Pendlerpauschale / Pendlereuro</field>
<field name="code">at_pendler</field>
<field name="country_id" ref="base.at"/>
</record>
<record id="hr_rule_parameter_value_at_pendler_2026"
model="hr.rule.parameter.value">
<field name="rule_parameter_id" ref="hr_rule_parameter_at_pendler"/>
<field name="date_from">2026-01-01</field>
<field name="parameter_value">{'klein': [[20, 696.0], [41, 1356.0], [61, 2016.0]], 'gross': [[2, 372.0], [21, 1476.0], [41, 2568.0], [61, 3672.0]], 'pendlereuro_km_jahr': 6.0}</field>
</record>
</odoo>
@@ -0,0 +1,69 @@
# Pendlerförderung — Rechenfälle AP13-B
Stand: 2026-09. AP13-B umsetzt Pendlerpauschale (PP) und Pendlereuro (PE)
sowie die L-34-Verhältnisdaten inkl. §-26-Z5-Tickets auf der privaten
Structure. Grundlagen: lb-pen-01/02/04/06/08/09 und § 16 Abs 1 Z 6 EStG.
## Rechtsanker
- PP = Freibetrag der laufenden LSt-Bemessungsgrundlage; Staffeln 2026
klein 696/1.356/2.016 € (ab 20/>40/>60 km), groß 372/1.476/2.568/3.672 €
(ab 2/>20/>40/>60 km); nächsthöhere Stufe erst bei **mehr** als dem
unteren km-Wert.
- Fahrten-Stufe: 47 Fahrten → 1/3, 810 → 2/3, ab 11 → voll.
- Untermonatige Beschäftigung: Jahreswert × Stufe / 360 × Lohnsteuertage
des LZP (lb-pen-09).
- PE 2026: 6 €/km/Jahr (Budgetbegleitgesetz 2025), aliquotiert mit der
Fahrten-Stufe, direkter LSt-Absetzbetrag, Fixkopplung zum PP-Anspruch.
- Arbeitgebereigenes Kfz für die Pendelstrecke sperrt PP und PE; ein
arbeitgebereigenes Dienstfahrrad sperrt nicht.
- § 26 Z 5 lit a Werkverkehr: PP nur für die nicht abgedeckte Reststrecke
— datengetrieben über die L-34-Reststrecke.
- § 26 Z 5 lit b Öffi-Ticket: AG-Ticketkosten mindern das PP anteilig
(§ 16 Abs 1 Z 6 lit i); Bezugsumwandlung sperrt PP/PE nicht (Ticket =
steuerpflichtiger laufender Bezug).
- PP/PE erfordern die L 34 EDV mit Pendlerrechner-Ausdruck; Änderungen der
Verhältnisse sind binnen eines Monats zu melden — im Modell als neue
Gültigkeitsperiode (`l10n.at.payroll.pendler.erklaerung`).
## RF-PEN-1 — kleines PP voll, 25 km
PP = 696/12 = 58,00 €/Monat Freibetrag; PE = 25 × 6 = 150 €/Jahr.
Kein SV- und kein LNK-Effekt.
## RF-PEN-2 — Fahrten-Stufe 1/3
20 km, 4 Fahrten: PP = 696 × 1/3 / 12 = 19,33 €; PE = (20 × 6) × 1/3 = 50
€/Jahr.
## RF-PEN-3 — untermonatig (lb-pen-09)
Beginn 25. 4., 5 Pendeltage, 25 km, klein 1/3: PP = 696 × 1/3 / 360 × 6
= **3,87 €**.
## RF-PEN-4 — Öffi-Ticket
AG-Ticketkosten 30 €/Monat bei kleinem PP 58 €: PP = 28 €; PE bleibt
(Fixkopplung zum Restanspruch). Kosten ≥ PP → PP = 0 → PE = 0.
## RF-PEN-5 — Werkverkehr
PP auf der Reststrecke aus der L-34 (Datenpflege); PE folgt der Reststrecke.
Überwiegende Werkverkehr-Beförderung mit voll abgedeckter Strecke →
keine Erklärung mit Reststrecke → kein PP/PE.
## Engine-Bindung
- PP: Abzug in `_l10n_at_bemessung_laufen` (Privat-Override) — wirkt auf
die §-66-Hochrechnung, nicht auf SV/LNK.
- PE: Ergänzung in `_l10n_at_absetzbetraege` — Jahreswert × Stufe, wie der
Verkehrsabsetzbetrag über die kumulative Rollung verbraucht.
- Lohnkonto-Ausweise (zustehender PE, Kfz-Monate, Werkverkehrsmonate) sind
Report-Thema (lb-pen-06) und nicht Bestandteil dieses MVP.
## Offene Punkte (dokumentiert, nicht geblockt)
- Erhöhter Verkehrsabsetzbetrag (853 €) und Pendler-Zuschlag (804 €) sind
einkommensabhängige Jahreskomponenten — Quer-LSt-Rest (siehe Plan).
- L 34-Änderungsmeldung binnen Monatsfrist ist Prozessregel; das Modell
stützt die monatsgenaue Historie, prüft die Frist nicht.
@@ -19,3 +19,4 @@ from . import pfaendung
from . import pfaendung_report
from . import sachbezuege_private
from . import reisekosten
from . import pendler
@@ -0,0 +1,117 @@
# Part of the odoo-at-payroll project. License: LGPL-3.
"""AP13-B: L-34-backed commuter allowance and commuter tax credit."""
from odoo import api, fields, models, _
from odoo.exceptions import UserError
class L10nAtPayrollPendlerErklaerung(models.Model):
_name = 'l10n.at.payroll.pendler.erklaerung'
_description = 'AT Pendlererklärung (L 34 EDV)'
_order = 'date_start desc, id desc'
employee_id = fields.Many2one('hr.employee', required=True, index=True)
company_id = fields.Many2one(
related='employee_id.company_id', readonly=True, required=True)
date_start = fields.Date(string='Gültig ab', required=True, index=True)
date_end = fields.Date(string='Gültig bis')
strecke_km = fields.Integer(string='Einfache Wegstrecke (km)', required=True)
pauschale_art = fields.Selection(
[('klein', 'Kleines Pendlerpauschale'),
('gross', 'Großes Pendlerpauschale')], required=True)
fahrten_stufe = fields.Selection(
[('drittel', '47 Fahrten (1/3)'),
('zwei_drittel', '810 Fahrten (2/3)'),
('voll', 'Ab 11 Fahrten (voll)')], required=True, default='voll')
pendlerrechner_datum = fields.Date(string='Pendlerrechner-Abfragedatum', required=True)
l34_attachment_ids = fields.Many2many('ir.attachment', string='L 34 EDV / Pendlerrechner')
arbeitgeber_kfz = fields.Boolean(string='Arbeitgebereigenes Kfz für Pendelstrecke')
ticket_art = fields.Selection(
[('keines', 'Kein Werkverkehr/Öffi-Ticket'),
('werkverkehr', '§ 26 Z 5 lit a Werkverkehr'),
('oeffi', '§ 26 Z 5 lit b Öffi-Ticket')], default='keines', required=True)
ticket_kosten_monatlich = fields.Monetary(string='AG-Ticketkosten pro Monat', currency_field='currency_id')
ticket_bezugsumwandlung = fields.Boolean(string='Ticket durch Bezugsumwandlung')
currency_id = fields.Many2one(related='company_id.currency_id', readonly=True)
note = fields.Text(string='Bemerkung')
@api.constrains('date_start', 'date_end', 'strecke_km', 'ticket_art', 'ticket_kosten_monatlich', 'ticket_bezugsumwandlung')
def _check_values(self):
for rec in self:
if rec.date_end and rec.date_end < rec.date_start:
raise UserError(_('The commuter declaration end date cannot precede its start date.'))
if rec.strecke_km < 0:
raise UserError(_('The commuter distance cannot be negative.'))
if rec.ticket_kosten_monatlich and rec.ticket_art == 'keines':
raise UserError(_('Ticket costs require a ticket classification.'))
if rec.ticket_bezugsumwandlung and rec.ticket_art == 'keines':
raise UserError(_('Salary conversion requires a ticket classification.'))
def _l10n_at_pendler_werte(self, payslip):
"""(PP-LZP-Freibetrag, PE-Jahreswert mit Stufenfaktor).
PP ist ein §-66-Abs-3-aliquotierter Freibetrag: Jahreswert ×
Fahrten-Stufe / 360 × Lohnsteuertage des LZP (lb-pen-09); die
kumulative Engine annualisiert ihn über 360/tage.
Der Pendlereuro ist ein Absetzbetrag auf der Steuer (lb-pen-04)
und muss daher — wie der Verkehrsabsetzbetrag — auf der
annualisierten Basis als Jahreswert einfließen: km × €/J ×
Fahrten-Stufe.
§ 26 Z 5 lit b: die AG-Ticketkosten mindern das PP anteilig
(§ 16 Abs 1 Z 6 lit i); § 26 Z 5 lit a (Werkverkehr) ist
datengetrieben über die L-34-Reststrecke abgebildet. Eine
Bezugsumwandlung sperrt PP/PE nicht (Ticket = steuerpflichtiger
laufender Bezug, der DN trägt die Kosten). PE ist mit dem
PP-Anspruch Fixkopplung (lb-pen-04): ohne PP-Anspruch kein PE.
"""
self.ensure_one()
if self.arbeitgeber_kfz:
return 0.0, 0.0
params = payslip._l10n_at_param('at_pendler')
km = self.strecke_km
grenzen = params['gross' if self.pauschale_art == 'gross' else 'klein']
annual = 0.0
for minimum, value in grenzen:
if km >= minimum:
annual = float(value)
factor = {'drittel': 1.0 / 3.0, 'zwei_drittel': 2.0 / 3.0,
'voll': 1.0}[self.fahrten_stufe]
pp = annual / 12.0 * factor \
* payslip._l10n_at_lohnsteuertage() / 30.0
if self.ticket_art == 'oeffi':
pp = max(pp - self.ticket_kosten_monatlich, 0.0)
pe = km * float(params['pendlereuro_km_jahr']) * factor
if pp <= 0.0:
pe = 0.0
return pp, pe
class HrPayslip(models.Model):
_inherit = 'hr.payslip'
def _l10n_at_pendler_erklaerung(self):
self.ensure_one()
return self.env['l10n.at.payroll.pendler.erklaerung'].search([
('employee_id', '=', self.employee_id.id),
('date_start', '<=', self.date_to),
'|', ('date_end', '=', False), ('date_end', '>=', self.date_from),
], order='date_start desc, id desc', limit=1)
def _l10n_at_pendler_werte(self):
self.ensure_one()
erklaerung = self._l10n_at_pendler_erklaerung()
return erklaerung._l10n_at_pendler_werte(self) if erklaerung else (0.0, 0.0)
def _l10n_at_bemessung_laufen(self, result_rules=None):
basis = super()._l10n_at_bemessung_laufen(result_rules)
pp, _pe = self._l10n_at_pendler_werte()
return basis - pp
def _l10n_at_absetzbetraege(self, jbg):
betrag = super()._l10n_at_absetzbetraege(jbg)
_pp, pe = self._l10n_at_pendler_werte()
# Pendlereuro = direkter LSt-Absetzbetrag (§ 33 Abs 5 Z 4):
# Jahreswert mit Fahrten-Stufe; der Jahresverbrauch läuft über
# die kumulative Engine (wie der Verkehrsabsetzbetrag).
return betrag + pe
@@ -21,3 +21,5 @@ access_l10n_at_payroll_aeb_user,L10n AT Payroll AEB User,model_l10n_at_payroll_a
access_l10n_at_payroll_aeb_manager,L10n AT Payroll AEB Manager,model_l10n_at_payroll_aeb,hr_payroll.group_hr_payroll_manager,1,1,1,1
access_l10n_at_payroll_pfaendung_user,L10n AT Payroll Pfaendung User,model_l10n_at_payroll_pfaendung,hr_payroll.group_hr_payroll_user,1,1,1,0
access_l10n_at_payroll_pfaendung_manager,L10n AT Payroll Pfaendung Manager,model_l10n_at_payroll_pfaendung,hr_payroll.group_hr_payroll_manager,1,1,1,1
access_l10n_at_payroll_pendler_erklaerung_user,L10n AT Payroll Pendlererklaerung User,model_l10n_at_payroll_pendler_erklaerung,hr_payroll.group_hr_payroll_user,1,1,1,0
access_l10n_at_payroll_pendler_erklaerung_manager,L10n AT Payroll Pendlererklaerung Manager,model_l10n_at_payroll_pendler_erklaerung,hr_payroll.group_hr_payroll_manager,1,1,1,1
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
21 access_l10n_at_payroll_aeb_manager L10n AT Payroll AEB Manager model_l10n_at_payroll_aeb hr_payroll.group_hr_payroll_manager 1 1 1 1
22 access_l10n_at_payroll_pfaendung_user L10n AT Payroll Pfaendung User model_l10n_at_payroll_pfaendung hr_payroll.group_hr_payroll_user 1 1 1 0
23 access_l10n_at_payroll_pfaendung_manager L10n AT Payroll Pfaendung Manager model_l10n_at_payroll_pfaendung hr_payroll.group_hr_payroll_manager 1 1 1 1
24 access_l10n_at_payroll_pendler_erklaerung_user L10n AT Payroll Pendlererklaerung User model_l10n_at_payroll_pendler_erklaerung hr_payroll.group_hr_payroll_user 1 1 1 0
25 access_l10n_at_payroll_pendler_erklaerung_manager L10n AT Payroll Pendlererklaerung Manager model_l10n_at_payroll_pendler_erklaerung hr_payroll.group_hr_payroll_manager 1 1 1 1
@@ -37,4 +37,11 @@
<field name="groups"
eval="[(4, ref('hr_payroll.group_hr_payroll_user'))]"/>
</record>
<record id="rule_pendler_erklaerung_company" model="ir.rule">
<field name="name">Pendlererklärung: eigene Company</field>
<field name="model_id" ref="model_l10n_at_payroll_pendler_erklaerung"/>
<field name="domain_force">[('company_id', 'in', company_ids)]</field>
<field name="groups"
eval="[(4, ref('hr_payroll.group_hr_payroll_user'))]"/>
</record>
</odoo>
@@ -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,
})
@@ -0,0 +1,76 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- AP13-B: Pendlererklärung (L 34 EDV / Pendlerrechner) — -->
<!-- effektiv datierte, company-gebundene Stammdaten (lb-pen-06/08). -->
<record id="pendler_erklaerung_list_view" model="ir.ui.view">
<field name="name">l10n.at.payroll.pendler.erklaerung.list</field>
<field name="model">l10n.at.payroll.pendler.erklaerung</field>
<field name="arch" type="xml">
<list string="Pendlererklärungen">
<field name="employee_id"/>
<field name="date_start"/>
<field name="date_end" optional="show"/>
<field name="strecke_km"/>
<field name="pauschale_art"/>
<field name="fahrten_stufe" optional="show"/>
<field name="ticket_art" optional="show"/>
<field name="arbeitgeber_kfz" optional="show"/>
<field name="company_id" groups="base.group_multi_company"/>
</list>
</field>
</record>
<record id="pendler_erklaerung_form_view" model="ir.ui.view">
<field name="name">l10n.at.payroll.pendler.erklaerung.form</field>
<field name="model">l10n.at.payroll.pendler.erklaerung</field>
<field name="arch" type="xml">
<form string="Pendlererklärung (L 34 EDV)">
<sheet>
<group>
<group string="Erklärung (L 34 EDV)">
<field name="employee_id"/>
<field name="date_start"/>
<field name="date_end"/>
<field name="pendlerrechner_datum"/>
<field name="l34_attachment_ids" widget="many2many_binary"/>
</group>
<group string="Werte (aus Pendlerrechner, nicht nachrechnen)">
<field name="strecke_km"/>
<field name="pauschale_art"/>
<field name="fahrten_stufe"/>
<field name="arbeitgeber_kfz"/>
<field name="ticket_art"/>
<field name="ticket_kosten_monatlich"
invisible="ticket_art == 'keines'"/>
<field name="ticket_bezugsumwandlung"
invisible="ticket_art == 'keines'"/>
<field name="note"/>
</group>
</group>
</sheet>
</form>
</field>
</record>
<record id="action_pendler_erklaerung" model="ir.actions.act_window">
<field name="name">Pendlererklärungen (L 34 EDV)</field>
<field name="res_model">l10n.at.payroll.pendler.erklaerung</field>
<field name="view_mode">list,form</field>
<field name="help" type="html">
<p class="o_view_nocontent_smiling_face">
Erste Pendlererklärung erfassen
</p>
<p>
L 34 EDV mit Pendlerrechner-Ausdruck: Wegstrecke, Pauschalenart,
Fahrtenkategorie und Werkverkehrs-/Öffi-Ticket-Verhältnisse.
Änderungen der Verhältnisse werden als neue Gültigkeitsperiode
erfasst (binnen eines Monats meldepflichtig, lb-pen-06).
</p>
</field>
</record>
<menuitem id="menu_l10n_at_payroll_pendler"
name="Pendlerförderung (L 34)"
parent="hr_work_entry_enterprise.menu_hr_payroll_root"
action="action_pendler_erklaerung" sequence="41"/>
</odoo>