# 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.000−21.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)