mirror of
http://100.103.83.12:3003/fegger/odoo-at-payroll.git
synced 2026-09-17 16:56:42 +00:00
a2a823ad35
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.
285 lines
13 KiB
Python
285 lines
13 KiB
Python
# 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.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) |