Files
odoo-at-payroll/addons/l10n_at_hr_payroll_private/models/endabrechnung.py
T
fegger 9add58fe83 [IMP] l10n_at_hr_payroll_private: add statutory old-system severance (AP15-A)
Pay the pre-2003 AngG severance from the end-of-employment run as its own neutral benefit line: free of SV (§ 49 Abs 3 Z 7 ASVG) and payroll levies, outside the annual sixth, and collected into the § 291d EO termination pot net of its own wage tax. Taxation follows the binding better-of rule of § 67 Abs 3 EStG: multiple-of-method (tariff wage tax on the running monthly wage × the statutory multiple of the payout) versus the flat 6 % rate, applied mandatorily in favour of the employee. The tariff wage tax is captured as a separately confirmed value instead of deriving it from gross pay through the § 62 engine. The wizard enforces § 23a Abs 3 AngG (parental exit needs five uninterrupted years), blocks BMSVG-new contracts except frozen claims and validates AGRD 02/06 against the claim type. The LNK basis subtracts § 67 Abs 3/6 rules only when they actually run in the tax base, the L16 generator reports LST_ABF in key 260 outside 210/220, and result_rules access in the garnishment paths avoids DefaultDictPayroll materialisation. Verified against the original statute texts in .ris/ (AngG §§ 23/23a, EStG § 67 Abs 3).

Version 19.0.13.0.0 (private), 19.0.9.0.0 (core)
2026-09-15 11:01:55 +02:00

