Files
odoo-at-payroll/addons/l10n_at_hr_payroll/models/lohnsteuer.py
T
fegger a2a823ad35 [IMP] l10n_at_hr_payroll: GP4 SV/DB-Kern (DG-Beiträge, Geringfügigkeit, § 49, Beitragsmonat, DAG)
AP4-Kernbau (D3-Vorfinanzierung, freigegebener GP4-Bauplan): Die
Dienstgeberseite der Sozialversicherung wird sektorneutral im Kern
gebaut, damit sowohl das General-AT-Produkt als auch die GemBG-Schiene
sie konsumieren (keine Fork-Werte, payroll-Skill-Architekturregel).

Neu in models/sozialversicherung.py:
- SVDG laufend (ÖGK Normalbetriebe 2026: 20,98 %; Wien 21,23 %) und auf
  Sonderzahlungen (§ 54 ASVG: 20,48 % inkl. UV 1,10 und IESG 0,10);
- Geringfügigkeit § 5 Abs 2 ASVG mit Hochrechnung bei untermonatiger
  Beschäftigung (SZ zählen nicht) und §-53a-Abs-1-UV als Monatspauschale;
- beitragsfreie Bezüge § 49 Abs 3 ASVG als Jahres-Kumulative je DN
  (8 €/AT, 1.000/365/186/300/2.000 €/J, Homeoffice 3 €/Tag × 100);
- altersabhängige Satzausschaltungen als Stichtags-Datumfelder am
  Vertrag: AlVG § 1 Abs 2 lit e (AV DN+DG und IESG), ASVG § 51 Abs 6
  (UV ab 60/Lehrlinge), § 51 Abs 7 (PV in der Bonusphase je hälftig
  DG+DN), FLAG § 41 Abs 4 lit f (LNK-Freiheit ab 60);
- ALV-DN-Staffel § 2a AMPFG, WF-Satzvektor Bundesland × Jahr (Wien
  0,75/0,75 ab 1.1.2026, löst die KB-Protokollnotiz), Beitragsmonats-
  Allokation (Anspruchsprinzip: Sätze/HBG historisch je Gruppe) und
  DAG-Bewertung § 1 DAG (19,4 %, Schwelle 1,5 × GFG, ≥ 2 Geringfügige).

Quellenverifikation (verbindlich): ÖGK-Beitragstarifsystem gültig ab
01.07.2026, TASY-LSWH-Export 2026-03-24, Beitragsrechtliche Werte 2026
und RIS-Normtexte. GFG 2026 = 551,10 € (Aufwertung ausgesetzt gem.
§ 810 Abs 3 ASVG — löst KB-Konflikt A7-Rest); §-19a-Selbstversicherung
2026 = 83,49 €/Monat, DG-UV daraus abgeleitet 6,50 €/Monat.

Rechtskorrektur § 2a AMPFG: Die Staffel mindert nur den DN-Anteil
(ÖGK-Abschläge 'Mind. AV auf 0/1/2 %') und gilt auch für SZ-Beiträge;
bei 2.059,41 € BG sinkt DN-SV von pauschal 18,07 % auf 15,12 %.
test_lohnsteuer wurde entsprechend angepasst (Rechenfälle RF-L1 ff.).

Tests: test_sozialversicherung (14 Fälle); Suite 74/74 grün auf
odoo_dev. Rechenfälle: l10n_at_hr_payroll_private/docs/rechenfaelle/
sozialversicherung.md.
2026-09-11 23:21:55 +02:00

