[IMP] l10n_at_hr_payroll_private: GP3 payroll tax integration (DN/SV and LSt rules, 67/68 rule flags, tests)

This commit is contained in:
2026-09-11 22:24:03 +02:00
parent 547c48e456
commit 90e4bcaa5d
7 changed files with 472 additions and 32 deletions
@@ -1,7 +1,7 @@
# Part of the odoo-at-payroll project. License: LGPL-3.
{
'name': 'Österreich - Privatwirtschaft Personalverrechnung (General-AT)',
'version': '19.0.2.0.0',
'version': '19.0.3.0.0',
'author': 'ixsol / gem360',
'website': 'https://gem360.at',
'category': 'Human Resources/Payroll',
@@ -52,6 +52,7 @@ gem360-Brücke).
'data/work_entry_types_private.xml',
'data/input_types_private.xml',
'data/salary_rules_private.xml',
'data/salary_rules_lst_private.xml',
'views/kv_views.xml',
],
'countries': ['at'],
@@ -0,0 +1,107 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- ============================================================ -->
<!-- GP3: Dünne DN-Regeln für die privatwirtschaftliche Structure. -->
<!-- Logik/Werte vollständig im Kern (models/lohnsteuer.py + -->
<!-- rule_parameters_lst_2026.xml): hr.salary.rule.struct_id ist -->
<!-- required und rule_ids One2many — geteilte Kern-Regeln sind -->
<!-- mechanisch unmöglich; hier nur 1-Zeilen-Bindungen in die -->
<!-- lokale Abzugs-Kategorie (kein Wertefork, keine Logikfork). -->
<!-- ============================================================ -->
<!-- DN-SV (§ 62-Abzugsposten; laufende Basis, HBG-capped). -->
<record id="rule_svdn_kv" model="hr.salary.rule">
<field name="name">SV-DN Krankenversicherung (§ 62 Z 1 iVm § 16 Abs 3)</field>
<field name="code">SVDN_KV</field>
<field name="sequence" eval="60"/>
<field name="category_id" ref="l10n_at_hr_payroll_private.category_atp_abzug"/>
<field name="struct_id" ref="l10n_at_hr_payroll_private.structure_at_privat"/>
<field name="condition_select">python</field>
<field name="condition_python">result = payslip._l10n_at_svdn('kv', result_rules) > 0</field>
<field name="amount_select">code</field>
<field name="l10n_at_bemessung">neutral</field>
<field name="amount_python_compute"><![CDATA[
result = payslip._l10n_at_svdn('kv', result_rules)
]]></field>
</record>
<record id="rule_svdn_pv" model="hr.salary.rule">
<field name="name">SV-DN Pensionsversicherung (§ 62 Z 1 iVm § 16 Abs 3)</field>
<field name="code">SVDN_PV</field>
<field name="sequence" eval="61"/>
<field name="category_id" ref="l10n_at_hr_payroll_private.category_atp_abzug"/>
<field name="struct_id" ref="l10n_at_hr_payroll_private.structure_at_privat"/>
<field name="condition_select">python</field>
<field name="condition_python">result = payslip._l10n_at_svdn('pv', result_rules) > 0</field>
<field name="amount_select">code</field>
<field name="amount_python_compute"><![CDATA[
result = payslip._l10n_at_svdn('pv', result_rules)
]]></field>
</record>
<record id="rule_svdn_av" model="hr.salary.rule">
<field name="name">SV-DN Arbeitslosenversicherung (§ 62 Z 1 iVm § 16 Abs 3)</field>
<field name="code">SVDN_AV</field>
<field name="sequence" eval="62"/>
<field name="category_id" ref="l10n_at_hr_payroll_private.category_atp_abzug"/>
<field name="struct_id" ref="l10n_at_hr_payroll_private.structure_at_privat"/>
<field name="condition_select">python</field>
<field name="condition_python">result = payslip._l10n_at_svdn('av', result_rules) > 0</field>
<field name="amount_select">code</field>
<field name="amount_python_compute"><![CDATA[
result = payslip._l10n_at_svdn('av', result_rules)
]]></field>
</record>
<record id="rule_svdn_ak" model="hr.salary.rule">
<field name="name">AK-Umlage DN (§ 61 AKG; § 62 Z 3)</field>
<field name="code">SVDN_AK</field>
<field name="sequence" eval="63"/>
<field name="category_id" ref="l10n_at_hr_payroll_private.category_atp_abzug"/>
<field name="struct_id" ref="l10n_at_hr_payroll_private.structure_at_privat"/>
<field name="condition_select">python</field>
<field name="condition_python">result = payslip._l10n_at_svdn('ak', result_rules) > 0</field>
<field name="amount_select">code</field>
<field name="amount_python_compute"><![CDATA[
result = payslip._l10n_at_svdn('ak', result_rules)
]]></field>
</record>
<record id="rule_svdn_wf" model="hr.salary.rule">
<field name="name">WF-Beitrag DN (§ 62 Z 5; Wien 0,75 % via Company-Flag)</field>
<field name="code">SVDN_WF</field>
<field name="sequence" eval="64"/>
<field name="category_id" ref="l10n_at_hr_payroll_private.category_atp_abzug"/>
<field name="struct_id" ref="l10n_at_hr_payroll_private.structure_at_privat"/>
<field name="condition_select">python</field>
<field name="condition_python">result = payslip._l10n_at_svdn('wf', result_rules) > 0</field>
<field name="amount_select">code</field>
<field name="amount_python_compute"><![CDATA[
result = payslip._l10n_at_svdn('wf', result_rules)
]]></field>
</record>
<!-- Lohnsteuer: § 66 kumulativ (Rollung) + § 67 Fixsätze. -->
<record id="rule_lst_lfd" model="hr.salary.rule">
<field name="name">Lohnsteuer laufend (§ 66, kumulativ mit Rollung)</field>
<field name="code">LST_LFD</field>
<field name="sequence" eval="70"/>
<field name="category_id" ref="l10n_at_hr_payroll_private.category_atp_abzug"/>
<field name="struct_id" ref="l10n_at_hr_payroll_private.structure_at_privat"/>
<field name="condition_select">python</field>
<field name="condition_python">result = payslip._l10n_at_lst_laufend(result_rules) > 0</field>
<field name="amount_select">code</field>
<field name="amount_python_compute"><![CDATA[
result = payslip._l10n_at_lst_laufend(result_rules)
]]></field>
</record>
<record id="rule_lst_sz" model="hr.salary.rule">
<field name="name">Lohnsteuer sonstige Bezüge (§ 67 Fixsätze, Aufrollung)</field>
<field name="code">LST_SZ</field>
<field name="sequence" eval="71"/>
<field name="category_id" ref="l10n_at_hr_payroll_private.category_atp_abzug"/>
<field name="struct_id" ref="l10n_at_hr_payroll_private.structure_at_privat"/>
<field name="condition_select">python</field>
<field name="condition_python">result = payslip._l10n_at_lst_sonstige(result_rules) > 0</field>
<field name="amount_select">code</field>
<field name="amount_python_compute"><![CDATA[
result = payslip._l10n_at_lst_sonstige(result_rules)
]]></field>
</record>
</odoo>
@@ -19,6 +19,7 @@
<field name="struct_id" ref="l10n_at_hr_payroll_private.structure_at_privat"/>
<field name="condition_select">python</field>
<field name="condition_python">result = bool(employee.kv_id and employee.kv_gruppe_id and (employee.ueberzahlung or employee.erfahrungsstufe))</field>
<field name="l10n_at_bemessung">laufend</field>
<field name="amount_select">code</field>
<field name="amount_python_compute"><![CDATA[
# § 17 KV: Stufen-Mindestgrundgehalt zum Stichtag; individuelle
@@ -42,12 +43,16 @@ result = payslip._at_kv_gehalt_basis() * payslip._at_arbeitstage_faktor()
<field name="condition_select">python</field>
<field name="condition_python">result = payslip._at_nacht_sv_stunden() > 0</field>
<field name="amount_select">code</field>
<field name="l10n_at_bemessung">laufend</field>
<field name="l10n_at_steuerbefreiung_68">szfn</field>
<field name="l10n_at_zuschlagsanteil" eval="100.0"/>
<field name="amount_python_compute"><![CDATA[
# § 6 KV: €-Satz je Nachtstunde (226), unabhängig von Gehalt,
# Werk-/Sonn-/Feiertag und Zuschlagsstatus; Version je date_from
# (2748: 2,71 € ab 2026, 2,76 € ab 2027).
satz = float(employee.kv_id._wert('nacht_sonderverguetung', payslip.date_to))
result = payslip._at_nacht_sv_stunden() * satz
# (2748: 2,71 € ab 2026, 2,76 € ab 2027). § 68 Abs 1: SZFN-
# steuerfrei (Regel-Flag szfn, Anteil 100 %).
result = float(employee.kv_id._wert('nacht_sonderverguetung', payslip.date_to))
result_qty = payslip._at_nacht_sv_stunden()
]]></field>
</record>
@@ -62,14 +67,17 @@ result = payslip._at_nacht_sv_stunden() * satz
<field name="condition_select">python</field>
<field name="condition_python">result = 'ATP_UE_STD' in worked_days and worked_days['ATP_UE_STD'].number_of_hours > 0</field>
<field name="amount_select">code</field>
<field name="l10n_at_bemessung">laufend</field>
<field name="l10n_at_steuerbefreiung_68">ue_std</field>
<field name="l10n_at_zuschlagsanteil" eval="100.0 * 50.0 / 150.0"/>
<field name="amount_python_compute"><![CDATA[
# Grundstunde ÷ Teilungsfaktor ÜStd (§ 5 Abs 6 KV) + Zuschlag
# Tag (kv.wert ue_zuschlag_tag). Höchstzuschlag § 5 Abs 7 über
# die WET-Klassifikation (je Stunde genau ein Zuschlagstyp).
# Grundstunde ÷ Teilungsfaktor ÜStd (§ 5 Abs 6 KV) + Zuschlag Tag
# (kv.wert ue_zuschlag_tag). Pro Einheit (Stunde); Menge über
# result_qty (§ 68 Abs 2: erste ÜStd — Steuerfreiheit über Regel-Flag).
grund = payslip._at_grundstunde_ue()
zuschlag = float(employee.kv_id._wert('ue_zuschlag_tag', payslip.date_to))
stunden = worked_days['ATP_UE_STD'].number_of_hours
result = stunden * grund * (1 + zuschlag / 100.0)
result = grund * (1 + zuschlag / 100.0)
result_qty = worked_days['ATP_UE_STD'].number_of_hours
]]></field>
</record>
<record id="rule_ue_nacht" model="hr.salary.rule">
@@ -81,13 +89,17 @@ result = stunden * grund * (1 + zuschlag / 100.0)
<field name="condition_select">python</field>
<field name="condition_python">result = 'ATP_UE_NACHT' in worked_days and worked_days['ATP_UE_NACHT'].number_of_hours > 0</field>
<field name="amount_select">code</field>
<field name="l10n_at_bemessung">laufend</field>
<field name="l10n_at_steuerbefreiung_68">szfn</field>
<field name="l10n_at_zuschlagsanteil" eval="50.0"/>
<field name="amount_python_compute"><![CDATA[
# 100 %-Zuschlag-Fenster 206 (kv.wert ue_fenster_von/bis);
# hier der Anteil 226 (€-Sondervergütung läuft über NACHT_SV).
# § 68 Abs 1: SFN-Zuschläge steuerfrei bis 400 € (Regel-Flag szfn).
grund = payslip._at_grundstunde_ue()
zuschlag = float(employee.kv_id._wert('ue_zuschlag_nacht', payslip.date_to))
stunden = worked_days['ATP_UE_NACHT'].number_of_hours
result = stunden * grund * (1 + zuschlag / 100.0)
result = grund * (1 + zuschlag / 100.0)
result_qty = worked_days['ATP_UE_NACHT'].number_of_hours
]]></field>
</record>
<record id="rule_ue_nacht_frueh" model="hr.salary.rule">
@@ -99,13 +111,16 @@ result = stunden * grund * (1 + zuschlag / 100.0)
<field name="condition_select">python</field>
<field name="condition_python">result = 'ATP_UE_NACHT_FRUEH' in worked_days and worked_days['ATP_UE_NACHT_FRUEH'].number_of_hours > 0</field>
<field name="amount_select">code</field>
<field name="l10n_at_bemessung">laufend</field>
<field name="l10n_at_steuerbefreiung_68">szfn</field>
<field name="l10n_at_zuschlagsanteil" eval="50.0"/>
<field name="amount_python_compute"><![CDATA[
# Randfenster 2022 des 100 %-Zuschlags — keine €-Sondervergütung
# (Abgrenzung RF-Z6: Zuschlag ja, Nacht-SV nein).
grund = payslip._at_grundstunde_ue()
zuschlag = float(employee.kv_id._wert('ue_zuschlag_nacht', payslip.date_to))
stunden = worked_days['ATP_UE_NACHT_FRUEH'].number_of_hours
result = stunden * grund * (1 + zuschlag / 100.0)
result = grund * (1 + zuschlag / 100.0)
result_qty = worked_days['ATP_UE_NACHT_FRUEH'].number_of_hours
]]></field>
</record>
<record id="rule_ue_sofei" model="hr.salary.rule">
@@ -117,13 +132,16 @@ result = stunden * grund * (1 + zuschlag / 100.0)
<field name="condition_select">python</field>
<field name="condition_python">result = 'ATP_UE_SOFEI' in worked_days and worked_days['ATP_UE_SOFEI'].number_of_hours > 0</field>
<field name="amount_select">code</field>
<field name="l10n_at_bemessung">laufend</field>
<field name="l10n_at_steuerbefreiung_68">szfn</field>
<field name="l10n_at_zuschlagsanteil" eval="50.0"/>
<field name="amount_python_compute"><![CDATA[
# SonntagsüStd § 5 Abs 4, FeiertagsüStd ab erster Stunde § 5 Abs 5
# iVm ARG — Zuschlag je kv.wert so_feiertag_zuschlag.
grund = payslip._at_grundstunde_ue()
zuschlag = float(employee.kv_id._wert('so_feiertag_zuschlag', payslip.date_to))
stunden = worked_days['ATP_UE_SOFEI'].number_of_hours
result = stunden * grund * (1 + zuschlag / 100.0)
result = grund * (1 + zuschlag / 100.0)
result_qty = worked_days['ATP_UE_SOFEI'].number_of_hours
]]></field>
</record>
<record id="rule_ue_sofei_nacht" model="hr.salary.rule">
@@ -135,14 +153,17 @@ result = stunden * grund * (1 + zuschlag / 100.0)
<field name="condition_select">python</field>
<field name="condition_python">result = 'ATP_UE_SOFEI_NACHT' in worked_days and worked_days['ATP_UE_SOFEI_NACHT'].number_of_hours > 0</field>
<field name="amount_select">code</field>
<field name="l10n_at_bemessung">laufend</field>
<field name="l10n_at_steuerbefreiung_68">szfn</field>
<field name="l10n_at_zuschlagsanteil" eval="50.0"/>
<field name="amount_python_compute"><![CDATA[
# Zuschlagskumulation So/Fei × Nacht: nur der höchste Zuschlag
# (§ 5 Abs 7) = 100 % einmal (Höchstzuschlag über WET-Klasse);
# €-Sondervergütung separat über NACHT_SV.
grund = payslip._at_grundstunde_ue()
zuschlag = float(employee.kv_id._wert('so_feiertag_zuschlag', payslip.date_to))
stunden = worked_days['ATP_UE_SOFEI_NACHT'].number_of_hours
result = stunden * grund * (1 + zuschlag / 100.0)
result = grund * (1 + zuschlag / 100.0)
result_qty = worked_days['ATP_UE_SOFEI_NACHT'].number_of_hours
]]></field>
</record>
@@ -159,15 +180,18 @@ result = stunden * grund * (1 + zuschlag / 100.0)
result = 'ATP_MEHR_VZ' in worked_days and worked_days['ATP_MEHR_VZ'].number_of_hours > 0 and bool(employee.kv_id._wert('mehrarbeit_zuschlag', payslip.date_to, raise_if_not_found=False))
]]></field>
<field name="amount_select">code</field>
<field name="l10n_at_bemessung">laufend</field>
<field name="l10n_at_steuerbefreiung_68">ue_std</field>
<field name="l10n_at_zuschlagsanteil" eval="100.0 * 50.0 / 150.0"/>
<field name="amount_python_compute"><![CDATA[
# § 4a KV: Zuschlag auf die Normalstunde (Basis ÷ Teilungsfaktor
# Normalstunde, 2748: ÷ 167); zählt nicht gegen das ÜStd-Ausmaß;
# Stunden mit > 50 %-Zuschlag sind ÜStd (§ 4a letzter Satz —
# Klassifikation stromauf).
# Normalstunde, 2748: ÷ 167); zählt nicht gegen das ÜStd-Ausmaß.
# LSt: Differenzstunden-Zuschläge abgabenrechtlich wie ÜStd-Zuschläge
# (LStR Rz 1146) → § 68 Abs 2-Bucket (Regel-Flag ue_std).
grund = payslip._at_grundstunde_norm()
zuschlag = float(employee.kv_id._wert('mehrarbeit_zuschlag', payslip.date_to))
stunden = worked_days['ATP_MEHR_VZ'].number_of_hours
result = stunden * grund * (1 + zuschlag / 100.0)
result = grund * (1 + zuschlag / 100.0)
result_qty = worked_days['ATP_MEHR_VZ'].number_of_hours
]]></field>
</record>
<record id="rule_mehr_vz_nacht" model="hr.salary.rule">
@@ -181,11 +205,14 @@ result = stunden * grund * (1 + zuschlag / 100.0)
result = 'ATP_MEHR_VZ_NACHT' in worked_days and worked_days['ATP_MEHR_VZ_NACHT'].number_of_hours > 0 and bool(employee.kv_id._wert('mehrarbeit_zuschlag', payslip.date_to, raise_if_not_found=False))
]]></field>
<field name="amount_select">code</field>
<field name="l10n_at_bemessung">laufend</field>
<field name="l10n_at_steuerbefreiung_68">ue_std</field>
<field name="l10n_at_zuschlagsanteil" eval="100.0 * 50.0 / 150.0"/>
<field name="amount_python_compute"><![CDATA[
grund = payslip._at_grundstunde_norm()
zuschlag = float(employee.kv_id._wert('mehrarbeit_zuschlag', payslip.date_to))
stunden = worked_days['ATP_MEHR_VZ_NACHT'].number_of_hours
result = stunden * grund * (1 + zuschlag / 100.0)
result = grund * (1 + zuschlag / 100.0)
result_qty = worked_days['ATP_MEHR_VZ_NACHT'].number_of_hours
]]></field>
</record>
@@ -201,15 +228,18 @@ result = stunden * grund * (1 + zuschlag / 100.0)
<field name="condition_select">python</field>
<field name="condition_python">result = 'ATP_MEHR_TZ' in worked_days and worked_days['ATP_MEHR_TZ'].number_of_hours > 0</field>
<field name="amount_select">code</field>
<field name="l10n_at_bemessung">laufend</field>
<field name="amount_python_compute"><![CDATA[
# § 19d AZG: Stunden über Teilzeit-Ausmaß bis Vollzeit-NAZ;
# Zuschlag 25 % gesetzlich (Kern-Parameter), KV kann besser
# stellen (kv.wert-Code mehrarbeit_teilzeit_zuschlag).
# LSt: Teilzeit-Mehrarbeitszuschläge voll steuerpflichtig
# (kein §-68-Freibetrag — Regel-Flag keine).
grund = payslip._at_grundstunde_norm()
kv_zuschlag = employee.kv_id._wert('mehrarbeit_teilzeit_zuschlag', payslip.date_to, raise_if_not_found=False)
zuschlag = float(kv_zuschlag) if kv_zuschlag else float(payslip._rule_parameter('at_mehrarbeit_teilzeit_zuschlag'))
stunden = worked_days['ATP_MEHR_TZ'].number_of_hours
result = stunden * grund * (1 + zuschlag / 100.0)
result = grund * (1 + zuschlag / 100.0)
result_qty = worked_days['ATP_MEHR_TZ'].number_of_hours
]]></field>
</record>
<record id="rule_mehr_tz_nacht" model="hr.salary.rule">
@@ -221,12 +251,13 @@ result = stunden * grund * (1 + zuschlag / 100.0)
<field name="condition_select">python</field>
<field name="condition_python">result = 'ATP_MEHR_TZ_NACHT' in worked_days and worked_days['ATP_MEHR_TZ_NACHT'].number_of_hours > 0</field>
<field name="amount_select">code</field>
<field name="l10n_at_bemessung">laufend</field>
<field name="amount_python_compute"><![CDATA[
grund = payslip._at_grundstunde_norm()
kv_zuschlag = employee.kv_id._wert('mehrarbeit_teilzeit_zuschlag', payslip.date_to, raise_if_not_found=False)
zuschlag = float(kv_zuschlag) if kv_zuschlag else float(payslip._rule_parameter('at_mehrarbeit_teilzeit_zuschlag'))
stunden = worked_days['ATP_MEHR_TZ_NACHT'].number_of_hours
result = stunden * grund * (1 + zuschlag / 100.0)
result = grund * (1 + zuschlag / 100.0)
result_qty = worked_days['ATP_MEHR_TZ_NACHT'].number_of_hours
]]></field>
</record>
@@ -243,6 +274,7 @@ result = stunden * grund * (1 + zuschlag / 100.0)
<field name="condition_python"><![CDATA[
result = payslip._at_urlaub_ue_avg()[0] and any(wd.work_entry_type_id.is_leave for wd in payslip.worked_days_line_ids)
]]></field>
<field name="l10n_at_bemessung">laufend</field>
<field name="amount_select">code</field>
<field name="amount_python_compute"><![CDATA[
# § 6 UrlG Abs 2: laufendes (monatsweises) Entgelt bleibt
@@ -265,6 +297,7 @@ result = avg * urlaubstage / arbeitstage if arbeitstage and regelmaessig else 0.
<field name="struct_id" ref="l10n_at_hr_payroll_private.structure_at_privat"/>
<field name="condition_select">python</field>
<field name="condition_python">result = 'ATP_PRAMIE' in inputs and inputs['ATP_PRAMIE'].amount</field>
<field name="l10n_at_bemessung">laufend</field>
<field name="amount_select">code</field>
<field name="amount_python_compute"><![CDATA[
result = inputs['ATP_PRAMIE'].amount
@@ -281,6 +314,7 @@ result = inputs['ATP_PRAMIE'].amount
<field name="struct_id" ref="l10n_at_hr_payroll_private.structure_at_privat"/>
<field name="condition_select">python</field>
<field name="condition_python">result = payslip._at_b13_faellig()</field>
<field name="l10n_at_bemessung">sonstig</field>
<field name="amount_select">code</field>
<field name="amount_python_compute"><![CDATA[
# Basis je kv.wert sz13_basis_monat: 'auszahlung' (2203) =
@@ -301,6 +335,7 @@ result = payslip._at_sz_basis(basis_datum) * payslip._at_sz_dienstzeit_faktor()
<field name="struct_id" ref="l10n_at_hr_payroll_private.structure_at_privat"/>
<field name="condition_select">python</field>
<field name="condition_python">result = payslip._at_b14_faellig()</field>
<field name="l10n_at_bemessung">sonstig</field>
<field name="amount_select">code</field>
<field name="amount_python_compute"><![CDATA[
# Fälligkeit § 11 Abs 3: bei Urlaubsantritt (R1: erstfälliger
@@ -322,6 +357,7 @@ result = payslip._at_sz_basis(payslip.date_to) * payslip._at_sz_dienstzeit_fakto
austritt = version.contract_date_end and payslip.date_from <= version.contract_date_end <= payslip.date_to
result = bool(austritt and payslip._at_sz_bereits_bezahlt('B14'))
]]></field>
<field name="l10n_at_bemessung">sonstig</field>
<field name="amount_select">code</field>
<field name="amount_python_compute"><![CDATA[
# § 11 Abs 4, 2. Satz: bei Austritt nach vollem B14-Bezug ist
@@ -0,0 +1,101 @@
# Rechenfälle Lohnsteuer (GP3) — Kern + privatwirtschaftliche Structure
**Quellen (✅ P0-GP3 2026-09-11):** EStG-Volltext RIS-Fassung
10. 09. 2026 (lokal `.firecrawl/ris/`); RECHTSQUELLEN-Privat
Abschnitt 5 (§-Anker je Wert). Kern: `l10n_at_hr_payroll`
(`models/lohnsteuer.py`, `data/rule_parameters_lst_2026.xml`);
privat: dünne Regeln `SVDN_*`/`LST_LFD`/`LST_SZ`.
## Systematik (user-Entscheidungen 2026-09-11)
- **Lohnsteuertage:** 30 je vollem Monat; Teilmonat = Kalendertage
der Beschäftigung inkl. arbeitsfreier Tage (§ 66 Abs 3), max 30.
- **Bemessung laufend (user-Formel):** Arbeitslohn SV-DN
(KV/PV/ALV + AK § 62 Z 3 + WF § 62 Z 5) steuerfreie Bezüge § 68.
- **Bemessung SZ:** Bezug SV-DN(SZ) (§ 67 Abs 12) — dann
0 % bis 620 € (**jahreskumulativ**), 6 % bis Jahressechstel,
darüber LSt-lfd (§ 67 Abs 10); Sechstel ≤ 2.615 € → SZ
tarifisch (Pauschalierung unterbleibt).
- **§ 66:** JBG = Bemessung × Faktor 132 € (Werbe-) 60 €
(SA-Pauschbetrag); Steuer = Tarif(JBG) Absetzbeträge
(Verkehrs-AB 496 €, AVB/AVAB 612/828/+273); ÷ Faktor —
kumulativ mit Rollung (YTD bereits einbehaltene LSt).
## RF-L1: Vollmonat November mit B13 (VG II Stufe 1)
- KVGEO 2.059,41 €; SVDN = 18,07 % × 2.059,41 = **372,91 €**
(KV 79,70 · PV 211,08 · ALV 60,75 · AK 10,30 · WF 10,30).
- Bemessung laufend = 2.059,41 372,91 = **1.686,50 €**;
JBG = 1.686,50 × 12 192 = 20.046,06 → Tarif 20 % × 6.507,06 =
1.301,41 496 = 805,41 ÷ 12 → **LST_LFD 67,12 €**.
- B13: SV-SZ = 2.059,41 × 17,07 % = **351,54 €**; Bemessung
1.707,87; Sechstel = 2.059,41 × 60/30 = 4.118,82 > 2.615 ✓
pauschal → LST_SZ = (1.707,87 620) × 6 % = **65,27 €**.
## RF-L2: Kumulative Rollung (Zuflussprinzip)
- Januar LST_LFD 67,12; Februar: JBG unverändert → Soll kumuliert
134,24 bereits 67,12 = **67,12** — Korrekturen Vormonat
(Nachzahlung, Gehaltsänderung) rollen automatisch in den
Folgelauf (§ 66 kumulativ = Rollungsprinzip § 77 Abs 3).
## RF-L3: Günstig-Aufrollung bei zweiter SZ
- Okt (nur laufend), Nov B13 (LST_SZ 65,27 bezahlt), Dez B14
(Dezember-Fallback ohne Urlaub): Jahres-SZ-Bemessung
2 × 1.707,87 = 3.415,74 ≤ Sechstel 4.118,82 → Soll-Jahr
(3.415,74 620) × 6 % = 167,74 65,27 = **LST_SZ(Dez) 102,47 €**
(Neuberechnung bei jeder SZ-Zahlung, lb-son-04).
## RF-L4: Sechstel-Freigrenze (2.615 €) — SZ tarifisch
- Laufend 500 €/Monat: Sechstel = 500 × 60/30 = 1.000 ≤ 2.615 →
B14 läuft tarifisch über LST_LFD (LST_SZ = 0).
## RF-L5: Sechstel-Überschuss (§ 67 Abs 10)
- Laufend 2.000 €/Monat (Sechstel 4.000), SZ 6.000 €:
SV-SZ = 6.000 × 17,07 % = 1.024,20 → Bemessung 4.975,80;
4.000 im Sechstel → Fixsteuer (4.000 620) × 6 % = **202,80 €**;
Überschuss 975,80 läuft dem laufenden Arbeitslohn des
Auszahlungsmonats zu (LSt-lfd-Kette).
## RF-L6: § 68-Freibeträge (Buckets über Regel-Flags)
- **SZFN (Abs 1):** Zuschlagsanteile der 100 %-Zuschläge +
Nacht-€-Sondervergütung bis 400 € (600 € bei überwiegender
Nachtarbeit — Vertrags-Flag) steuerfrei.
- **ÜStd (Abs 2 iVm § 124b Z 440 idF BGBl I 4/2026):** 50 %-Klasse
(UE_STD, MEHR_VZ-Differenzstunden, LStR Rz 1146): erste 15 ÜStd,
max 170 € (2026; ab 2027 10/120 — versioniert). Beispiel:
2 ÜStd à 13,73 € + 50 % Zuschlag = 41,19 → davon 33,33 % = 13,73 €
steuerfrei. Teilzeit-Mehrarbeit § 19d: **kein** Freibetrag
(voll steuerpflichtig).
- Feiertagsentgelt: § 124b Z 492/Z 494 (idF BGBl I 43/2026) —
Wirkungsabgrenzung GP6/Feiertagsengine.
## RF-L7: Absetzbeträge 2026 (Reform — AN-AB entfallen)
- Verkehrsabsetzbetrag **496 €** (Pendler-Variante 853 € bei
Einkommen ≤ 15.069 — R1-Option mit Pendlerdaten; Zuschlag 804 €
nur Veranlagung).
- AVB/AVAB: Kind 1 **612 €**, 2 Kinder **828 €**, je weiteres
**+273 €** (Vertrags-Feld + Kinderzahl; AVB-Partnergrenze
7.411 € mandantenseitig nachgewiesen).
- Arbeitnehmerabsetzbetrag: **entfallen** (im Tarif integriert —
Steuerreform; die historische 60 %-Werbungskosten-Regelung war
ohnehin nur Veranlagungsmechanik: monatlich liefen SV-Beiträge
stets voll über § 62 Z 1 iVm § 16 Abs 3).
## RF-L8: Nachzahlungs-/Aufrollungs-Workflow (GP3+GP8)
- Unterjährige Korrektur = Nachzahlungslinie im Zuflussmonat —
kumulative Engine bucht die Differenz automatisch (kein Storno).
- Echter Datenfehler: Odoo-Refund + Neuberechnung (YTD-Helfer
refundbewusst).
- **SV je Beitragsmonat (Anspruchsprinzip)** mit historischen
versionierten Sätzen/HBG + **Nachtrags-mBGM** (Storno +
Neumeldung): GP4 (Allokation) + GP7 (ELDA-Export).
- 15.2.-Cutoff (Vorjahres-L16 + berichtigter Lohnzettel),
Fünftelregelung § 67 Abs 8 lit c (`_l10n_at_fuenftelregelung`),
Willkür→Tarif: Datenmodell + Utility in GP3, Vollausbau GP8.
@@ -1,3 +1,4 @@
# Part of the odoo-at-payroll project. License: LGPL-3.
from . import test_kv_katalog
from . import test_entgelt_private
from . import test_lst_private
@@ -368,8 +368,9 @@ class EntgeltPrivateTest(TransactionCase):
# ------------------------------------------------------------------
# Netto / All-in
# ------------------------------------------------------------------
def test_20_netto_ohne_abzuege(self):
"""R1: Netto = Brutto-Kategorien (AP3/AP4 folgen)."""
def test_20_netto_mit_dn_abzuegen(self):
"""GP3: Netto = ATP_BRUTTO ATP_ABZUG — SVDN + LSt laufen
real mit (AP3-Kern angebunden)."""
employee = self._employee(stufe=1)
slip = self._compute(employee, 11)
brutto = sum(
@@ -379,7 +380,12 @@ class EntgeltPrivateTest(TransactionCase):
'MEHR_VZ', 'MEHR_VZ_NACHT', 'MEHR_TZ',
'MEHR_TZ_NACHT', 'URL_UE', 'PRAM', 'B13', 'B14',
'B14_RUECK'))
self.assertAlmostEqual(self._total(slip, 'NETTO'), brutto, places=2)
abzug = sum(
self._total(slip, code)
for code in ('SVDN_KV', 'SVDN_PV', 'SVDN_AV', 'SVDN_AK',
'SVDN_WF', 'LST_LFD', 'LST_SZ'))
self.assertAlmostEqual(
self._total(slip, 'NETTO'), brutto - abzug, places=2)
def test_21_allin_deckungsrechnung(self):
"""§ 2g AVRAG: Jahresdeckung = 12 × Pauschale vs. geschuldete
@@ -0,0 +1,188 @@
# Part of the odoo-at-payroll project. License: LGPL-3.
"""GP3 Lohnsteuer-Integration auf der privatwirtschaftlichen
Structure: End-to-End-Abrechnung mit SVDN/LST-Regeln, B13 als
§-67-Bezug, Rollung, Günstig-Aufrollung, § 68-Freibetrag."""
from datetime import date, datetime, timedelta
from odoo.tests import TransactionCase, tagged
@tagged('post_install', '-at_install')
class LstPrivateTest(TransactionCase):
def setUp(self):
super().setUp()
self.structure = self.env.ref(
'l10n_at_hr_payroll_private.structure_at_privat')
self.kv2203 = self.env.ref(
'l10n_at_hr_payroll_private.kv_si_2203')
self.gruppe_ii = self.env.ref(
'l10n_at_hr_payroll_private.kv2203_grp_ii')
self.calendar = self.env['resource.calendar'].create({
'name': 'AT Test 40h Mo-Fr',
'tz': 'Europe/Vienna',
'hours_per_day': 8,
'attendance_ids': [
(0, 0, {'name': 'Tag %d' % wd, 'dayofweek': str(wd),
'hour_from': 8.0, 'hour_to': 16.0})
for wd in range(5)
],
})
self.env.company.resource_calendar_id = self.calendar
def _employee(self):
employee = self.env['hr.employee'].create({
'name': 'Test LSt privat',
})
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),
'kv_id': self.kv2203.id,
'kv_gruppe_id': self.gruppe_ii.id,
'erfahrungsstufe': 1,
'resource_calendar_id': self.calendar.id,
})
return employee
def _slip(self, employee, monat, work_entries=(), confirm=False):
erster = date(2026, monat, 1)
naechster = (erster + timedelta(days=32)).replace(day=1)
slip = self.env['hr.payslip'].create({
'name': 'Test-LSt-Privat %d' % monat,
'employee_id': employee.id,
'struct_id': self.structure.id,
'date_from': erster,
'date_to': naechster - timedelta(days=1),
})
for wet, tag, stunden in work_entries:
self.env['hr.work.entry'].create({
'name': 'Test-Eintrag',
'version_id': slip.version_id.id,
'employee_id': slip.employee_id.id,
'work_entry_type_id': wet.id,
'date': tag,
'duration': stunden,
})
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'))
def test_01_end_to_end_november_mit_b13(self):
"""Vollmonat VG II Stufe 1 mit B13: SVDN 18,07 %, LSt-lfd
(§ 66: JBG = Bemessung × 12 192), B13 nach § 67 (Abs 12-SV,
620-Freibetrag, 6 %)."""
employee = self._employee()
slip = self._slip(employee, 11)
self.assertAlmostEqual(self._total(slip, 'KVGEO'), 2059.41, places=2)
self.assertAlmostEqual(
self._total(slip, 'SVDN_KV'), 2059.41 * 0.0387, places=2)
self.assertAlmostEqual(
self._total(slip, 'SVDN_PV'), 2059.41 * 0.1025, places=2)
self.assertAlmostEqual(
self._total(slip, 'SVDN_AV'), 2059.41 * 0.0295, places=2)
self.assertAlmostEqual(
self._total(slip, 'SVDN_AK'), 2059.41 * 0.005, places=2)
self.assertAlmostEqual(
self._total(slip, 'SVDN_WF'), 2059.41 * 0.005, places=2)
self.assertAlmostEqual(self._total(slip, 'B13'), 2059.41, places=2)
# LSt-lfd: Bemessung 1686,50 → JBG 20.046,06 → (1301,41 496)/12
bem = 2059.41 - 2059.41 * 0.1807
jbg = bem * 12 - 132 - 60
erwartet_lfd = (max(0.0, 0.20 * (jbg - 13539.0) - 496.0)) / 12
self.assertAlmostEqual(
self._total(slip, 'LST_LFD'), erwartet_lfd, places=1)
# LSt-SZ (§ 67): 2059,41 SV-SZ (17,07 %) 620 → 6 %
sz_bem = 2059.41 - 2059.41 * 0.1707
erwartet_sz = (sz_bem - 620.0) * 0.06
self.assertAlmostEqual(
self._total(slip, 'LST_SZ'), erwartet_sz, places=1)
# Netto-Konsistenz: ATP_BRUTTO ATP_ABZUG
brutto = (self._total(slip, 'KVGEO') + self._total(slip, 'B13'))
abzug = sum(
self._total(slip, code) for code in
('SVDN_KV', 'SVDN_PV', 'SVDN_AV', 'SVDN_AK', 'SVDN_WF',
'LST_LFD', 'LST_SZ'))
self.assertAlmostEqual(
self._total(slip, 'NETTO'), brutto - abzug, places=2)
def test_02_rollung_zweiter_monat(self):
"""Kumulative §-66-Rollung: Februar zahlt die Differenz."""
employee = self._employee()
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')
self.assertAlmostEqual(feb, jan, places=2)
def test_03_guenstig_aufrollung_b14(self):
"""B13 November + B14 Dezember (ohne Urlaub): zweite SZ rechnet
die Jahres-SZ-Steuer neu (Günstig-Aufrollung, lb-son-04)."""
employee = self._employee()
self._slip(employee, 10, confirm=True)
slip_nov = self._slip(employee, 11, confirm=True)
sz_nov = self._total(slip_nov, 'LST_SZ')
sz_bem = 2059.41 - 2059.41 * 0.1707
self.assertAlmostEqual(sz_nov, (sz_bem - 620.0) * 0.06, places=1)
slip_dez = self._slip(employee, 12)
self.assertAlmostEqual(
self._total(slip_dez, 'B14'), 2059.41, places=2)
soll_jahr = (2 * sz_bem - 620.0) * 0.06
self.assertAlmostEqual(
self._total(slip_dez, 'LST_SZ'), soll_jahr - sz_nov, places=1)
def test_04_ue_std_68_freibetrag(self):
"""2 ÜStd Tag: UE_STD 41,19 (quantity=Stunden); §-68-Anteil
13,73 mindert die LSt-Bemessung (§ 68 Abs 2 iVm § 124b Z 440)."""
employee = self._employee()
wet = self.env.ref('l10n_at_hr_payroll_private.wett_ue_std')
slip_ohne = self._slip(employee, 10, confirm=True)
lst_ohne = self._total(slip_ohne, 'LST_LFD')
slip_mit = self._slip(employee, 11, work_entries=[
(wet, date(2026, 11, 5), 2.0),
])
self.assertAlmostEqual(self._total(slip_mit, 'UE_STD'), 41.19, places=2)
freibe = slip_mit._l10n_at_freibetraege_68()
self.assertAlmostEqual(freibe['ue_std'], 41.19 * 0.33333, places=1)
# LSt steigt (nur der nicht befreite Anteil wird besteuert)
self.assertGreater(self._total(slip_mit, 'LST_LFD'), lst_ohne)
def test_05_wien_wf(self):
"""Wiener WF-Zuschlag: SVDN_WF 0,75 % statt 0,50 %."""
employee = self._employee()
slip = self._slip(employee, 11)
slip.company_id.write({'l10n_at_wf_wien': True})
slip.compute_sheet()
self.assertAlmostEqual(
self._total(slip, 'SVDN_WF'), 2059.41 * 0.0075, places=2)
def test_06_2748_november_basis_b13_dezember(self):
"""2748: B13 im Dezember mit November-Basis; B14-Dezember-
fallback ohnt Urlaub läuft mit — §-67-Bemessung über beide
Bezüge (Abs 12-SV vorher)."""
employee = self._employee()
version = self.env['hr.version'].search(
[('employee_id', '=', employee.id)], limit=1)
version.write({
'kv_id': self.env.ref(
'l10n_at_hr_payroll_private.kv_si_2748').id,
'kv_gruppe_id': self.env.ref(
'l10n_at_hr_payroll_private.kv2748_grp_ii').id,
})
slip = self._slip(employee, 12)
self.assertAlmostEqual(self._total(slip, 'KVGEO'), 2199.15, places=2)
self.assertAlmostEqual(self._total(slip, 'B13'), 2199.15, places=2)
# B14-Dezember-Fallback (kein Urlaub im Jahr) — läuft mit
self.assertAlmostEqual(self._total(slip, 'B14'), 2199.15, places=2)
sz_bem = 2 * 2199.15 - 2 * 2199.15 * 0.1707
self.assertAlmostEqual(
self._total(slip, 'LST_SZ'), (sz_bem - 620.0) * 0.06, places=1)