[IMP] l10n_at_hr_payroll: GP3 Lohnsteuer-Kern (tariff, cumulative monthly tax, 67 sechstel engine, 68 buckets) + TASY runtime fixes

This commit is contained in:
2026-09-11 22:24:03 +02:00
parent 51ff857414
commit 547c48e456
8 changed files with 833 additions and 11 deletions
+2 -1
View File
@@ -1,2 +1,3 @@
# Part of the odoo-at-payroll project. License: LGPL-3.
from . import test_tasy_import
from . import test_tasy_import
from . import test_lohnsteuer
@@ -0,0 +1,284 @@
# 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 18,07 % (KV/PV/AV/AK/WF) auf laufender Basis; Bemessung
= Arbeitslohn SV-DN (user-Formel)."""
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.1807, 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.1707 # KV+PV+AV auf SZ-Basis
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 × 17,07 % = 351,54 (KV+PV+AV auf SZ-Basis)
self.assertAlmostEqual(
sz_nov, (2059.41 * (1 - 0.1707) - 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.1707)
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 (1.024,20)
self.assertAlmostEqual(sz_bem, 6000.0 - 6000.0 * 0.1707, 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)
@@ -106,22 +106,22 @@ class TasyImportTest(TransactionCase):
parameter = self.env['hr.rule.parameter']
stichtag = date(2026, 7, 1)
self.assertEqual(
float(parameter._get_parameter_from_code('at_sv_kv_dn', stichtag).parameter_value),
float(parameter._get_parameter_from_code('at_sv_kv_dn', stichtag)),
3.87)
self.assertEqual(
float(parameter._get_parameter_from_code('at_sv_kv_dg', stichtag).parameter_value),
float(parameter._get_parameter_from_code('at_sv_kv_dg', stichtag)),
3.78)
self.assertEqual(
float(parameter._get_parameter_from_code('at_sv_gfg_monatlich', stichtag).parameter_value),
float(parameter._get_parameter_from_code('at_sv_gfg_monatlich', stichtag)),
551.10)
self.assertEqual(
float(parameter._get_parameter_from_code('at_sv_hbg_monatlich', stichtag).parameter_value),
float(parameter._get_parameter_from_code('at_sv_hbg_monatlich', stichtag)),
6930.00)
self.assertEqual(
float(parameter._get_parameter_from_code('at_sv_uv_dg', stichtag).parameter_value),
float(parameter._get_parameter_from_code('at_sv_uv_dg', stichtag)),
1.10)
self.assertEqual(
float(parameter._get_parameter_from_code('at_sv_iesg_dg', stichtag).parameter_value),
float(parameter._get_parameter_from_code('at_sv_iesg_dg', stichtag)),
0.10)
def test_02_wizard_parse_fixture(self):