Files
odoo-at-payroll/addons/l10n_at_hr_payroll/tests/test_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

285 lines
13 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.
"""GP3 Lohnsteuer-Kern: § 33 Tarif, Absetzbeträge, § 66 kumulative
Monatslohnsteuer (Rollung), § 68-Freibeträge, § 67 Sechstel/Fixsätze
mit Günstig-Aufrollung, DN-SV-Basis. Rechenfälle s.
personalverrechnung/RECHTSQUELLEN-Privat.md Abschnitt 5 (P0-GP3)."""
from datetime import date, timedelta
from odoo.tests import TransactionCase, tagged
@tagged('post_install', '-at_install')
class LohnsteuerTest(TransactionCase):
def setUp(self):
super().setUp()
self.category = self.env['hr.salary.rule.category'].create({
'name': 'Test AT Kern',
'code': 'TEST_AT_KERN',
})
self.structure_type = self.env['hr.payroll.structure.type'].create({
'name': 'Test AT Kern Type',
})
self.structure = self.env['hr.payroll.structure'].create({
'name': 'Test AT Lohnsteuer',
'code': 'TEST_AT_LST',
'type_id': self.structure_type.id,
})
def _regel(self, code, betrag, bemessung='laufend', flag68='keine',
anteil=0.0, quantity='1.0', python=None):
vals = {
'name': code,
'code': code,
'struct_id': self.structure.id,
'category_id': self.category.id,
'condition_select': 'none',
'l10n_at_bemessung': bemessung,
'l10n_at_steuerbefreiung_68': flag68,
'l10n_at_zuschlagsanteil': anteil,
}
if python:
vals.update({'amount_select': 'code',
'amount_python_compute': python})
else:
vals.update({'amount_select': 'fix', 'amount_fix': betrag,
'quantity': quantity})
return self.env['hr.salary.rule'].create(vals)
def _employee(self, version_vals=None):
employee = self.env['hr.employee'].create({'name': 'Test LSt'})
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': date(2020, 1, 1)})
version.write({'contract_date_start': date(2020, 1, 1)})
if version_vals:
version.write(version_vals)
return employee
def _slip(self, employee, monat, jahr=2026, confirm=False):
erster = date(jahr, monat, 1)
naechster = (erster + timedelta(days=32)).replace(day=1)
slip = self.env['hr.payslip'].create({
'name': 'Test-LSt %d/%d' % (monat, jahr),
'employee_id': employee.id,
'struct_id': self.structure.id,
'date_from': erster,
'date_to': naechster - timedelta(days=1),
})
slip.compute_sheet()
if confirm:
slip.action_payslip_done()
return slip
def _total(self, slip, code):
return sum(slip.line_ids.filtered(
lambda l: l.code == code).mapped('total'))
# ------------------------------------------------------------------
# § 33 Tarif / Absetzbeträge
# ------------------------------------------------------------------
def test_01_tarif_zonen(self):
employee = self._employee()
slip = self._slip(employee, 1)
self.assertAlmostEqual(slip._l10n_at_tarif(13539.0), 0.0, places=2)
# 15.000: 20 % auf 1.461 = 292,20
self.assertAlmostEqual(slip._l10n_at_tarif(15000.0), 292.20, places=2)
# 30.000: 1.690,60 + (30.00021.992) × 0,30 = 4.093,00
self.assertAlmostEqual(slip._l10n_at_tarif(30000.0), 4093.00, places=2)
# 80.000: 1.690,60 + 4.339,80 + 13.562,80 + 4.624,80 = 24.218,00
self.assertAlmostEqual(slip._l10n_at_tarif(80000.0), 24218.00, places=2)
def test_02_absetzbetraege(self):
employee = self._employee()
slip = self._slip(employee, 1)
self.assertAlmostEqual(slip._l10n_at_absetzbetraege(20000.0), 496.0, places=2)
version = slip.version_id
version.write({'l10n_at_familienabsetzbetrag': 'avb', 'children': 1})
self.assertAlmostEqual(
slip._l10n_at_absetzbetraege(20000.0), 496.0 + 612.0, places=2)
version.write({'children': 3})
self.assertAlmostEqual(
slip._l10n_at_absetzbetraege(20000.0),
496.0 + 828.0 + 273.0, places=2)
# ------------------------------------------------------------------
# Lohnsteuertage (user decision: 30 je vollem Monat)
# ------------------------------------------------------------------
def test_03_lohnsteuertage(self):
employee = self._employee()
slip = self._slip(employee, 1)
self.assertEqual(slip._l10n_at_lohnsteuertage(), 30)
slip.version_id.write({
'contract_date_start': date(2026, 1, 16)})
# 16.31. 1. = 16 Kalendertage (inkl. arbeitsfreier, § 66 Abs 3)
self.assertEqual(slip._l10n_at_lohnsteuertage(), 16)
slip.version_id.write({
'contract_date_start': date(2026, 1, 1),
'contract_date_end': date(2026, 1, 10)})
self.assertEqual(slip._l10n_at_lohnsteuertage(), 10)
# ------------------------------------------------------------------
# § 68 Freibeträge (Buckets über Regel-Flags)
# ------------------------------------------------------------------
def test_68_freibetraege(self):
employee = self._employee()
self._regel('L68A', 300.0, flag68='szfn', anteil=50.0)
self._regel('L68B', 300.0, flag68='szfn', anteil=50.0) # 300 gesamt > 400? 2x150=300
self._regel('L68C', 41.19, flag68='ue_std', anteil=33.33, quantity='2.0')
slip = self._slip(employee, 1)
freibe = slip._l10n_at_freibetraege_68()
# szfn: 150 + 150 = 300 ≤ 400
self.assertAlmostEqual(freibe['szfn'], 300.0, places=2)
# ue_std: 41,19 × 2 h? total = 41,19 × 2 = 82,38; Anteil 33,33 %
# → 27,46; 2 h ≤ 15; ≤ 170
self.assertAlmostEqual(freibe['ue_std'], 27.46, places=2)
slip.version_id.write({'l10n_at_ueberw_nachtarbeit': True})
freibe = slip._l10n_at_freibetraege_68()
self.assertAlmostEqual(freibe['szfn'], 300.0, places=2)
def test_68_ue_std_cap(self):
"""§ 68 Abs 2 iVm § 124b Z 440: 15 ÜStd/170 € (2026)."""
employee = self._employee()
# 20 ÜStd à 41,19 (Anteil 33,33 %) → Zuschlag 274,60; > 15 h
# → anteilig 15/20; cap 170 greift
self._regel('LUE', 41.19, flag68='ue_std', anteil=33.33, quantity='20.0')
slip = self._slip(employee, 1)
freibe = slip._l10n_at_freibetraege_68()
self.assertAlmostEqual(
freibe['ue_std'], min(41.19 * 20 * 0.3333 * 15 / 20, 170.0), places=2)
self.assertAlmostEqual(freibe['ue_std'], 170.0, places=1)
# ------------------------------------------------------------------
# DN-SV / Bemessung (user-Formel)
# ------------------------------------------------------------------
def test_05_svdn_und_bemessung(self):
"""SV-DN auf 2.059,41 €: § 2a AMPFG mindert den DN-AV auf 0 %
(BG ≤ 2.225 €) → DN-Gesamt 15,12 % statt 18,07 % (GP4-Staffel;
DG-AV bleibt 2,95 %); Bemessung = Arbeitslohn SV-DN."""
employee = self._employee()
self._regel('LFD', 2059.41)
slip = self._slip(employee, 1)
svdn = slip._l10n_at_svdn_gesamt()
self.assertAlmostEqual(svdn, 2059.41 * 0.1512, places=2)
bem = slip._l10n_at_bemessung_laufen()
self.assertAlmostEqual(bem, 2059.41 - svdn, places=2)
def test_06_svdn_wien_wf(self):
employee = self._employee()
self._regel('LFD', 2059.41)
slip = self._slip(employee, 1)
slip.company_id.write({'l10n_at_wf_wien': True})
self.assertAlmostEqual(
slip._l10n_at_svdn('wf'), 2059.41 * 0.0075, places=2)
# ------------------------------------------------------------------
# § 66 kumulative Monatslohnsteuer + Rollung
# ------------------------------------------------------------------
def test_07_lst_laufend_monat(self):
"""Vollmonat: JBG = Bemessung × 12 192; Tarif Verkehrs-AB."""
employee = self._employee()
self._regel('LFD', 2059.41)
slip = self._slip(employee, 1)
svdn = slip._l10n_at_svdn_gesamt()
bem = 2059.41 - svdn
jbg = bem * 12 - 132 - 60
erwartet = max(0.0, (0.20 * (jbg - 13539)) - 496.0) / 12
self.assertAlmostEqual(
slip._l10n_at_lst_laufend(), erwartet, places=2)
def test_08_lst_rollung_zweiter_monat(self):
"""Kumulativ mit LST-Linie: Februar zahlt die Differenz zum
Jänner-Soll (Rollung über result_rules-Mechanik)."""
employee = self._employee()
self._regel('LFD', 2059.41)
self._regel(
'LST_LFD', 0.0, bemessung='neutral',
python='result = payslip._l10n_at_lst_laufend(result_rules)')
slip_jan = self._slip(employee, 1, confirm=True)
jan = self._total(slip_jan, 'LST_LFD')
slip_feb = self._slip(employee, 2)
feb = self._total(slip_feb, 'LST_LFD')
# Identisches Gehalt: Feb-Soll ≈ Jan-Soll (kumulative Neuberechnung
# minus bereits einbehaltene LSt)
self.assertAlmostEqual(feb, jan, places=2)
# Rollung: dritter Monat zieht die Differenz nach
slip_mar = self._slip(employee, 3)
self.assertAlmostEqual(self._total(slip_mar, 'LST_LFD'), jan, places=2)
# ------------------------------------------------------------------
# § 67 sonstige Bezüge
# ------------------------------------------------------------------
def test_09_lst_sonstige_b13(self):
"""B13 über Freigrenze: Fixsatz 6 % nach 620-Freibetrag;
Abs 12: SV-DN vom SZ vorher. SZ-Regel erst NACH dem Oktober-
lauf anlegen (sonst zahlt Oktober fälschlich SZ)."""
employee = self._employee()
self._regel('LFD', 2059.41)
slip_okt = self._slip(employee, 10, confirm=True)
self._regel('SZ', 2059.41, bemessung='sonstig')
slip_nov = self._slip(employee, 11)
sz_bem = slip_nov._l10n_at_sz_bemessung()
sv_sz = 2059.41 * 0.1412 # KV+PV auf SZ-Basis; AV-DN 0 (§ 2a)
self.assertAlmostEqual(sz_bem, 2059.41 - sv_sz, places=2)
# Sechstel = 2 × 2059,41 × 60 ÷ 60 = 4.118,82 > Freigrenze
self.assertAlmostEqual(
slip_nov._l10n_at_sechstel(), 2 * 2059.41, places=2)
erwartet = (sz_bem - 620.0) * 0.06
self.assertAlmostEqual(
slip_nov._l10n_at_lst_sonstige(), erwartet, places=2)
self.assertAlmostEqual(slip_nov._l10n_at_sz_tarifisch_aktuell(), 0.0, places=2)
def test_10_sechstel_freigrenze_tarifisch(self):
"""Sechstel ≤ 2.615 €: Pauschalierung unterbleibt — SZ läuft
tarifisch über die §-66-Kette."""
employee = self._employee()
self._regel('LFD', 500.0)
self._regel('SZ', 300.0, bemessung='sonstig')
slip = self._slip(employee, 1)
# Sechstel = 500 × 60/30 = 1.000 ≤ 2.615
self.assertAlmostEqual(slip._l10n_at_sechstel(), 1000.0, places=2)
self.assertAlmostEqual(slip._l10n_at_lst_sonstige(), 0.0, places=2)
sz_bem = slip._l10n_at_sz_bemessung()
self.assertAlmostEqual(
slip._l10n_at_sz_tarifisch_aktuell(), sz_bem, places=2)
def test_11_guenstig_aufrollung_zweite_sz(self):
"""Günstig-Aufrollung: zweite SZ-Zahlung rechnet die Jahres-
SZ-Steuer mit dem dann aktuellen Sechstel neu."""
employee = self._employee()
self._regel('LFD', 2059.41)
slip_okt = self._slip(employee, 10, confirm=True)
self._regel('SZ', 2059.41, bemessung='sonstig')
self._regel(
'LST_SZ', 0.0, bemessung='neutral',
python='result = payslip._l10n_at_lst_sonstige(result_rules)')
slip_nov = self._slip(employee, 11, confirm=True)
sz_nov = self._total(slip_nov, 'LST_SZ')
# SV-SZ = 2.059,41 × 14,12 % (KV+PV; AV-DN 0 nach § 2a AMPFG)
self.assertAlmostEqual(
sz_nov, (2059.41 * (1 - 0.1412) - 620.0) * 0.06, places=1)
slip_dez = self._slip(employee, 12)
sz_dez = self._total(slip_dez, 'LST_SZ')
# Jahres-SZ-Bemessung = 2 × (2.059,41 SV-SZ)
jahres_bem = 2 * 2059.41 * (1 - 0.1412)
soll_jahr = (jahres_bem - 620.0) * 0.06
self.assertAlmostEqual(sz_dez, soll_jahr - sz_nov, places=1)
def test_12_sz_ueberschuss_tarifisch(self):
"""§ 67 Abs 10: SZ über dem Sechstel läuft tarifisch (LSt-lfd);
Sechstel muss über der Freigrenze 2.615 € liegen."""
employee = self._employee()
self._regel('LFD', 2000.0)
self._regel('SZ', 6000.0, bemessung='sonstig')
slip = self._slip(employee, 1)
# Sechstel = 2.000 × 60/30 = 4.000 > 2.615 → Pauschalierung
self.assertAlmostEqual(slip._l10n_at_sechstel(), 4000.0, places=2)
sz_bem = slip._l10n_at_sz_bemessung() # 6.000 SV-SZ (14,12 %)
self.assertAlmostEqual(sz_bem, 6000.0 - 6000.0 * 0.1412, places=2)
self.assertAlmostEqual(
slip._l10n_at_sz_tarifisch_aktuell(), sz_bem - 4000.0, places=2)
self.assertAlmostEqual(
slip._l10n_at_lst_sonstige(), (4000.0 - 620.0) * 0.06, places=1)