413 lines
20 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Part of the odoo-at-payroll project. License: LGPL-3.
"""AP14-A: Endabrechnungs-Zyklus — Urlaubsersatzleistung (UEL),
Abgangsentschädigung (§ 67 Abs 8 lit b), Aufrechnung offener Vorschüsse
und Zeitguthaben/-schulden (§ 19e AZG).
Am Originalwortlaut verifiziert (.ris/, Stand 2026-09-14):
- § 11 Abs 2 ASVG: die Pflichtversicherung besteht für die Zeit des
Bezuges einer Ersatzleistung für Urlaubsentgelt (und einer
Kündigungsentschädigung) weiter; gebühren beide, ist zur Bestimmung
des Zeitraumes ZUERST die Kündigungsentschädigung und im Anschluss
die Ersatzleistung heranzuziehen (sequentielle Verlängerungsfenster).
Die 6-WT/5-AT-Tage-Umrechnung ist Verwaltungspraxis (lb-url-15),
nicht Gesetzeswortlaut.
- § 67 Abs 8 lit d EStG: UEL „soweit sie laufenden Arbeitslohn
betreffen, als laufender Arbeitslohn, soweit sie sonstige Bezüge
betreffen, als sonstiger Bezug im Kalendermonat der Zahlung" — die
Zweikomponenten-Abbildung läuft nativ über l10n_at_bemessung.
- § 67 Abs 8 lit b EStG (Kündigungs-/Abgangsentschädigung): nach
Abzug der §-62-Beiträge ist ein Fünftel steuerfrei zu belassen,
höchstens ein Fünftel des Neunfachen der monatlichen HBG; der Rest
läuft tarifisch im Zuflussmonat (Abs 10), Abs 2 (Sechstel) ist
NICHT anzuwenden. LNK-pflichtig (FLAG § 41 Abs 4 nennt nur
§ 67 Abs 3 und 6) und SV-pflichtig (keine §-49-Abs-3-Z-7-Befreiung)
mit Umlegung auf den Kündigungsfrist-Zeitraum.
- § 19e Abs 2 AZG: Guthaben an Normalarbeitszeit + 50 % Zuschlag
(Entfall bei unberechtigtem vorzeitigem Austritt; KV kann
abweichend regeln).
"""
from datetime import timedelta
from odoo import fields, models
class HrSalaryRule(models.Model):
_inherit = 'hr.salary.rule'
l10n_at_sv_verlaengerung = fields.Selection(
[('keine', 'Keine SV-Verlängerung (§ 11 Abs 2 ASVG)'),
('urlaubsersatz', 'UEL-Tage-Schema (Urlaubsersatzleistung)'),
('abgang', 'Kündigungsfrist (Abgangsentschädigung)')],
string='AT SV-Verlängerung', default='keine',
help='Verteilt die laufende SV-Basis dieser Regel auf die '
'Pflichtversicherungs-Verlängerungsmonate nach § 11 Abs 2 '
'ASVG statt auf den Abrechnungsmonat: Abgangsentschädigung '
'zuerst (Kündigungsfrist), UEL anschließend (Tage-Schema '
'lb-url-15).')
l10n_at_s67_fuenftel = fields.Boolean(
string='AT § 67 Abs 8 lit a/b (Fünftelregelung)',
help='LSt-Pfad der Vergleichs-/Kündigungsentschädigungen: nach '
'Abzug der §-62-Beiträge ist ein Fünftel steuerfrei '
'(Deckel: ein Fünftel des Neunfachen der monatlichen HBG), '
'der Rest läuft tarifisch im Zuflussmonat — ohne '
'Jahressechstel-Erhöhung (Abs 2 nicht anzuwenden).')
class HrPayslip(models.Model):
_inherit = 'hr.payslip'
l10n_at_abf_alt_anspruchsart = fields.Selection(
[('normal', 'Normalanspruch (§ 23 AngG)'),
('eltern', 'Elternaustritt (§ 23a Abs 3/4 AngG)'),
('tod', 'Tod (§ 23 Abs 6 AngG)'),
('pension', 'Pension/Invalidität (§ 23a Abs 1 AngG)'),
('eingefroren', 'Eingefrorener Altanspruch (§ 47 BMSVG)'),
('manuell', 'Sonstiger nachgewiesener Anspruch')],
string='AT Abfertigung Alt — Anspruchsart', copy=False)
l10n_at_abf_alt_dienstzeit_monate = fields.Integer(
string='AT Abfertigung Alt — Dienstzeitmonate', copy=False)
l10n_at_abf_alt_monatsentgelt = fields.Float(
string='AT Abfertigung Alt — Monatsentgelt', copy=False,
help='Bestätigte arbeitsrechtliche Bemessungsgrundlage inklusive '
'regelmäßiger Bezugsbestandteile und aliquoter Sonderzahlungen.')
l10n_at_abf_alt_laufender_arbeitslohn = fields.Float(
string='AT Abfertigung Alt — laufender Arbeitslohn', copy=False,
help='Laufender Monatsarbeitslohn für den steuerlichen '
'Vervielfacher nach § 67 Abs 3 EStG.')
l10n_at_abf_alt_monatslohnsteuer = fields.Float(
string='AT Abfertigung Alt — tarifmäßige Monatslohnsteuer',
copy=False,
help='Auf den maßgeblichen laufenden Monatsarbeitslohn entfallende '
'tarifmäßige Lohnsteuer nach den persönlichen Verhältnissen.')
l10n_at_abf_alt_mehrfach = fields.Float(
string='AT Abfertigung Alt — Anspruchsfaktor', copy=False)
l10n_at_abf_alt_nachweis = fields.Text(
string='AT Abfertigung Alt — Nachweis/Begründung', copy=False)
# ------------------------------------------------------------------
# SV-Tage-Umrechnung und Verlängerungsmonate (§ 11 Abs 2 ASVG)
# ------------------------------------------------------------------
def _at_end_arbeitsrechtliches_ende(self):
"""Arbeitsrechtliches Ende = letzter Arbeitstag; das SV-Ende
(„Ende des Entgeltanspruches") liegt darüber hinaus, wenn ein
Entgeltanspruch über das Ende hinaus läuft."""
self.ensure_one()
return self.version_id.contract_date_end or self.date_to
def _at_end_arbeitstage_woche(self):
"""Arbeitstage pro Woche des Vertragskalenders (Schema-Grundlage
der SV-Tage-Umrechnung: 5-AT-Woche, 6-WT-Woche, Teilzeitwoche)."""
self.ensure_one()
calendar = self.version_id.resource_calendar_id
days = {int(att.dayofweek) for att in calendar.attendance_ids} \
if calendar else set()
return len(days) or 5
def _at_end_sv_tage(self, tage):
"""SV-Tage aus abzurechnenden Urlaubstagen (Verwaltungspraxis
lb-url-15): je volle Woche à N Arbeitstage werden 7N
Kalendertage hinzugerechnet (5-AT-Woche +2, 6-WT-Woche +1,
2-Tage-Teilzeitwoche +5) — nur ganze Wochenblöcke."""
self.ensure_one()
at_woche = self._at_end_arbeitstage_woche()
blocks = int(tage // at_woche)
return tage + blocks * (7 - at_woche)
def _at_end_verlaengerungs_monate(self, sv_tage, start):
"""[(Monatsanfang, Tage im Monat)] eines Verlängerungsfensters
ab dem übergebenen Starttag (§ 11 Abs 2 ASVG)."""
self.ensure_one()
ergebnis = {}
rest = int(sv_tage)
tag = start
while rest > 0:
monatsende = (tag.replace(day=28) + timedelta(days=4)) \
.replace(day=1) - timedelta(days=1)
tage_dabei = min((monatsende - tag).days + 1, rest)
monat = tag.replace(day=1)
ergebnis[monat] = ergebnis.get(monat, 0) + tage_dabei
tag = monatsende + timedelta(days=1)
rest -= tage_dabei
return sorted(ergebnis.items())
def _at_end_rule_verlaengerung(self, rule, result_rules=None):
"""[(Monatsanfang, Tage, Anteil)] der Regel-Basis nach § 11
Abs 2: die Abgangsentschädigung belegt ZUERST die (fiktive)
Kündigungsfrist, die UEL SCHLIESSLICH die Urlaubstage — beide
Fenster laufen ab dem Tag nach dem arbeitsrechtlichen Ende
hintereinander (Gesetzeswortlaut: „zunächst die
Kündigungsentschädigung und im Anschluss die Ersatzleistung")."""
self.ensure_one()
start = self._at_end_arbeitsrechtliches_ende() + timedelta(days=1)
if rule.l10n_at_sv_verlaengerung == 'urlaubsersatz':
ffrist = self._at_input_amount('ATP_ABGANG_FFRIST_TAGE')
if ffrist:
start += timedelta(days=int(ffrist))
tage = self._at_end_sv_tage(self._at_uel_tage())
else:
ffrist = self._at_input_amount('ATP_ABGANG_FFRIST_TAGE')
tage = int(ffrist) if ffrist else 0
monate = self._at_end_verlaengerungs_monate(tage, start)
if not monate:
return []
gesamt = sum(tage for _monat, tage in monate)
return [(monat, tage, tage / float(gesamt))
for monat, tage in monate]
# ------------------------------------------------------------------
# Eingabe-Helfer
# ------------------------------------------------------------------
def _at_input_amount(self, code):
self.ensure_one()
linie = self.input_line_ids.filtered(
lambda i: i.input_type_id.code == code)
return linie[:1].amount or 0.0
# ------------------------------------------------------------------
# Abfertigung Alt — §§ 23/23a AngG, § 67 Abs 3 EStG
# ------------------------------------------------------------------
def _at_abf_alt_staffel(self, dienstzeit_monate=None):
"""Arbeitsrechtlicher Grundfaktor nach § 23 Abs 1 AngG."""
self.ensure_one()
monate = dienstzeit_monate
if monate is None:
monate = self.l10n_at_abf_alt_dienstzeit_monate
staffel = self._l10n_at_param('at_endabrechnung').get(
'abfertigung_alt_staffel', [])
mehrfach = 0.0
for grenze, faktor in staffel:
if monate >= int(grenze):
mehrfach = float(faktor)
return mehrfach
def _at_abf_alt_mehrfach_vorschlag(self, dienstzeit_monate=None,
anspruchsart=None):
"""§-23-Faktor mit Halb-/Deckelregeln für Elternaustritt/Tod."""
self.ensure_one()
art = anspruchsart or self.l10n_at_abf_alt_anspruchsart
monate = dienstzeit_monate
if monate is None:
monate = self.l10n_at_abf_alt_dienstzeit_monate
mehrfach = self._at_abf_alt_staffel(monate)
if art == 'eltern':
if monate < 60:
return 0.0
return min(mehrfach / 2.0, 3.0) if mehrfach else 0.0
if art == 'tod':
return mehrfach / 2.0
return mehrfach
def _at_abf_alt_betrag(self, result_rules=None):
"""Gesetzliche Alt-Abfertigung als eigene Bezugszeile."""
self.ensure_one()
return self._at_input_amount('ATP_ABF_ALT')
def _at_abf_alt_steuer_6(self, result_rules=None):
self.ensure_one()
satz = float(self._l10n_at_param(
'at_endabrechnung')['abfertigung_steuersatz'])
return max(self._at_abf_alt_betrag(result_rules), 0.0) * satz / 100.0
def _at_abf_alt_steuer_vervielfachung(self, result_rules=None):
"""§ 67 Abs 3: Monats-Tarif-LSt × steuerlicher Vervielfacher.
Der steuerliche Vervielfacher ist Abfertigungsbetrag dividiert
durch den laufenden Arbeitslohn. Er kann vom arbeitsrechtlichen
Staffel-Faktor abweichen, weil die Abfertigungsbasis etwa aliquote
Sonderzahlungen enthält (lb-end-01, RF 21 Dienstjahre: 10,5 statt 9).
"""
self.ensure_one()
betrag = self._at_abf_alt_betrag(result_rules)
laufend = self.l10n_at_abf_alt_laufender_arbeitslohn
if betrag <= 0 or laufend <= 0:
return self._at_abf_alt_steuer_6(result_rules)
vervielfacher = betrag / laufend
return self.l10n_at_abf_alt_monatslohnsteuer * vervielfacher
def _at_abf_alt_lohnsteuer(self, result_rules=None):
"""Zwingend günstigere Steuer aus Vervielfachung und 6 %."""
self.ensure_one()
if self._at_abf_alt_betrag(result_rules) <= 0:
return 0.0
return min(self._at_abf_alt_steuer_vervielfachung(result_rules),
self._at_abf_alt_steuer_6(result_rules))
# ------------------------------------------------------------------
# Urlaubsersatzleistung — § 67 Abs 8 lit d, lb-url-15/16
# ------------------------------------------------------------------
def _at_uel_tage(self):
"""Offene Urlaubstage (Arbeitstage; keine Rundung, lb-url-16)."""
self.ensure_one()
return self._at_input_amount('ATP_UEL_TAGE')
def _at_uel_divisor(self):
"""Tagewert-Divisor: /26 bei Werktags-, /22 bei 5-AT-Kalender
(lb-url-16)."""
self.ensure_one()
params = self._l10n_at_param('at_endabrechnung')
if self._at_end_arbeitstage_woche() >= 6:
return float(params['divisor_werktage'])
return float(params['divisor_arbeitstage'])
def _at_uel_lfd(self):
"""UEL-laufender Anteil: Tage × Monatsentgelt / Divisor —
läuft als laufender Bezug durch den Tarif des Austrittsmonats
und erhöht das Jahressechstel (nativ über bemessung='laufend')."""
self.ensure_one()
return self._at_uel_tage() * self._at_kv_gehalt_basis() \
/ self._at_uel_divisor()
def _at_uel_sz_tageswert(self):
"""SZ-Anteil je Urlaubstag: 2 Sonderzahlungen à Monatsentgelt
pro Jahr (KV § 11) → je Monat 2 × SZ-Basis/12, als Tageswert
(auch bei bereits überaliquot bezahlten SZ, lb-url-16)."""
self.ensure_one()
sz_monat = 2.0 * self._at_sz_basis(self.date_to) / 12.0
return sz_monat / self._at_uel_divisor()
def _at_uel_sz(self):
"""UEL-Sonderzahlungsanteil: Tage × SZ-Tageswert."""
self.ensure_one()
return self._at_uel_tage() * self._at_uel_sz_tageswert()
# ------------------------------------------------------------------
# Abgangsentschädigung — § 67 Abs 8 lit b (Fünftelregelung)
# ------------------------------------------------------------------
def _at_fuenftel_regeln(self):
self.ensure_one()
return self.struct_id.rule_ids.filtered(
lambda rule: rule.l10n_at_s67_fuenftel)
def _at_fuenftel_total(self, result_rules=None):
self.ensure_one()
total = 0.0
for rule in self._at_fuenftel_regeln():
if result_rules is not None:
if rule.code in result_rules:
total += result_rules[rule.code]['total']
else:
linie = self.line_ids.filtered(lambda l: l.code == rule.code)
total += sum(linie.mapped('total'))
return total
def _at_fuenftel_tarif_basis(self, result_rules=None):
"""Tarifischer Teil (4/5) der Fünftel-Bezüge: Bemessung nach
Abzug der darauf entfallenden §-62-Beiträge (approximiert über
den effektiven DN-Satz der laufenden Basis; die HBG-Caps der
Verlängerungsmonate werden im SV-Pfad separat geführt); ein
Fünftel ist steuerfrei, gedeckelt mit einem Fünftel des
Neunfachen der monatlichen HBG (lb-end-21: 2026 6.930 × 9/5
= 12.474)."""
self.ensure_one()
total = self._at_fuenftel_total(result_rules)
if total <= 0:
return 0.0
satz = sum(self._l10n_at_sv_satz(
'dn', komponente,
staffel_basis=self._l10n_at_sv_basis_roh(result_rules))
for komponente in ('kv', 'pv', 'av', 'ak', 'wf'))
bemessung = max(total * (1.0 - satz / 100.0), 0.0)
deckel = float(self._l10n_at_param('at_sv_hbg_monatlich')) \
* 9.0 / 5.0
frei = min(bemessung / 5.0, deckel)
return bemessung - frei
# ------------------------------------------------------------------
# Engine-Anbindung: Tarif-Teil ohne Sechstel, LNK-Pflicht, SV-Umlegung
# ------------------------------------------------------------------
def _l10n_at_bemessung_laufen(self, result_rules=None):
basis = super()._l10n_at_bemessung_laufen(result_rules)
# § 67 Abs 8 lit b: 4/5 tarifisch im Zuflussmonat; die Regel
# selbst ist neutral und erhöht brutto('laufend') nicht → kein
# Sechstel-Effekt (Abs 2 nicht anzuwenden).
return basis + self._at_fuenftel_tarif_basis(result_rules)
def _at_lnk_basis(self, steuer, result_rules=None):
"""Upstream-Überschreibung (Muster account_posting_private;
Vorlage payslip_private._at_lnk_basis — bei Änderungen dort
gegen diesen Pin neu abgleichen): Kündigungsentschädigungen
sind Arbeitslöhne iSd § 41 Abs 3 FLAG und unterliegen NICHT
den Abs-4-Ausschlüssen (lit b nennt nur § 67 Abs 3 und 6) —
sie sind VOR der Freibetragsstaffel in die LNK-Basis
aufzunehmen."""
self.ensure_one()
basis = (self._l10n_at_brutto('laufend', result_rules)
+ self._l10n_at_brutto('sonstig', result_rules)
- self._l10n_at_st_frei_freie_laufen(result_rules))
for rule in self.struct_id.rule_ids:
if (rule.l10n_at_bemessung in ('laufend', 'sonstig')
and rule.l10n_at_s67_abs in ('abs3', 'abs6')):
if result_rules is not None and rule.code in result_rules:
basis -= result_rules[rule.code]['total']
elif result_rules is None:
linie = self.line_ids.filtered(
lambda l: l.code == rule.code)
basis -= sum(linie.mapped('total'))
basis += self._at_fuenftel_total(result_rules)
basis = max(basis, 0.0)
if steuer in ('flaf', 'dz'):
version = self.version_id
if (version.l10n_at_lnkp_frei_ab
and self.date_from >= version.l10n_at_lnkp_frei_ab):
return 0.0
freibetrag = self.env['hr.rule.parameter']._get_parameter_from_code(
'at_lnk_freibetrag', self.date_to, raise_if_not_found=False) or {}
grenze = float(freibetrag.get('grenze', 0.0))
if basis <= 0.0:
return 0.0
if basis <= grenze:
basis -= float(freibetrag.get('freibetrag', 0.0))
return max(basis, 0.0)
def _l10n_at_sv_basis_laufen_monate(self, result_rules=None):
"""Upstream-Überschreibung (Muster account_posting_private;
Vorlage l10n_at_hr_payroll.sozialversicherung — bei Odoo- oder
Kern-Upgrades gegen diesen Pin neu abgleichen): identischer
Ablauf wie der Kern, zusätzlich werden Regeln mit AT-SV-
Verlängerung tageproportional auf die Verlängerungsmonate
gelegt. Der Standardanteil jedes Monats bleibt auf die volle
monatliche HBG gedeckelt, der Verlängerungsanteil auf den
Tageswert (monatliche HBG × Verlängerungstage/30, lb-url-15);
die Fenster laufen sequenziell (Abgang zuerst, UEL danach)."""
self.ensure_one()
gruppen = {}
verlaengerung = {}
tage_pro_monat = {}
for rule in self.struct_id.rule_ids:
if self._l10n_at_sv_art(rule) != 'laufend':
continue
werte = None
if result_rules is not None:
if rule.code in result_rules:
werte = result_rules[rule.code]
else:
linie = self.line_ids.filtered(
lambda l: l.code == rule.code)
if linie:
werte = {'total': sum(linie.mapped('total'))}
if not werte:
continue
if rule.l10n_at_sv_verlaengerung != 'keine':
for monat, tage, anteil in \
self._at_end_rule_verlaengerung(rule, result_rules):
verlaengerung[monat] = verlaengerung.get(monat, 0.0) \
+ werte['total'] * anteil
tage_pro_monat[monat] = \
tage_pro_monat.get(monat, 0) + tage
continue
monat = (rule.l10n_at_beitragsmonat or self.date_from) \
.replace(day=1)
gruppen[monat] = gruppen.get(monat, 0.0) + werte['total']
standard = self.date_from.replace(day=1)
if standard in gruppen:
gruppen[standard] -= self._l10n_at_sv49_freie_laufen(
result_rules)
ergebnis = []
for monat in sorted(set(gruppen) | set(verlaengerung)):
hbg = float(self._l10n_at_param('at_sv_hbg_monatlich', monat))
basis = min(max(gruppen.get(monat, 0.0), 0.0), hbg)
if monat in verlaengerung:
tageswert = hbg * tage_pro_monat[monat] / 30.0
basis += min(max(verlaengerung[monat], 0.0), tageswert)
ergebnis.append((monat, basis))
return ergebnis