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

400 lines
18 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, st_frei='keine',
sv_art='folgt'):
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,
'l10n_at_st_frei': st_frei,
'l10n_at_sv_art': sv_art,
}
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)
# ------------------------------------------------------------------
# § 3/§ 26 — steuerfreie Sachbezüge (Freibeträge, Jahres-Kumulative)
# ------------------------------------------------------------------
def test_13_st_frei_mahlzeit(self):
"""Mahlzeiten-Gutschein 11 €/AT × 10 AT = 110 €: 80 € steuerfrei
(8 €/AT, § 3 Abs 1 Z 17), 30 € steuerpflichtig; LSt-Bemessung
um 80 € reduziert (lb-sac-17)."""
employee = self._employee()
self._regel('LFD', 2000.0)
self._regel('MAH', 11.0, quantity='10.0', st_frei='mahlzeit')
slip = self._slip(employee, 1)
self.assertAlmostEqual(
slip._l10n_at_st_frei_freie_laufen(), 80.0, places=2)
svdn = slip._l10n_at_svdn_gesamt()
self.assertAlmostEqual(
slip._l10n_at_bemessung_laufen(),
2000.0 + 110.0 - svdn - 80.0, places=2)
def test_14_st_frei_jahrespools(self):
"""Jahres-Kumulative: Betriebsveranstaltung 365 €/J (Freibetrag)
und Sachzuwendung/Jubiläum 186 €/J als separate Pools
(§ 3 Abs 1 Z 14; lb-sac-07, lb-ges-02)."""
employee = self._employee()
self._regel('LFD', 2000.0)
self._regel('BV', 200.0, st_frei='betriebsveranstaltung')
slip_a = self._slip(employee, 1, confirm=True)
self.assertAlmostEqual(
slip_a._l10n_at_st_frei_freie_laufen(), 200.0, places=2)
# Februar: 165 € Rest (365 200) → 165 frei, 35 steuerpflichtig
slip_b = self._slip(employee, 2, confirm=True)
self.assertAlmostEqual(
slip_b._l10n_at_st_frei_freie_laufen(), 165.0, places=2)
# März: Pool verbraucht → 0 frei, 200 steuerpflichtig
slip_c = self._slip(employee, 3)
self.assertAlmostEqual(
slip_c._l10n_at_st_frei_freie_laufen(), 0.0, places=2)
# Sachzuwendung separater Pool: 300 € → 186 frei, 114 pflichtig
self._regel('GSC', 300.0, st_frei='jubilaeum')
slip_d = self._slip(employee, 4)
self.assertAlmostEqual(
slip_d._l10n_at_st_frei_freie_laufen(), 186.0, places=2)
def test_15_st_frei_homeoffice(self):
"""Homeoffice 3 €/Tag, max 100 Tage/Jahr (§ 26 Z 9): 70 Tage im
Jänner (210 frei), Februar nur 30 Tage frei (90), 40 Tage × 3 =
120 steuerpflichtig."""
employee = self._employee()
self._regel('LFD', 2000.0)
self._regel('HO', 3.0, quantity='70.0', st_frei='homeoffice')
slip_a = self._slip(employee, 1, confirm=True)
self.assertAlmostEqual(
slip_a._l10n_at_st_frei_freie_laufen(), 210.0, places=2)
# Februar: 30 Tage Rest (100 70) → 90 frei, 120 steuerpflichtig
slip_b = self._slip(employee, 2)
self.assertAlmostEqual(
slip_b._l10n_at_st_frei_freie_laufen(), 90.0, places=2)
def test_16_st_frei_neutral(self):
"""Ohne st_frei-Flag keine Wirkung auf die LSt-Bemessung
(Regression)."""
employee = self._employee()
self._regel('LFD', 2000.0)
slip = self._slip(employee, 1)
self.assertAlmostEqual(slip._l10n_at_st_frei_freie_laufen(), 0.0)
svdn = slip._l10n_at_svdn_gesamt()
self.assertAlmostEqual(
slip._l10n_at_bemessung_laufen(), 2000.0 - svdn, places=2)
def test_17_sv_art_entkoppelt_lst_art(self):
"""SV-Art folgt standardmäßig der LSt-Art; Drittlohn kann nur
SV-laufend sein, Hardware-Verkauf LSt-sonstig/SV-laufend."""
employee = self._employee()
self._regel('LFD', 100.0)
self._regel('DRITT', 300.0, bemessung='neutral', sv_art='laufend')
self._regel('HW', 200.0, bemessung='sonstig', sv_art='laufend')
slip = self._slip(employee, 1)
self.assertAlmostEqual(
slip._l10n_at_sv_brutto('laufend'), 600.0, places=2)
self.assertAlmostEqual(
slip._l10n_at_sv_brutto('sonstig'), 0.0, places=2)
self.assertAlmostEqual(slip._l10n_at_svdn_basis_sz(), 0.0, places=2)
self.assertAlmostEqual(
slip._l10n_at_brutto('laufend'), 100.0, places=2)
self.assertAlmostEqual(
slip._l10n_at_brutto('sonstig'), 200.0, places=2)
def test_18_sv_art_folgt_ist_neutral(self):
"""Der Default 'folgt' erhält bestehende laufende und sonstige
SV-Zuordnungen unverändert."""
employee = self._employee()
self._regel('LFD', 100.0)
self._regel('SZ', 200.0, bemessung='sonstig')
slip = self._slip(employee, 1)
self.assertAlmostEqual(
slip._l10n_at_sv_brutto('laufend'), 100.0, places=2)
self.assertAlmostEqual(
slip._l10n_at_sv_brutto('sonstig'), 200.0, places=2)
def test_19_sv_art_keine_nur_lst(self):
"""SV-Art 'keine' schließt eine Regel ausschließlich aus der
SV aus; die deklarierte LSt-Bemessung bleibt unverändert."""
employee = self._employee()
self._regel('LFD', 100.0)
self._regel('NUR_LST', 300.0, sv_art='keine')
slip = self._slip(employee, 1)
self.assertAlmostEqual(
slip._l10n_at_sv_brutto('laufend'), 100.0, places=2)
self.assertAlmostEqual(
slip._l10n_at_brutto('laufend'), 400.0, places=2)