[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
@@ -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