408 lines
19 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.
"""Lohnsteuer-Kern (GP3/AP3): § 66 Monatslohnsteuer kumulativ mit
Rollung (Zuflussprinzip), § 67 sonstige Bezüge mit Sechstel-
Aufrollung, § 68-Steuerfreiheiten, DN-SV als Bemessungsbestandteil.
Architektur (D4-verifiziert): hr.salary.rule.struct_id ist required und
rule_ids One2many — Kern-Regeln sind nicht teilbar. Der Kern liefert
Compute + Parameter + Flags; die Lokalisationen binden dünne Regeln,
die diese Methoden aufrufen (kein Wertefork, keine Logikfork).
Laufzeit-Datenfluss: compute_sheet legt die Zeilen erst NACH der
Regelschleife an — die Regeln reichen daher die Zwischensummen über
``result_rules`` (localdict, s. hr_payslip._compute_line L1225) an
die Compute-Methoden; frühere (validierte) Läufe werden über deren
line_ids gelesen (YTD/Rollung).
User-Entscheidungen 2026-09-11:
- Lohnsteuertage: 30 je vollem Monat; Teilmonat = Kalendertage der
Beschäftigung inkl. arbeitsfreier Tage (§ 66 Abs 3), max 30.
- Bemessung laufend = Arbeitslohn SV-DN steuerfreie Bezüge
(user-Formel „SV-Beitragsgrundlage SV-DN").
- Bemessung SZ = sonstiger Bezug SV-DN(SZ) (Abs 12) 620 €
(jahreskumulativ); 620Jahressechstel 6 %, darüber LSt-lfd (Abs 10).
- Aufrollung: Vormonatskorrekturen rollen über die kumulative Engine
in den Zuflussmonat; SV-Allokation auf Beitragsmonate = GP4,
Nachtrags-mBGM = GP7.
"""
from datetime import date, timedelta
from odoo import fields, models
class HrSalaryRule(models.Model):
_inherit = 'hr.salary.rule'
l10n_at_bemessung = fields.Selection(
[('neutral', 'Kein Arbeitslohn (Abzug/Netto/DG)'),
('laufend', 'Laufender Arbeitslohn (§ 25/§ 66)'),
('sonstig', 'Sonstiger Bezug (§ 67)')],
string='AT Bemessungsart',
default='neutral',
help='Laufend: §-66-Kette (Hochrechnung); sonstig: § 67 '
'(Jahressechstel/Fixsätze); neutral: kein Arbeitslohn.')
l10n_at_steuerbefreiung_68 = fields.Selection(
[('keine', 'Keine §-68-Steuerfreiheit'),
('szfn', '§ 68 Abs 1 (Sonn-/Feiertags-/Nachtzuschläge)'),
('ue_std', '§ 68 Abs 2 (ÜStd-Zuschläge bis 50 % Grundlohn)')],
string='AT § 68-Freibetrag',
default='keine')
l10n_at_zuschlagsanteil = fields.Float(
string='AT Zuschlagsanteil (%)',
help='Anteil des Zeilenbetrags, der Zuschlag (und damit '
'§-68-potentiell steuerfrei) ist — z. B. 50 %-Regel mit '
'Grundvergütung: 0,5/1,5 = 33,33 %.')
class HrVersion(models.Model):
_inherit = 'hr.version'
l10n_at_familienabsetzbetrag = fields.Selection(
[('keine', 'Keine'), ('avb', 'Alleinverdienerabsetzbetrag'),
('avab', 'Alleinerzieherabsetzbetrag')],
string='AT Familienabsetzbetrag',
default='keine',
help='AVB nur bei Partnereinkommen ≤ Grenze (§ 33 Abs 4; '
'Nachweis mandantenseitig). Betrag nach Anzahl Kinder.')
l10n_at_ueberw_nachtarbeit = fields.Boolean(
string='AT Überwiegende Nachtarbeit (§ 68 Abs 6)',
help='Erhöht den SZFN-Freibetrag 400 → 600 € (Nachtarbeit '
'≥ 3 h zwischen 19 und 7 Uhr, überwiegend in der NAZ).')
class ResCompany(models.Model):
_inherit = 'res.company'
l10n_at_wf_wien = fields.Boolean(
string='AT Wien (WF-Zuschlag)',
help='Wiener WF-Zuschlag: WF gesamt 1,5 % (DN 0,75 %) ab '
'1. 1. 2026 statt 1,0 % (Wiener WF-Beitragstarif 2018 § 1).')
class HrPayslip(models.Model):
_inherit = 'hr.payslip'
# ------------------------------------------------------------------
# Basis-Helfer
# ------------------------------------------------------------------
def _l10n_at_param(self, code, datum=None):
return self.env['hr.rule.parameter']._get_parameter_from_code(
code, datum or self.date_to)
def _l10n_at_linien(self, art):
self.ensure_one()
return self.line_ids.filtered(
lambda l: l.salary_rule_id.l10n_at_bemessung == art)
def _l10n_at_brutto(self, art, result_rules=None):
"""Arbeitslohn je Bemessungsart (user-Formel-Basis): im
laufenden compute über result_rules (Zwischensummen), bei
abgeschlossenen Läufen über line_ids."""
self.ensure_one()
if result_rules is not None:
total = 0.0
for rule in self.struct_id.rule_ids:
if (rule.l10n_at_bemessung == art
and rule.code in result_rules):
total += result_rules[rule.code]['total']
return total
return sum(self._l10n_at_linien(art).mapped('total'))
def _l10n_at_lohnsteuertage(self):
"""Lohnsteuertage des eigenen LZP: 30 je vollem Monat
(user decision; § 66 Abs 3 360-Tage-Jahr); Teilmonat =
Kalendertage der Beschäftigung inkl. arbeitsfreier Tage,
max 30."""
self.ensure_one()
erster = self.date_from.replace(day=1)
naechster = (erster + timedelta(days=32)).replace(day=1)
vollen_monat = (self.date_from == erster
and self.date_to == naechster - timedelta(days=1))
version = self.version_id
von = max(self.date_from,
version.contract_date_start or self.date_from)
bis = min(self.date_to, version.contract_date_end or self.date_to)
if von > bis:
return 0
if vollen_monat and von == self.date_from and bis == self.date_to:
return 30
return min((bis - von).days + 1, 30)
def _l10n_at_ytd_slaeufe(self):
"""Validierte, nicht stornierte Läufe des Kalenderjahres vor
diesem LZP (Rollungs-/Aufrollungsbasis; refundbewusst:
Negativ-Linien des Refund-Laufs neutralisieren den Original)."""
self.ensure_one()
jahr = self.date_to.year
return self.env['hr.payslip'].search([
('employee_id', '=', self.employee_id.id),
('date_to', '>=', date(jahr, 1, 1)),
('date_to', '<', self.date_from),
]).filtered(lambda p: p.state not in ('draft', 'cancel'))
def _l10n_at_ytd_linien_total(self, code):
"""YTD-Summe einer Regel über frühere Läufe (refundbewusst)."""
self.ensure_one()
total = 0.0
for slip in self._l10n_at_ytd_slaeufe():
total += sum(
slip.line_ids.filtered(
lambda l: l.code == code).mapped('total'))
return total
def _l10n_at_lohnsteuertage_ytd(self):
self.ensure_one()
return sum(slip._l10n_at_lohnsteuertage()
for slip in self._l10n_at_ytd_slaeufe()) \
+ self._l10n_at_lohnsteuertage()
# ------------------------------------------------------------------
# § 68 — Steuerfreiheiten (Buckets je Regel-Flag, versionierte Caps)
# ------------------------------------------------------------------
def _l10n_at_freibetraege_68(self, result_rules=None):
"""Steuerfreie Bezüge je LZP: {'szfn': x, 'ue_std': y} —
Abs 1: SFN-Zuschläge bis 400 € (600 € überw. Nachtarbeit);
Abs 2 iVm § 124b Z 440: ÜStd-Zuschläge bis 50 % Grundlohn,
erste 15 ÜStd, max 170 € (2026; ab 2027 10/120 — versioniert)."""
self.ensure_one()
ergebnis = {'szfn': 0.0, 'ue_std': 0.0}
grenzen = self._l10n_at_param('at_68_szfn_grenze')
cap_szfn = (grenzen['grenze_ueberwiegend']
if self.version_id.l10n_at_ueberw_nachtarbeit
else grenzen['grenze'])
ue = self._l10n_at_param('at_68_ue_std')
szfn = 0.0
betrag_ue, stunden_ue = 0.0, 0.0
for rule in self.struct_id.rule_ids:
werte = None
if result_rules is not None:
if rule.code in result_rules:
werte = result_rules[rule.code]
elif rule.l10n_at_bemessung != 'neutral':
line = self.line_ids.filtered(lambda l: l.code == rule.code)
if line:
werte = {'total': sum(line.mapped('total')),
'quantity': sum(line.mapped('quantity'))}
if not werte:
continue
anteil = (rule.l10n_at_zuschlagsanteil or 0.0) / 100.0
if rule.l10n_at_steuerbefreiung_68 == 'szfn':
szfn += werte['total'] * anteil
elif rule.l10n_at_steuerbefreiung_68 == 'ue_std':
betrag_ue += werte['total'] * anteil
stunden_ue += werte.get('quantity', 0.0)
ergebnis['szfn'] = min(max(szfn, 0.0), cap_szfn)
if stunden_ue > float(ue['stunden']):
betrag_ue = betrag_ue * float(ue['stunden']) / stunden_ue
ergebnis['ue_std'] = min(max(betrag_ue, 0.0), float(ue['betrag']))
return ergebnis
# ------------------------------------------------------------------
# DN-SV (Bemessungsbestandteil § 62) — Basis/Sätze/Geringfügigkeit/
# §-49-Kumulative/DG-Seite: models/sozialversicherung.py (GP4)
# ------------------------------------------------------------------
def _l10n_at_svdn(self, komponente, result_rules=None):
"""DN-SV-Komponente auf laufender Basis: kv/pv/av/ak/wf —
AK § 61 AKG und WF § 62 Z 5 nur laufend (nicht auf §-67-Bezüge);
Wien-WF über Satzvektor (l10n_at_wf_wien/Bundesland, GP4)."""
self.ensure_one()
return self._l10n_at_sv_beitrag('dn', komponente, result_rules)
def _l10n_at_svdn_gesamt(self, result_rules=None):
self.ensure_one()
return sum(self._l10n_at_svdn(k, result_rules)
for k in ('kv', 'pv', 'av', 'ak', 'wf'))
def _l10n_at_svdn_basis_sz(self, result_rules=None):
"""SV-BG sonstige Bezüge: HBG monatlich und SZ-Jahreshöchstbetrag
(13.860) minus YTD-SZ-Basen (Anspruchsprinzip-Vorlauf GP4)."""
self.ensure_one()
sz = max(self._l10n_at_brutto('sonstig', result_rules), 0.0)
hbg = float(self._l10n_at_param('at_sv_hbg_monatlich'))
jahreshbg = float(self._l10n_at_param('at_sv_hbg_sz_jaehrlich'))
for slip in self._l10n_at_ytd_slaeufe():
jahreshbg -= max(slip._l10n_at_brutto('sonstig'), 0.0)
return min(sz, hbg, max(jahreshbg, 0.0))
def _l10n_at_svdn_sz(self, result_rules=None):
"""SV-DN auf sonstigen Bezügen (§ 67 Abs 12 — vor Fixsätzen
abzuziehen): KV/PV/AV, ohne AK/WF (§ 62 Z 3/Z 5); Satzausschal-
tungen/Steffel über _l10n_at_sv_satz (GP4)."""
self.ensure_one()
if self._l10n_at_geringfuegig(result_rules):
return 0.0
basis = self._l10n_at_svdn_basis_sz(result_rules)
satz = sum(self._l10n_at_sv_satz(
'dn', k, staffel_basis=self._l10n_at_sv_basis_roh(result_rules))
for k in ('kv', 'pv', 'av'))
return basis * satz / 100.0
# ------------------------------------------------------------------
# Bemessung (user-Formeln) — laufend / sonstig
# ------------------------------------------------------------------
def _l10n_at_bemessung_laufen(self, result_rules=None):
"""LSt-Bemessung laufend: Arbeitslohn SV-DN (§ 62-Kette,
inkl. AK/WF) steuerfreie Bezüge § 68. Der volle Arbeitslohn
ist lohnsteuerlich unbegrenzt (keine HBG-Cap für die LSt;
die Cap gilt nur für die SV-Sätze)."""
self.ensure_one()
freibe = self._l10n_at_freibetraege_68(result_rules)
return (self._l10n_at_brutto('laufend', result_rules)
- self._l10n_at_svdn_gesamt(result_rules)
- freibe['szfn'] - freibe['ue_std'])
def _l10n_at_sz_bemessung(self, result_rules=None):
"""LSt-Bemessung sonstiger Bezug: Bezug SV-DN (Abs 12);
Freibetrag/Sechstel-Portionierung erfolgt in
_l10n_at_lst_sonstige."""
self.ensure_one()
return (self._l10n_at_brutto('sonstig', result_rules)
- self._l10n_at_svdn_sz(result_rules))
def _l10n_at_brutto_laufen_ytd(self, result_rules=None):
"""Kumulierte laufende Bruttobezüge des Jahres (Sechstel-Basis
ist Brutto, § 67 Abs 2 — inkl. Auszahlungsmonat-Laufendem)."""
self.ensure_one()
total = self._l10n_at_brutto('laufend', result_rules)
for slip in self._l10n_at_ytd_slaeufe():
total += slip._l10n_at_brutto('laufend')
return total
def _l10n_at_sechstel(self, result_rules=None):
"""Jahressechstel = kumulierte laufende Brutto × 60 ÷
Lohnsteuertage (YTD inkl. aktueller LZP)."""
self.ensure_one()
tage = self._l10n_at_lohnsteuertage_ytd()
if not tage:
return 0.0
return self._l10n_at_brutto_laufen_ytd(result_rules) * 60.0 / tage
# ------------------------------------------------------------------
# § 33 Tarif + Absetzbeträge
# ------------------------------------------------------------------
def _l10n_at_tarif(self, jbg):
"""§ 33 Abs 1: Zonentarif auf die Jahresbemessungsgrundlage."""
self.ensure_one()
zonen = self._l10n_at_param('at_lst_tarif')['zonen']
steuer = 0.0
for index, (grenze, satz) in enumerate(zonen):
obere = zonen[index + 1][0] if index + 1 < len(zonen) else jbg
if jbg > grenze:
steuer += (min(jbg, obere) - grenze) * float(satz) / 100.0
return steuer
def _l10n_at_absetzbetraege(self, jbg):
"""§ 66 zitierte Absetzbeträge: Verkehrsabsetzbetrag (496 €)
+ AVB/AVAB je Vertrags-Feld und Kinderzahl (§ 33 Abs 4/5)."""
self.ensure_one()
absetz = self._l10n_at_param('at_lst_absetzbetraege')
betrag = float(absetz['verkehrsabsetzbetrag'])
fam = self.version_id.l10n_at_familienabsetzbetrag
kinder = self.version_id.children or 0
if fam in ('avb', 'avab') and kinder > 0:
if kinder == 1:
betrag += float(absetz['avb_kind1'])
else:
betrag += float(absetz['avb_kinder2']) \
+ (kinder - 2) * float(absetz['avb_weiteres_kind'])
return betrag
# ------------------------------------------------------------------
# § 66 Monatslohnsteuer — kumulativ mit Rollung
# ------------------------------------------------------------------
def _l10n_at_lst_laufend(self, result_rules=None):
"""LSt auf laufende Bezüge (§ 66): kumulative Hochrechnung
(360-Tage-Jahr) minus bereits einbehaltene LSt — Vormonats-
korrekturen rollen automatisch in den Zuflussmonat (Aufrollung,
user decision). §-67-Bezüge unter der Freigrenze sowie der
Sechstel-Überschuss (§ 67 Abs 10) laufen tarifisch hier mit."""
self.ensure_one()
bem_ytd = self._l10n_at_bemessung_laufen(result_rules)
sz_tarifisch_aktuell = self._l10n_at_sz_tarifisch_aktuell(result_rules)
for slip in self._l10n_at_ytd_slaeufe():
bem_ytd += (slip._l10n_at_bemessung_laufen()
+ slip._l10n_at_sz_tarifisch_aktuell())
bem_ytd += sz_tarifisch_aktuell
tage = self._l10n_at_lohnsteuertage_ytd()
if not tage or bem_ytd <= 0:
return 0.0
faktor = 360.0 / tage
pauschalen = self._l10n_at_param('at_lst_pauschalen')
jbg = (bem_ytd * faktor
- float(pauschalen['werbepauschbetrag'])
- float(pauschalen['sonderausgaben_pauschbetrag']))
steuer = max(0.0,
self._l10n_at_tarif(jbg) - self._l10n_at_absetzbetraege(jbg))
soll_kumuliert = steuer / faktor
bereits = self._l10n_at_ytd_linien_total('LST_LFD')
return max(0.0, soll_kumuliert - bereits)
def _l10n_at_sz_tarifisch_aktuell(self, result_rules=None):
"""Anteil des aktuellen §-67-Bezugs, der tarifisch (LSt-lfd)
läuft: (a) gesamter Bezug bei Sechstel ≤ Freigrenze (2.615 €,
Pauschalierung unterbleibt) oder (b) Überschuss über das
Jahressechstel (§ 67 Abs 10)."""
self.ensure_one()
sz_bem = self._l10n_at_sz_bemessung(result_rules)
if sz_bem <= 0:
return 0.0
sechstel = self._l10n_at_sechstel(result_rules)
freigrenze = float(self._l10n_at_param('at_lst_67_freigrenze'))
if sechstel <= freigrenze:
return sz_bem
ytd_sz_bem = self._l10n_at_ytd_sz_bemessung()
verbleibend = max(sechstel - ytd_sz_bem, 0.0)
return max(sz_bem - verbleibend, 0.0)
def _l10n_at_ytd_sz_bemessung(self):
"""Kumulierte §-67-Bemessungen früherer Läufe (für Sechstel-
Verbrauch und Aufrollung)."""
self.ensure_one()
total = 0.0
for slip in self._l10n_at_ytd_slaeufe():
total += slip._l10n_at_sz_bemessung()
return total
# ------------------------------------------------------------------
# § 67 — sonstige Bezüge (Fixsätze im Sechstel + Aufrollung)
# ------------------------------------------------------------------
def _l10n_at_lst_sonstige(self, result_rules=None):
"""LSt auf sonstige Bezüge: Fixsätze 0/6/27/35,75 % auf die
Bemessung (Abs 12-SV vorher, Freibetrag 620 € jahreskumulativ)
innerhalb des Jahressechstels; Neuberechnung des gesamten
Jahres-SZ-Steuer bei jeder SZ-Zahlung (Günstig-Aufrollung,
lb-son-04): Soll = f(Jahres-Bemessung, aktuelles Sechstel)
minus bereits gezahlte SZ-Steuer."""
self.ensure_one()
sz_bem = self._l10n_at_sz_bemessung(result_rules)
if sz_bem <= 0:
return 0.0
sechstel = self._l10n_at_sechstel(result_rules)
freigrenze = float(self._l10n_at_param('at_lst_67_freigrenze'))
if sechstel <= freigrenze:
return 0.0 # tarifisch — läuft über LST_LFD
ytd_bem = max(self._l10n_at_ytd_sz_bemessung(), 0.0)
total_in_sechstel = min(ytd_bem + sz_bem, sechstel)
fixsaetze = self._l10n_at_param('at_lst_67_fixsaetze')
freibetrag = float(fixsaetze['freibetrag'])
soll_steuer = 0.0
offen = max(total_in_sechstel - freibetrag, 0.0)
for breite, satz in fixsaetze['pakete']:
portion = min(offen, float(breite))
soll_steuer += portion * float(satz) / 100.0
offen -= portion
if offen <= 0:
break
bereits = self._l10n_at_ytd_linien_total('LST_SZ')
return max(soll_steuer - bereits, 0.0)
# ------------------------------------------------------------------
# Nachzahlungsfälle (plan 4.3 — Rollung automatisch; Fünftelregelung
# § 67 Abs 8 lit c als Utility; 15.2.-Cutoff/Willkür→Tarif GP8)
# ------------------------------------------------------------------
def _l10n_at_fuenftelregelung(self, betrag):
"""§ 67 Abs 8 lit c: zwangsläufige Verschiebung eines sonstigen
Bezugs über den 15. 2. — 1/5 des begünstigten Teils steuerfrei,
4/5 mit Tarif (Utility für GP8-Nachzahlungskonfiguration)."""
self.ensure_one()
return betrag * 4.0 / 5.0