Files
odoo-at-payroll/addons/l10n_at_hr_payroll/tests/test_lohnsteuer.py
T
fegger 4b86bcb790 [IMP] l10n_at_hr_payroll_private: add voluntary severance under § 67 Abs 6 (AP15-B)
Split the voluntary severance into its privileged part and a tariff remainder on the exit run: the privileged part combines the quarter rule (1/4 of the last twelve months' running pay, capped at nine times the monthly HBG) with the twelfth rule (2/12 to 12/12 by proven service, capped at n × 3 × HBG), reduced per Z 3 by severance already received and the statutory claim due at this termination, all taxed at the flat 6 % rate. The excess runs as its own rule flagged l10n_at_s67_abs6_ueberhang: taxed with the running tariff of the payment month but excluded from the annual sixth, since Z 5 exempts only the Z 1/Z 2 amounts and the excess is a special payment, not running pay. The twelve-month basis aggregates validated prior runs plus own lines, excluding the flagged excess and § 26 Z 4 travel overhang via a dedicated rule flag. Free of SV (§ 49 Abs 3 Z 7 ASVG), payroll levies regardless of the 6 %/tarif split (§ 41 Abs 4 lit b FLAG, § 122 Abs 8 WKG, § 5 Abs 2 lit b KommStG) and BV contributions; the former BV-Anwartschaft exclusion Z 7 is repealed (BGBl I 118/2015). The wizard proposes the rolling basis and the statutory claim, previews Z 1/Z 2/reduction/privileged/excess/tax and requires explicit confirmation against claim-conversion risk. The § 291d EO termination pot nets both severance wage-tax rules; key 260 now includes LST_ABF_FREI. Verified against the original statute text in .ris/ (EStG § 67 Abs 6) and the knowledge-base sources lb-end-12/13/15; KZ positioning of Abs-6 amounts and the excess's § 291d pot allocation remain documented verification points against LStR Rz 1087 ff.

Version 19.0.14.0.0 (private), 19.0.10.0.0 (core)
2026-09-15 11:34:09 +02:00

421 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.
"""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)
# ------------------------------------------------------------------
# § 67 Abs 6 Z 6 — Überhang (tarifisch, ohne Sechstel-Effekt)
# ------------------------------------------------------------------
def test_20_abs6_ueberhang_ohne_sechstel(self):
"""Der gekennzeichnete Überhang läuft tarifisch in der laufenden
Bemessung, erhöht aber das Jahressechstel nicht."""
employee = self._employee()
slip_ohne = self._slip(employee, 1)
regel = self._regel('T_ABS6_UEB', 1000.0, sv_art='keine')
regel.l10n_at_s67_abs6_ueberhang = True
slip_mit = self._slip(employee, 2)
self.assertAlmostEqual(
slip_mit._l10n_at_abs6_ueberhang_total(), 1000.0, places=2)
self.assertAlmostEqual(
slip_mit._l10n_at_bemessung_laufen()
- slip_ohne._l10n_at_bemessung_laufen(), 1000.0, places=2)
self.assertAlmostEqual(
slip_mit._l10n_at_sechstel() - slip_ohne._l10n_at_sechstel(),
0.0, places=2)
# ------------------------------------------------------------------
# § 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)