diff --git a/addons/l10n_at_hr_payroll/__manifest__.py b/addons/l10n_at_hr_payroll/__manifest__.py
index 09d1f9d..630f1c4 100644
--- a/addons/l10n_at_hr_payroll/__manifest__.py
+++ b/addons/l10n_at_hr_payroll/__manifest__.py
@@ -1,7 +1,7 @@
# Part of the odoo-at-payroll project. License: LGPL-3.
{
'name': 'Österreich - Personalverrechnung Kern (AT Payroll Core)',
- 'version': '19.0.2.0.0',
+ 'version': '19.0.3.0.0',
'author': 'ixsol',
'website': 'https://gem360.at',
'category': 'Human Resources/Payroll',
@@ -19,10 +19,21 @@ Produkt (``l10n_at_hr_payroll_private``). Grundlagen: ``personalverrechnung/``
generiert und gegen den offiziellen SV-Werte-Report gespiegelt):
KV/PV/UV/AV/AK/WF/BV/IESG-Beitragssätze, Geringfügigkeitsgrenze,
Höchstbeitragsgrundlagen (monatlich/SZ), AV-Staffel § 2a AMPFG;
+- **Lohnsteuer (AP3, GP3)**: ``models/lohnsteuer.py`` (§ 66 kumulativ mit
+ Rollung, § 67 Sechstel/Fixsätze, § 68-Steuerfreiheiten),
+ ``rule_parameters_lst_2026.xml``;
+- **SV/DB (AP4, GP4)**: ``models/sozialversicherung.py`` — DG-Beiträge
+ (SVDG, ÖGK 2026: 20,98 %), Geringfügigkeit (§ 5 Abs 2 iVm § 810 Abs 3
+ ASVG) + § 53a-UV, beitragsfreie Bezüge § 49 Abs 3 mit Jahres-
+ Kumulativen, altersabhängige Satzausschaltungen (AlVG § 1 Abs 2 lit e,
+ ASVG § 51 Abs 6/7, IESG § 12), WF-Satzvektor Bundesland × Jahr
+ (Wien 1,5 % ab 2026), ALV-DN-Staffel § 2a AMPFG, Beitragsmonats-
+ Allokation (Anspruchsprinzip), DAG-Bewertung § 1 DAG —
+ ``rule_parameters_sv_2026.xml``;
- deklarative TASY-Mapping-Tabelle und Import-Wizard (Preview,
Sprungwarnung > 10 %, explizite Bestätigung, versionierte Werte);
-- folgen nach Freigabe der Bgld.-APs: Lohnsteuer (§ 33/§ 68 EStG, AP3),
- Meldewesen (L16/FinanzOnline, eSV/ELDA, AP5), Belege (AP6).
+- folgen nach Freigabe der Bgld.-APs: Meldewesen (L16/FinanzOnline,
+ eSV/ELDA, AP5), Belege (AP6).
Kein VRV-Bezug: die Gemeinde-Buchhaltung (Anlage 3b, Ansatz/MVAG) läuft
über die Brücke ``l10n_at_gemeinde_payroll_vrv`` im gem360-Repo (AP7).
@@ -32,6 +43,7 @@ Kein VRV-Bezug: die Gemeinde-Buchhaltung (Anlage 3b, Ansatz/MVAG) läuft
'security/ir.model.access.csv',
'data/rule_parameters_2026.xml',
'data/rule_parameters_lst_2026.xml',
+ 'data/rule_parameters_sv_2026.xml',
'data/tasy_mapping.xml',
'views/tasy_views.xml',
],
diff --git a/addons/l10n_at_hr_payroll/data/rule_parameters_2026.xml b/addons/l10n_at_hr_payroll/data/rule_parameters_2026.xml
index 5585b15..c514ba3 100644
--- a/addons/l10n_at_hr_payroll/data/rule_parameters_2026.xml
+++ b/addons/l10n_at_hr_payroll/data/rule_parameters_2026.xml
@@ -65,15 +65,31 @@
2024-01-01
2.95
+
- SV AV-Beitragsstaffel bei geringem Einkommen (§ 2a AMPFG)
+ SV ALV-DN-Staffel bei geringem Einkommen (§ 2a AMPFG)
at_sv_av_staffel_ampfg
+
+
+ 2025-01-01
+ {'tiers': [(0.0, 2074.00, 0.0), (2074.00, 2262.00, 1.0), (2262.00, 2451.00, 2.0)], 'dn_voll': 2.95}
+
- 2026-07-01
- {'tiers': [(0.0, 2225.00, 0.0), (2225.00, 2427.00, 1.0), (2427.00, 2630.00, 2.0)], 'av_total': 5.9}
+ 2026-01-01
+ {'tiers': [(0.0, 2225.00, 0.0), (2225.00, 2427.00, 1.0), (2427.00, 2630.00, 2.0)], 'dn_voll': 2.95}
+
+
+
+ 2027-01-01
+ {'tiers': [], 'dn_voll': 2.95}
Betriebliche Vorsorge Dienstgeberanteil
@@ -110,6 +126,12 @@
at_sv_hbg_monatlich
+
+
+
+ 2025-01-01
+ 6450.0
+
2026-01-01
diff --git a/addons/l10n_at_hr_payroll/data/rule_parameters_sv_2026.xml b/addons/l10n_at_hr_payroll/data/rule_parameters_sv_2026.xml
new file mode 100644
index 0000000..fea33bc
--- /dev/null
+++ b/addons/l10n_at_hr_payroll/data/rule_parameters_sv_2026.xml
@@ -0,0 +1,181 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ WF-Beitrag Satzvektor (Bundesland × Jahr)
+ at_sv_wf_vektor
+
+
+
+
+ 2019-01-01
+ {'default': {'dn': 0.5, 'dg': 0.5}}
+
+
+
+ 2026-01-01
+ {'wien': {'dn': 0.75, 'dg': 0.75}, 'default': {'dn': 0.5, 'dg': 0.5}}
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Beitragsfreie Bezüge § 49 Abs 3 ASVG (Grenzen)
+ at_sv49_grenzen
+
+
+
+
+ 2026-01-01
+ {'mahlzeit_tag': 8.0, 'lebensmittel_tag': 2.0, 'rabatt_jahr': 1000.0, 'betriebsveranstaltung_jahr': 365.0, 'jubilaeum_jahr': 186.0, 'zukunftssicherung_jahr': 300.0, 'kinderbetreuung_jahr': 2000.0, 'homeoffice_tag': 3.0, 'homeoffice_tage': 100}
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Geringfügig Beschäftigte — § 53a ASVG (UV/Selbstversicherung)
+ at_sv_geringfuegig
+
+
+
+
+ 2025-01-01
+ {'beitrag_uv': 6.06, 'beitrag_selbstversicherung': 77.81}
+
+
+
+ 2026-01-01
+ {'beitrag_uv': 6.50, 'beitrag_selbstversicherung': 83.49}
+
+
+
+
+
+
+ PV-Faktor Bonusphase (§ 51 Abs 7 ASVG)
+ at_pv_bonus_faktor
+
+
+
+
+ 2019-01-01
+ 0.5
+
+
+
+
+
+
+ FLAF-Dienstgeberbeitrag (§ 41 Abs 5 FLAG)
+ at_flaf_db
+
+
+
+
+ 2018-01-01
+ 3.9
+
+
+
+ 2025-01-01
+ 3.7
+
+
+
+ 2028-01-01
+ 2.7
+
+
+
+
+ Kommunalsteuer (§ 9 KommStG 1993)
+ at_kommst_satz
+
+
+
+
+ 2025-01-01
+ 3.0
+
+
+
+
+
+
+
+ LNK-Freibetrag (FLAF/KommSt/DZ; § 41 Abs 4 FLAG)
+ at_lnk_freibetrag
+
+
+
+
+ 2025-01-01
+ {'freibetrag': 1095.0, 'grenze': 1460.0}
+
+
+
+
+
+
+
+ Zuschlag zum Dienstgeberbeitrag (WKG; je Bundesland)
+ at_dz_vektor
+
+
+
+
+ 2026-01-01
+ {'laender': {'burgenland': 0.40, 'kaernten': 0.37, 'niederoesterreich': 0.33, 'oberoesterreich': 0.31, 'salzburg': 0.35, 'steiermark': 0.34, 'tirol': 0.39, 'vorarlberg': 0.33, 'wien': 0.36}}
+
+
+
+
+
+
+
+
+
+
+
+ Dienstgeberabgabe geringfügig Beschäftigter (§ 1 DAG)
+ at_dag
+
+
+
+
+ 2025-01-01
+ {'satz': 19.4, 'schwelle_faktor': 1.5}
+
+
\ No newline at end of file
diff --git a/addons/l10n_at_hr_payroll/models/__init__.py b/addons/l10n_at_hr_payroll/models/__init__.py
index a2b8219..97bafe9 100644
--- a/addons/l10n_at_hr_payroll/models/__init__.py
+++ b/addons/l10n_at_hr_payroll/models/__init__.py
@@ -1,3 +1,5 @@
# Part of the odoo-at-payroll project. License: LGPL-3.
from . import tasy
-from . import lohnsteuer
\ No newline at end of file
+from . import lohnsteuer
+from . import sozialversicherung
+from . import sozialversicherung
\ No newline at end of file
diff --git a/addons/l10n_at_hr_payroll/models/lohnsteuer.py b/addons/l10n_at_hr_payroll/models/lohnsteuer.py
index 559ebca..1c63f9f 100644
--- a/addons/l10n_at_hr_payroll/models/lohnsteuer.py
+++ b/addons/l10n_at_hr_payroll/models/lohnsteuer.py
@@ -198,29 +198,15 @@ class HrPayslip(models.Model):
return ergebnis
# ------------------------------------------------------------------
- # DN-SV (Bemessungsbestandteil § 62; Werte at_sv_* — GP4 baut
- # DG-Seite/Geringfügigkeit/beitragsfreie Kumulative darauf)
+ # DN-SV (Bemessungsbestandteil § 62) — Basis/Sätze/Geringfügigkeit/
+ # §-49-Kumulative/DG-Seite: models/sozialversicherung.py (GP4)
# ------------------------------------------------------------------
- def _l10n_at_svdn_basis_laufen(self, result_rules=None):
- """SV-Beitragsgrundlage laufend (HBG-capped, at_sv_*)."""
- self.ensure_one()
- hbg = float(self._l10n_at_param('at_sv_hbg_monatlich'))
- return min(max(self._l10n_at_brutto('laufend', result_rules), 0.0), hbg)
-
def _l10n_at_svdn(self, komponente, result_rules=None):
"""DN-SV-Komponente auf laufender Basis: kv/pv/av/ak/wf —
AK § 61 AKG und WF § 62 Z 5 nur laufend (nicht auf §-67-Bezüge);
- WF-Wien über Company-Flag (DN 0,75 statt 0,50)."""
+ Wien-WF über Satzvektor (l10n_at_wf_wien/Bundesland, GP4)."""
self.ensure_one()
- basis = self._l10n_at_svdn_basis_laufen(result_rules)
- codes = {'kv': 'at_sv_kv_dn', 'pv': 'at_sv_pv_dn',
- 'av': 'at_sv_av_dn', 'ak': 'at_sv_ak_dn'}
- if komponente == 'wf':
- satz = 0.75 if self.company_id.l10n_at_wf_wien \
- else float(self._l10n_at_param('at_sv_wf_dn'))
- else:
- satz = float(self._l10n_at_param(codes[komponente]))
- return basis * satz / 100.0
+ return self._l10n_at_sv_beitrag('dn', komponente, result_rules)
def _l10n_at_svdn_gesamt(self, result_rules=None):
self.ensure_one()
@@ -240,11 +226,15 @@ class HrPayslip(models.Model):
def _l10n_at_svdn_sz(self, result_rules=None):
"""SV-DN auf sonstigen Bezügen (§ 67 Abs 12 — vor Fixsätzen
- abzuziehen): KV/PV/AV, ohne AK/WF (§ 62 Z 3/Z 5)."""
+ abzuziehen): KV/PV/AV, ohne AK/WF (§ 62 Z 3/Z 5); Satzausschal-
+ tungen/Steffel über _l10n_at_sv_satz (GP4)."""
self.ensure_one()
+ if self._l10n_at_geringfuegig(result_rules):
+ return 0.0
basis = self._l10n_at_svdn_basis_sz(result_rules)
- satz = sum(float(self._l10n_at_param(code)) for code in
- ('at_sv_kv_dn', 'at_sv_pv_dn', 'at_sv_av_dn'))
+ satz = sum(self._l10n_at_sv_satz(
+ 'dn', k, staffel_basis=self._l10n_at_sv_basis_roh(result_rules))
+ for k in ('kv', 'pv', 'av'))
return basis * satz / 100.0
# ------------------------------------------------------------------
diff --git a/addons/l10n_at_hr_payroll/models/sozialversicherung.py b/addons/l10n_at_hr_payroll/models/sozialversicherung.py
new file mode 100644
index 0000000..b3b81b3
--- /dev/null
+++ b/addons/l10n_at_hr_payroll/models/sozialversicherung.py
@@ -0,0 +1,489 @@
+# Part of the odoo-at-payroll project. License: LGPL-3.
+"""Sozialversicherungs-Kern (GP4/AP4): DG-Beiträge (SVDG), Geringfügigkeit
+(§ 5 Abs 2 ASVG iVm § 810 Abs 3 — 2026-Aufwertung ausgestellt), §-53a-
+UV der Geringfügigen, beitragsfreie Bezüge nach § 49 Abs 3 ASVG mit
+Jahres-Kumulativen, altersabhängige Satzausschaltungen (§ 1 Abs 2 lit e
+AlVG, § 51 Abs 6/7 ASVG, § 12 Abs 2 IESG), WF-Satzvektor Bundesland ×
+Jahr (Wien 1,5 % ab 1.1.2026), ALV-DN-Staffel (§ 2a AMPFG) und
+Beitragsmonats-Allokation (Anspruchsprinzip, historisch versionierte
+Sätze/HBG je Beitragsmonat).
+
+Verifizierte Grundlagen (2026-09-11, s. RECHTSQUELLEN-Privat Abschn. 4
+und docs/rechenfaelle/sozialversicherung.md):
+- ÖGK-Beitragstarifsystem „Normalbetriebe Allgemeiner Teil", gültig ab
+ 01.07.2026 (DN 18,07 % · DG 20,98 % · Wien DN 18,32/DG 21,23 %);
+- TASY-LSWH-Export 2026-03-24 (GFG 551,10 · DAG-Grenze 826,65 ·
+ §-19a-Pauschalbeitrag 83,49 €/Monat — Primärquellen-Entscheidung);
+- „Beitragsrechtliche Werte 2026" (sozialversicherung.at): GFG-Aufwertung
+ 2026 ausgesetzt (§ 810 Abs 3 ASVG); AV-Staffel bis 2.225/2.427/2.630 €
+ 0/1/2 % DN; WF Wien 0,75/0,75 ab 1.1.2026;
+- ASVG §§ 51 Abs 6 (UV-Entfall 60. LJ/Lehrlinge), 51 Abs 7 (PV in der
+ Bonusphase hälftig, DG und DN), 53a Abs 1/3/5 (UV 1,1 % der allgemeinen
+ Beitragsgrundlage; Pauschalbeitrag 14,12 %, auch von SZ);
+ IESG § 12 Abs 1 Z 4 iVm Abs 2 (Entfall ab Folgemonat nach 63);
+ AlVG § 1 Abs 2 lit e (AV-Entfall, ab 2027 inkl. Korridorpension).
+
+Laufzeit-Datenfluss wie GP3: Regeln reichen Zwischensummen über
+``result_rules`` an die Compute-Methoden; abgeschlossene Läufe werden
+über line_ids gelesen. Der Beitragsmonat wird je Regel geführt
+(``l10n_at_beitragsmonat``) und auf die Lohnkontolinie durchgereicht.
+"""
+from datetime import date, timedelta
+
+from odoo import api, fields, models
+from odoo.tools.safe_eval import safe_eval
+
+
+class HrSalaryRule(models.Model):
+ _inherit = 'hr.salary.rule'
+
+ l10n_at_sv49_frei = fields.Selection(
+ [('keine', 'Keine §-49-Abs-3-Befreiung'),
+ ('mahlzeit', 'Mahlzeiten-Gutschein (§ 49 Abs 3 Z 12; 8 €/AT)'),
+ ('rabatt', 'Mitarbeiterrabatt (§ 49 Abs 3 Z 29; 1.000 €/J)'),
+ ('betriebsveranstaltung', 'Betriebsveranstaltung (Z 17; 365 €/J)'),
+ ('jubilaeum', 'Jubiläums-Sachzuwendung (Z 17; 186 €/J)'),
+ ('zukunftssicherung', 'Zukunftssicherung (Z 18 lit a; 300 €/J)'),
+ ('kinderbetreuung', 'Kinderbetreuungszuschuss (Z 11 lit d; 2.000 €/Kind/J)'),
+ ('homeoffice', 'Homeoffice-Pauschale (Z 31; 3 €/Tag, max 100 Tage)')],
+ string='AT § 49 Abs 3 (beitragsfrei)',
+ default='keine',
+ help='Beitragsfreie Bezugsbestandteile: der beitragsfreie Anteil '
+ 'wird je Bezugsmonat von der SV-Beitragsgrundlage abgezogen; '
+ 'Grenzen als Jahres-Kumulative je Dienstnehmer '
+ '(at_sv49_grenzen). Überschreiten = beitragspflichtiger '
+ 'Sachbezug (Umwidmung). Menge = Arbeitstage (Z 12/Z 31).')
+ l10n_at_s67_abs = fields.Selection(
+ [('keine', 'Keine §-67-Abs-3/6-Begünstigung'),
+ ('abs3', 'Abfertigung (§ 67 Abs 3 EStG)'),
+ ('abs6', 'Abfindung (§ 67 Abs 6 EStG)')],
+ string='AT § 67 Abs 3/6',
+ default='keine',
+ help='Markiert Bezüge, die beim FLAF-Dienstgeberbeitrag (§ 41 '
+ 'Abs 4 FLAG), Zuschlag zum DG-Beitrag (WKG) und der '
+ 'Kommunalsteuer (§ 5 Abs 2 KommStG) aus der Bemessungsgrund'
+ 'lage ausgenommen sind.')
+ l10n_at_beitragsmonat = fields.Date(
+ string='AT Beitragsmonat (Anspruchsprinzip)',
+ help='Monat, in dem der Anspruch auf diesen Bezug fällig wurde '
+ '(§ 17 ASVG). Leer = Abrechnungsmonat. Für Nachzahlungs-/'
+ 'Korrekturlinien setzen: SV-Sätze, HBG und GFG werden '
+ 'historisch versioniert je Beitragsmonat aufgelöst '
+ '(Lohnsteuer rollt kumulativ in den Zuflussmonat, GP3).')
+
+
+class HrPayslipLine(models.Model):
+ _inherit = 'hr.payslip.line'
+
+ l10n_at_beitragsmonat = fields.Date(
+ string='AT Beitragsmonat',
+ compute='_compute_l10n_at_beitragsmonat',
+ store=True,
+ readonly=False,
+ index=True,
+ help='Beitragsmonat der Linie (Anspruchsprinzip); default '
+ 'Abrechnungsmonat, über die Regel übersteuerbar.')
+
+ @api.depends('salary_rule_id.l10n_at_beitragsmonat', 'date_from')
+ def _compute_l10n_at_beitragsmonat(self):
+ for line in self:
+ line.l10n_at_beitragsmonat = (
+ line.salary_rule_id.l10n_at_beitragsmonat or line.date_from)
+
+
+class HrVersion(models.Model):
+ _inherit = 'hr.version'
+
+ l10n_at_lehrling = fields.Boolean(
+ string='AT Lehrling',
+ help='Keine AK-Umlage (§ 61 AKG) und kein UV-Beitrag (§ 51 Abs 6 '
+ 'ASVG — aus Mitteln der Unfallversicherung); GFG gilt nicht '
+ '(§ 5 Abs 3 ASVG).')
+ l10n_at_alv_entfall_ab = fields.Date(
+ string='AT ALV-Entfall ab (§ 1 Abs 2 lit e AlVG)',
+ help='AV-Beitrag DN+DG und IESG-Zuschlag entfallen ab diesem '
+ 'Beitragsmonat (63. Lebensjahr oder erfüllte Alterspensions-'
+ 'Anwartschaft; ab 1.1.2027 inkl. Korridorpension, '
+ 'NOR40280219; § 12 Abs 2 IESG). Stichtagsregel am Vertrag.')
+ l10n_at_uv_entfall_ab = fields.Date(
+ string='AT UV-Entfall ab (§ 51 Abs 6 ASVG)',
+ help='Unfallversicherungsbeitrag entfällt ab diesem '
+ 'Beitragsmonat (Vollendung des 60. Lebensjahres); die '
+ 'Person bleibt unfallversichert.')
+ l10n_at_pv_bonusphase_ab = fields.Date(
+ string='AT PV-Bonusphase ab (§ 51 Abs 7 ASVG)',
+ help='Pensionsversicherungsbeitrag je zur Hälfte reduziert für '
+ 'DG und DN (Bonusphase/Pensionsanspruch; ÖGK-Abschlag '
+ 'A 15; Pensionskonto-Gutschrift bleibt ungekürzt — Auswertung '
+ 'GP8).')
+ l10n_at_lnkp_frei_ab = fields.Date(
+ string='AT LNK-frei ab (§ 41 Abs 4 lit f FLAG)',
+ help='Arbeitslöhne ab diesem Kalendermonat sind vom FLAF-'
+ 'Dienstgeberbeitrag und vom Zuschlag zum Dienstgeberbeitrag '
+ '(WKG) ausgenommen (Folgemonat nach Vollendung des 60. '
+ 'Lebensjahres). Kommunalsteuer: keine Befreiung.')
+
+
+class ResCompany(models.Model):
+ _inherit = 'res.company'
+
+ l10n_at_bundesland = fields.Selection(
+ [('burgenland', 'Burgenland'), ('kaernten', 'Kärnten'),
+ ('niederoesterreich', 'Niederösterreich'),
+ ('oberoesterreich', 'Oberösterreich'), ('salzburg', 'Salzburg'),
+ ('steiermark', 'Steiermark'), ('tirol', 'Tirol'),
+ ('vorarlberg', 'Vorarlberg'), ('wien', 'Wien')],
+ string='AT Bundesland (Betriebsstätte)',
+ help='Betriebsstätten-Bundesland für Bundesland-Satzvektoren '
+ '(z. B. DZ-Landeskammeranteil; WF-Wien zusätzlich über das '
+ 'Company-Flag l10n_at_wf_wien steuerbar).')
+ l10n_at_wk_mitglied = fields.Boolean(
+ string='AT WKO-Mitgliedschaft (DZ-pflichtig)',
+ help='Zuschlag zum Dienstgeberbeitrag (§§ 122/126 WKG) nur für '
+ 'Mitglieder der Wirtschaftskammerorganisation (§ 2 WKG).')
+
+ def _l10n_at_dag(self, monat_start):
+ """Dienstgeberabgabe geringfügig Beschäftigter (§ 1 DAG) für den
+ Kalendermonat: 19,4 % der Lohnsumme (Beitragsgrundlagen ohne SZ)
+ aller geringfügig Beschäftigten, wenn die Lohnsumme das 1,5-fache
+ der monatlichen GFG überschreitet und mindestens zwei
+ Geringfügige beschäftigt sind (lb-bes-06; TASY MINGRDAGP1 2026:
+ 826,65 €; Ausweis/Veranlagung GP8)."""
+ monat_ende = (monat_start + timedelta(days=32)).replace(day=1) \
+ - timedelta(days=1)
+ slips = self.env['hr.payslip'].search([
+ ('company_id', '=', self.id),
+ ('date_from', '<=', monat_ende),
+ ('date_to', '>=', monat_start),
+ ('state', 'not in', ('draft', 'cancel')),
+ ('l10n_at_geringfuegig', '=', True),
+ ])
+ lohnsumme = sum(
+ slip._l10n_at_sv_basis_roh() for slip in slips)
+ anzahl = len({s.employee_id.id for s in slips})
+ params = self.env['hr.rule.parameter']._get_parameter_from_code(
+ 'at_dag', monat_start, raise_if_not_found=False) or {}
+ gfg = float(self.env['hr.rule.parameter']._get_parameter_from_code(
+ 'at_sv_gfg_monatlich', monat_start,
+ raise_if_not_found=False) or 0.0)
+ schwelle = float(params.get('schwelle_faktor', 0.0)) * gfg
+ abgabe = 0.0
+ if anzahl >= 2 and lohnsumme > schwelle:
+ abgabe = lohnsumme * float(params.get('satz', 0.0)) / 100.0
+ return {'anzahl': anzahl, 'lohnsumme': lohnsumme,
+ 'schwelle': schwelle, 'abgabe': abgabe}
+
+
+class HrPayslip(models.Model):
+ _inherit = 'hr.payslip'
+
+ l10n_at_geringfuegig = fields.Boolean(
+ string='AT Geringfügig beschäftigt',
+ compute='_compute_l10n_at_geringfuegig',
+ store=True,
+ help='Laufendes Entgelt (§-49-bereinigt, hochgerechnet bei '
+ 'untermonatiger Beschäftigung) überschreitet die GFG nicht '
+ '(§ 5 Abs 2 ASVG). DAG-Monitoring-Grundlage (§ 1 DAG).')
+
+ @api.depends('line_ids', 'date_from', 'date_to', 'version_id')
+ def _compute_l10n_at_geringfuegig(self):
+ for slip in self:
+ try:
+ slip.l10n_at_geringfuegig = slip._l10n_at_geringfuegig()
+ except Exception:
+ # Alter Lauf ohne GFG-Parameterzeile: keine Bewertung.
+ slip.l10n_at_geringfuegig = False
+
+ # ------------------------------------------------------------------
+ # Geringfügigkeit (§ 5 Abs 2 ASVG; SZ zählen nicht — lb-bes-06)
+ # ------------------------------------------------------------------
+ def _l10n_at_geringfuegig(self, result_rules=None):
+ """Hochgerechnetes laufendes Entgelt ≤ GFG (2026: 551,10 € —
+ Aufwertung gem. § 810 Abs 3 ASVG ausgesetzt). Lehrlinge sind
+ nie geringfügig (§ 5 Abs 3 ASVG). Verhältnisse kürzer als ein
+ Naturalmonat: Gesamtentgelt direkt gegen die Monatsgrenze."""
+ self.ensure_one()
+ version = self.version_id
+ if version.l10n_at_lehrling:
+ return False
+ roh = self._l10n_at_sv_basis_roh(result_rules)
+ if roh <= 0.0:
+ # Ohne laufenden Bezug keine Statusbewertung (z. B.
+ # reiner SZ-Lauf): Normalbehandlung der SZ-Beiträge.
+ return False
+ if (version.contract_date_start and version.contract_date_end
+ and (version.contract_date_end - version.contract_date_start)
+ .days < 29):
+ vergleich = roh
+ else:
+ tage = self._l10n_at_lohnsteuertage()
+ vergleich = roh * 30.0 / max(tage, 1) if tage < 30 else roh
+ gfg = float(self.env['hr.rule.parameter']
+ ._get_parameter_from_code(
+ 'at_sv_gfg_monatlich', self.date_to,
+ raise_if_not_found=False) or 0.0)
+ return vergleich <= gfg
+
+ # ------------------------------------------------------------------
+ # Beitragsfreie Bezüge § 49 Abs 3 ASVG (Jahres-Kumulative je DN)
+ # ------------------------------------------------------------------
+ def _l10n_at_sv49_ytd(self, kategorie, menge=False):
+ """Jahresverbrauch einer §-49-Kategorie aus früheren validierten
+ Läufen (refundbewusst über _l10n_at_ytd_slaeufe)."""
+ self.ensure_one()
+ total = 0.0
+ for slip in self._l10n_at_ytd_slaeufe():
+ linien = slip.line_ids.filtered(
+ lambda l: l.salary_rule_id.l10n_at_sv49_frei == kategorie)
+ if menge:
+ total += sum(linien.mapped('quantity'))
+ else:
+ total += sum(linien.mapped('total'))
+ return total
+
+ def _l10n_at_sv49_freie_laufen(self, result_rules=None):
+ """Beitragsfreie Anteile laufender Bezüge dieses LZP: je Kategorie
+ Tages-/Jahresgrenzen (at_sv49_grenzen) minus Jahresverbrauch;
+ Überschreiten = beitragspflichtig (Umwidmung, § 49 Abs 3)."""
+ self.ensure_one()
+ grenzen = self.env['hr.rule.parameter']._get_parameter_from_code(
+ 'at_sv49_grenzen', self.date_to, raise_if_not_found=False)
+ if not grenzen:
+ return 0.0
+ frei_gesamt = 0.0
+ for rule in self.struct_id.rule_ids:
+ kategorie = rule.l10n_at_sv49_frei
+ if (not kategorie or kategorie == 'keine'
+ or rule.l10n_at_bemessung != 'laufend'):
+ continue
+ werte = None
+ if result_rules is not None:
+ if rule.code in result_rules:
+ werte = result_rules[rule.code]
+ else:
+ linie = self.line_ids.filtered(lambda l: l.code == rule.code)
+ if linie:
+ werte = {'total': sum(linie.mapped('total')),
+ 'quantity': sum(linie.mapped('quantity'))}
+ if not werte:
+ continue
+ total = werte['total']
+ menge = werte.get('quantity', 0.0)
+ if kategorie == 'mahlzeit':
+ frei = min(
+ total, float(grenzen.get('mahlzeit_tag')) * menge)
+ elif kategorie == 'homeoffice':
+ rest_tage = max(
+ float(grenzen.get('homeoffice_tage'))
+ - self._l10n_at_sv49_ytd('homeoffice', menge=True), 0.0)
+ frei = min(
+ total,
+ float(grenzen.get('homeoffice_tag'))
+ * min(menge, rest_tage))
+ else:
+ pool = {'rabatt': 'rabatt_jahr',
+ 'betriebsveranstaltung': 'betriebsveranstaltung_jahr',
+ 'jubilaeum': 'jubilaeum_jahr',
+ 'zukunftssicherung': 'zukunftssicherung_jahr',
+ 'kinderbetreuung': 'kinderbetreuung_jahr'}.get(
+ kategorie)
+ rest = max(
+ float(grenzen.get(pool))
+ - self._l10n_at_sv49_ytd(kategorie), 0.0)
+ frei = min(total, rest)
+ frei_gesamt += max(frei, 0.0)
+ return frei_gesamt
+
+ # ------------------------------------------------------------------
+ # Laufende Beitragsgrundlage — je Beitragsmonat (Anspruchsprinzip)
+ # ------------------------------------------------------------------
+ def _l10n_at_sv_basis_roh(self, result_rules=None):
+ """Laufender Arbeitslohn minus beitragsfreie §-49-Anteile
+ (vor HBG-Cap; Geringfügigkeits-/Staffel-Basis)."""
+ self.ensure_one()
+ roh = self._l10n_at_brutto('laufend', result_rules) \
+ - self._l10n_at_sv49_freie_laufen(result_rules)
+ return max(roh, 0.0)
+
+ def _l10n_at_sv_basis_laufen_monate(self, result_rules=None):
+ """[(Beitragsmonat, HBG-gecappte Basis), …]: laufender Arbeitslohn
+ (§-49-bereinigt), gruppiert nach Regel-Beitragsmonat; HBG je
+ Beitragsmonat historisch aufgelöst (at_sv_hbg_monatlich)."""
+ self.ensure_one()
+ gruppen = {}
+ for rule in self.struct_id.rule_ids:
+ if rule.l10n_at_bemessung != 'laufend':
+ continue
+ werte = None
+ if result_rules is not None:
+ if rule.code in result_rules:
+ werte = result_rules[rule.code]
+ else:
+ linie = self.line_ids.filtered(lambda l: l.code == rule.code)
+ if linie:
+ werte = {'total': sum(linie.mapped('total'))}
+ if not werte:
+ continue
+ monat = (rule.l10n_at_beitragsmonat or self.date_from) \
+ .replace(day=1)
+ gruppen[monat] = gruppen.get(monat, 0.0) + werte['total']
+ standard = self.date_from.replace(day=1)
+ if standard in gruppen:
+ gruppen[standard] -= self._l10n_at_sv49_freie_laufen(
+ result_rules)
+ ergebnis = []
+ for monat, basis in sorted(gruppen.items()):
+ hbg = float(self._l10n_at_param('at_sv_hbg_monatlich', monat))
+ ergebnis.append((monat, min(max(basis, 0.0), hbg)))
+ return ergebnis
+
+ def _l10n_at_svdn_basis_laufen(self, result_rules=None):
+ """SV-Beitragsgrundlage laufend: Summe der je Beitragsmonat
+ HBG-gecappten, §-49-bereinigten Basen (at_sv_*)."""
+ self.ensure_one()
+ return sum(basis for _monat, basis
+ in self._l10n_at_sv_basis_laufen_monate(result_rules))
+
+ # ------------------------------------------------------------------
+ # Effektive Beitragssätze (Alter/Stichtäge, Wien-WF, ALV-DN-Staffel)
+ # ------------------------------------------------------------------
+ def _l10n_at_sv_satz(self, seite, komponente, monat=None,
+ staffel_basis=None):
+ """Effektiver Satz (vH) je Seite ('dn'/'dg') und Komponente
+ (kv/pv/av/uv/ak/wf/iesg) zum Beitragsmonat, unter Berücksich-
+ tigung der Stichtags-Regeln am Vertrag und der ALV-DN-Staffel
+ (§ 2a AMPFG: reduziert nur den DN-Anteil; Grenzen nicht zu
+ aliquotieren; gilt auch für SZ-Beiträge)."""
+ self.ensure_one()
+ version = self.version_id
+ monat = monat or self.date_from
+ if komponente == 'wf':
+ vektor = self.env['hr.rule.parameter']._get_parameter_from_code(
+ 'at_sv_wf_vektor', monat, raise_if_not_found=False)
+ satz = None
+ if vektor:
+ wien = (self.company_id.l10n_at_wf_wien
+ or self.company_id.l10n_at_bundesland == 'wien')
+ eintrag = vektor.get('wien' if wien else 'default') \
+ or vektor.get('default')
+ if eintrag:
+ satz = float(eintrag.get(seite, 0.0))
+ if satz is None:
+ satz = float(self.env['hr.rule.parameter']
+ ._get_parameter_from_code(
+ 'at_sv_wf_%s' % seite, monat,
+ raise_if_not_found=False) or 0.0)
+ return satz
+ satz = float(self._l10n_at_param(
+ 'at_sv_%s_%s' % (komponente, seite), monat))
+ entfall_alv = (version.l10n_at_alv_entfall_ab
+ and monat >= version.l10n_at_alv_entfall_ab)
+ if komponente in ('av', 'iesg') and entfall_alv:
+ # AV DN+DG entfallen (AlVG § 1 Abs 2 lit e) — der IESG-
+ # Zuschlag folgt dem AV-Beitrag (§ 12 Abs 1 Z 4 iVm
+ # § 12 Abs 2 IESG).
+ return 0.0
+ if komponente == 'uv':
+ if version.l10n_at_lehrling \
+ or (version.l10n_at_uv_entfall_ab
+ and monat >= version.l10n_at_uv_entfall_ab):
+ return 0.0
+ if komponente == 'ak' and version.l10n_at_lehrling:
+ return 0.0
+ if komponente == 'iesg' and version.l10n_at_lehrling:
+ # § 12 Abs 2 IESG: für Lehrlinge gesamte Lehrzeit kein Zuschlag
+ return 0.0
+ if komponente == 'pv' and (
+ version.l10n_at_pv_bonusphase_ab
+ and monat >= version.l10n_at_pv_bonusphase_ab):
+ faktor = float(self.env['hr.rule.parameter']
+ ._get_parameter_from_code(
+ 'at_pv_bonus_faktor', monat,
+ raise_if_not_found=False) or 0.5)
+ satz *= faktor
+ if komponente == 'av' and seite == 'dn':
+ staffel = self.env['hr.rule.parameter']._get_parameter_from_code(
+ 'at_sv_av_staffel_ampfg', monat, raise_if_not_found=False)
+ if staffel and staffel.get('tiers'):
+ basis = staffel_basis if staffel_basis is not None \
+ else self._l10n_at_sv_basis_roh()
+ if basis > 0:
+ for _von, bis, dn_satz in staffel['tiers']:
+ if basis <= float(bis):
+ return float(dn_satz)
+ # über der obersten Stufe: voller DN-Satz
+ return float(staffel.get('dn_voll', satz))
+ return satz
+
+ # ------------------------------------------------------------------
+ # Beiträge laufend (DN- wie DG-seitig; geringfügig = 0)
+ # ------------------------------------------------------------------
+ def _l10n_at_sv_beitrag(self, seite, komponente, result_rules=None):
+ """Beitragssumme je Beitragsmonat: Basis × historischen Satz;
+ geringfügige Beschäftigung → 0 (stattdessen §-53a-UV, s. u.)."""
+ self.ensure_one()
+ if self._l10n_at_geringfuegig(result_rules):
+ return 0.0
+ total = 0.0
+ for monat, basis in self._l10n_at_sv_basis_laufen_monate(
+ result_rules):
+ satz = self._l10n_at_sv_satz(seite, komponente, monat, basis)
+ total += basis * satz / 100.0
+ return total
+
+ def _l10n_at_svdn(self, komponente, result_rules=None):
+ """DN-SV-Komponente laufend: kv/pv/av/ak/wf — AK (§ 61 AKG) und
+ WF nur laufend, HBGL-Cap; Wien über WF-Satzvektor."""
+ self.ensure_one()
+ return self._l10n_at_sv_beitrag('dn', komponente, result_rules)
+
+ def _l10n_at_svdg(self, komponente, result_rules=None):
+ """DG-SV-Komponente laufend: kv/pv/av/uv/wf/iesg (AK = 0);
+ ÖGK-Tarif „Normalbetriebe" 2026: DG-Gesamt 20,98 % (Wien 21,23)."""
+ self.ensure_one()
+ return self._l10n_at_sv_beitrag('dg', komponente, result_rules)
+
+ def _l10n_at_svdg_gesamt(self, result_rules=None):
+ self.ensure_one()
+ return sum(self._l10n_at_svdg(k, result_rules)
+ for k in ('kv', 'pv', 'av', 'uv', 'wf', 'iesg'))
+
+ # ------------------------------------------------------------------
+ # Beiträge auf sonstigen Bezügen (§ 54 ASVG; 60faches HBG je Jahr)
+ # ------------------------------------------------------------------
+ def _l10n_at_svdg_sz(self, komponente, result_rules=None):
+ """DG-Beitrag auf sonstigen Bezügen: KV/PV/AV/UV und IESG-Zuschlag
+ (folgt dem AV-Beitrag) — ohne AK/WF (§ 62 Z 3/Z 5; lb-son-03:
+ DN 17,07 %; DG 20,48 % inkl. UV 1,10 + IESG 0,10)."""
+ self.ensure_one()
+ if self._l10n_at_geringfuegig(result_rules):
+ return 0.0
+ basis = self._l10n_at_svdn_basis_sz(result_rules)
+ satz = self._l10n_at_sv_satz(
+ 'dg', komponente, staffel_basis=self._l10n_at_sv_basis_roh(
+ result_rules))
+ return basis * satz / 100.0
+
+ def _l10n_at_svdg_sz_gesamt(self, result_rules=None):
+ self.ensure_one()
+ return sum(self._l10n_at_svdg_sz(k, result_rules)
+ for k in ('kv', 'pv', 'av', 'uv', 'iesg'))
+
+ # ------------------------------------------------------------------
+ # § 53a ASVG — Geringfügig Beschäftigte
+ # ------------------------------------------------------------------
+ def _l10n_at_svdg_uv_geringfuegig(self, result_rules=None):
+ """DG-UV geringfügig Beschäftigter: 1,1 % der allgemeinen
+ Beitragsgrundlage (§ 53a Abs 1 ASVG) als Monatspauschale
+ (at_sv_geringfuegig['beitrag_uv']; aBG 2026 = 83,49 ÷ 14,12 % =
+ 591,29 → 6,50 € — abgeleitet, s. Rechenfälle). Entfällt bei
+ UV-Stichtag (60. LJ) bzw. Lehrlingen (§ 51 Abs 6 ASVG)."""
+ self.ensure_one()
+ if not self._l10n_at_geringfuegig(result_rules):
+ return 0.0
+ if self._l10n_at_sv_satz('dg', 'uv') <= 0.0:
+ return 0.0
+ params = self.env['hr.rule.parameter']._get_parameter_from_code(
+ 'at_sv_geringfuegig', self.date_to, raise_if_not_found=False) \
+ or {}
+ return float(params.get('beitrag_uv', 0.0))
\ No newline at end of file
diff --git a/addons/l10n_at_hr_payroll/tests/__init__.py b/addons/l10n_at_hr_payroll/tests/__init__.py
index 5ced461..3b5c5c4 100644
--- a/addons/l10n_at_hr_payroll/tests/__init__.py
+++ b/addons/l10n_at_hr_payroll/tests/__init__.py
@@ -1,3 +1,4 @@
# Part of the odoo-at-payroll project. License: LGPL-3.
from . import test_tasy_import
-from . import test_lohnsteuer
\ No newline at end of file
+from . import test_lohnsteuer
+from . import test_sozialversicherung
\ No newline at end of file
diff --git a/addons/l10n_at_hr_payroll/tests/test_lohnsteuer.py b/addons/l10n_at_hr_payroll/tests/test_lohnsteuer.py
index 26b63fd..cfaf55d 100644
--- a/addons/l10n_at_hr_payroll/tests/test_lohnsteuer.py
+++ b/addons/l10n_at_hr_payroll/tests/test_lohnsteuer.py
@@ -156,13 +156,14 @@ class LohnsteuerTest(TransactionCase):
# 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)."""
+ """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.1807, places=2)
+ self.assertAlmostEqual(svdn, 2059.41 * 0.1512, places=2)
bem = slip._l10n_at_bemessung_laufen()
self.assertAlmostEqual(bem, 2059.41 - svdn, places=2)
@@ -221,7 +222,7 @@ class LohnsteuerTest(TransactionCase):
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
+ 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(
@@ -257,13 +258,13 @@ class LohnsteuerTest(TransactionCase):
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)
+ # SV-SZ = 2.059,41 × 14,12 % (KV+PV; AV-DN 0 nach § 2a AMPFG)
self.assertAlmostEqual(
- sz_nov, (2059.41 * (1 - 0.1707) - 620.0) * 0.06, places=1)
+ 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.1707)
+ 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)
@@ -276,8 +277,8 @@ class LohnsteuerTest(TransactionCase):
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)
+ 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(
diff --git a/addons/l10n_at_hr_payroll/tests/test_sozialversicherung.py b/addons/l10n_at_hr_payroll/tests/test_sozialversicherung.py
new file mode 100644
index 0000000..c95cf90
--- /dev/null
+++ b/addons/l10n_at_hr_payroll/tests/test_sozialversicherung.py
@@ -0,0 +1,400 @@
+# Part of the odoo-at-payroll project. License: LGPL-3.
+"""GP4 SV/DB-Kern: DG-Beiträge (ÖGK 2026), SZ-Beiträge (§ 54 ASVG),
+Geringfügigkeit (§ 5 Abs 2 iVm § 810 Abs 3 ASVG) + §-53a-UV,
+beitragsfreie Bezüge § 49 Abs 3 mit Jahres-Kumulativen, Altersstichtage
+(AlVG § 1 Abs 2 lit e, ASVG § 51 Abs 6/7, IESG § 12), ALV-DN-Staffel
+(§ 2a AMPFG), Beitragsmonats-Allokation und DAG (§ 1 DAG). Rechenfälle
+s. addons/l10n_at_hr_payroll_private/docs/rechenfaelle/
+sozialversicherung.md und RECHTSQUELLEN-Privat Abschnitt 4."""
+from datetime import date, timedelta
+
+from odoo.tests import TransactionCase, tagged
+
+
+@tagged('post_install', '-at_install')
+class SozialversicherungTest(TransactionCase):
+
+ def setUp(self):
+ super().setUp()
+ self.category = self.env['hr.salary.rule.category'].create({
+ 'name': 'Test AT SV',
+ 'code': 'TEST_AT_SV',
+ })
+ 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 SV',
+ 'code': 'TEST_AT_SV',
+ 'type_id': self.structure_type.id,
+ })
+
+ def _struktur(self, code):
+ """Eigene Structure je Testfall-Stufe (Regeln kumulieren auf
+ geteilten Structures — Muster test_lohnsteuer.py)."""
+ return self.env['hr.payroll.structure'].create({
+ 'name': 'Test AT %s' % code,
+ 'code': 'TEST_AT_%s' % code,
+ 'type_id': self.structure_type.id,
+ })
+
+ def _regel(self, code, betrag, bemessung='laufend', sv49='keine',
+ beitragsmonat=None, quantity='1.0', struct=None):
+ return self.env['hr.salary.rule'].create({
+ 'name': code,
+ 'code': code,
+ 'struct_id': (struct or self.structure).id,
+ 'category_id': self.category.id,
+ 'condition_select': 'none',
+ 'l10n_at_bemessung': bemessung,
+ 'l10n_at_sv49_frei': sv49,
+ 'l10n_at_beitragsmonat': beitragsmonat,
+ 'amount_select': 'fix',
+ 'amount_fix': betrag,
+ 'quantity': quantity,
+ })
+
+ def _employee(self, version_vals=None):
+ employee = self.env['hr.employee'].create({'name': 'Test SV'})
+ 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,
+ struct=None):
+ erster = date(jahr, monat, 1)
+ naechster = (erster + timedelta(days=32)).replace(day=1)
+ slip = self.env['hr.payslip'].create({
+ 'name': 'Test-SV %d/%d' % (monat, jahr),
+ 'employee_id': employee.id,
+ 'struct_id': (struct or self.structure).id,
+ 'date_from': erster,
+ 'date_to': naechster - timedelta(days=1),
+ })
+ slip.compute_sheet()
+ if confirm:
+ slip.action_payslip_done()
+ return slip
+
+ # ------------------------------------------------------------------
+ # DG-Beiträge (ÖGK „Normalbetriebe" 2026: DG 20,98 % / Wien 21,23)
+ # ------------------------------------------------------------------
+ def test_01_svdg_gesamt(self):
+ """DG-Summe 20,98 % = KV 3,78 + PV 12,55 + AV 2,95 + UV 1,10 +
+ WF 0,50 + IESG 0,10 auf laufender, HBG-gecappter Basis."""
+ employee = self._employee()
+ self._regel('LFD', 2059.41)
+ slip = self._slip(employee, 1)
+ self.assertAlmostEqual(
+ slip._l10n_at_svdg('kv'), 2059.41 * 0.0378, places=2)
+ self.assertAlmostEqual(
+ slip._l10n_at_svdg('pv'), 2059.41 * 0.1255, places=2)
+ self.assertAlmostEqual(
+ slip._l10n_at_svdg('av'), 2059.41 * 0.0295, places=2)
+ self.assertAlmostEqual(
+ slip._l10n_at_svdg('uv'), 2059.41 * 0.011, places=2)
+ self.assertAlmostEqual(
+ slip._l10n_at_svdg('wf'), 2059.41 * 0.005, places=2)
+ self.assertAlmostEqual(
+ slip._l10n_at_svdg('iesg'), 2059.41 * 0.001, places=2)
+ self.assertAlmostEqual(
+ slip._l10n_at_svdg_gesamt(), 2059.41 * 0.2098, places=2)
+
+ def test_02_svdg_wien(self):
+ """WF-Wien 0,75 % DG ab 1.1.2026 (Satzvektor); 2025 nur 0,5 %."""
+ 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_svdg('wf'), 2059.41 * 0.0075, places=2)
+ self.assertAlmostEqual(
+ slip._l10n_at_svdg_gesamt(), 2059.41 * 0.2123, places=2)
+ # 2025: Vektor-Zeile ohne 'wien'-Eintrag → Bundesstandard 0,5 %
+ slip_2025 = self._slip(employee, 3, jahr=2025)
+ self.assertAlmostEqual(
+ slip_2025._l10n_at_svdg('wf'), 2059.41 * 0.005, places=2)
+ # HBG 2025 = 30 × 215 € = 6.450 € (KB A8, VO-Reihe § 108 Abs 3)
+ self.assertAlmostEqual(
+ slip_2025._l10n_at_svdn_basis_laufen(), 2059.41, places=2)
+ # Bundesland-Schlüssel wirkt identisch zum Company-Flag
+ slip.company_id.write(
+ {'l10n_at_wf_wien': False, 'l10n_at_bundesland': 'wien'})
+ self.assertAlmostEqual(
+ slip._l10n_at_svdg('wf'), 2059.41 * 0.0075, places=2)
+
+ def test_03_svdg_sz(self):
+ """DG auf sonstigen Bezügen: KV/PV/AV/UV/IESG = 20,48 %
+ (§ 54 ASVG; HBG monatlich + SZ-Jahreshöchstbetrag 13.860)."""
+ 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)
+ for komp, satz in (('kv', 0.0378), ('pv', 0.1255),
+ ('av', 0.0295), ('uv', 0.011),
+ ('iesg', 0.001)):
+ self.assertAlmostEqual(
+ slip_nov._l10n_at_svdg_sz(komp), 2059.41 * satz, places=2)
+ self.assertAlmostEqual(
+ slip_nov._l10n_at_svdg_sz_gesamt(), 2059.41 * 0.2048, places=2)
+ # Dezember: Jahresrest 13.860 − 2.059,41 lässt die zweite SZ zu
+ slip_dez = self._slip(employee, 12)
+ self.assertAlmostEqual(
+ slip_dez._l10n_at_svdn_basis_sz(), 2059.41, places=2)
+ # Deckel: SZ 20.000 im Januar 2027 (Jahresrest voll) → HBG 6.930
+ self._regel('SZ2', 20000.0, bemessung='sonstig')
+ slip_2027 = self._slip(employee, 1, jahr=2027)
+ self.assertAlmostEqual(
+ slip_2027._l10n_at_svdn_basis_sz(),
+ min(20000.0 + 2059.41, 6930.0, 13860.0), places=2)
+
+ # ------------------------------------------------------------------
+ # Geringfügigkeit (§ 5 Abs 2 ASVG; Aufwertung 2026 ausgesetzt) + § 53a
+ # ------------------------------------------------------------------
+ def test_04_geringfuegig_uv(self):
+ """Laufendes Entgelt ≤ GFG 551,10 €: keine SVDN/SVDG; stattdessen
+ §-53a-Abs-1-UV 6,50 €/Monat (1,1 % der allgemeinen BG)."""
+ employee = self._employee()
+ self._regel('LFD', 500.0)
+ slip = self._slip(employee, 1)
+ self.assertTrue(slip._l10n_at_geringfuegig())
+ self.assertAlmostEqual(slip._l10n_at_svdn_gesamt(), 0.0, places=2)
+ self.assertAlmostEqual(slip._l10n_at_svdg_gesamt(), 0.0, places=2)
+ self.assertAlmostEqual(
+ slip._l10n_at_svdg_uv_geringfuegig(), 6.50, places=2)
+ self.assertTrue(slip.l10n_at_geringfuegig) # stored (DAG-Suche)
+ # Über der Grenze: Vollversicherung, kein §-53a-UV
+ employee_b = self._employee()
+ struktur = self._struktur('GFG2')
+ self._regel('LFD', 600.0, struct=struktur)
+ slip_b = self._slip(employee_b, 1, struct=struktur)
+ self.assertFalse(slip_b._l10n_at_geringfuegig())
+ self.assertAlmostEqual(
+ slip_b._l10n_at_svdg_uv_geringfuegig(), 0.0, places=2)
+ self.assertAlmostEqual(
+ slip_b._l10n_at_svdg_gesamt(), 600.0 * 0.2098, places=2)
+
+ def test_05_geringfuegig_hochrechnung(self):
+ """Untermonatiger Beginn: Hochrechnung auf den Monat (lb-bes-06);
+ Verhältnis < 1 Naturalmonat: Gesamtentgelt direkt gegen die GFG."""
+ employee = self._employee(
+ {'contract_date_start': date(2026, 1, 16)})
+ self._regel('LFD', 400.0)
+ slip = self._slip(employee, 1)
+ # 400 × 30/16 = 750 > 551,10 → voll versichert; DN-AV 0 (§ 2a:
+ # 400 ≤ 2.225) → DN-Gesamt 15,12 %
+ self.assertFalse(slip._l10n_at_geringfuegig())
+ self.assertAlmostEqual(
+ slip._l10n_at_svdn_gesamt(), 400.0 * 0.1512, places=2)
+ # 250 × 30/16 = 468,75 ≤ 551,10 → geringfügig
+ struktur = self._struktur('HGR')
+ employee_b = self._employee(
+ {'contract_date_start': date(2026, 1, 16)})
+ self._regel('LFD', 250.0, struct=struktur)
+ slip_b = self._slip(employee_b, 1, struct=struktur)
+ self.assertTrue(slip_b._l10n_at_geringfuegig())
+ # auf unter einen Naturalmonat befristet: direkt gegen die GFG
+ employee_c = self._employee({
+ 'contract_date_start': date(2026, 1, 16),
+ 'contract_date_end': date(2026, 1, 25)})
+ struktur_c = self._struktur('KURZ')
+ self._regel('LFD', 500.0, struct=struktur_c)
+ slip_c = self._slip(employee_c, 1, struct=struktur_c)
+ self.assertTrue(slip_c._l10n_at_geringfuegig())
+
+ def test_06_lehrling(self):
+ """Lehrling: keine AK-Umlage (§ 61 AKG), kein UV (§ 51 Abs 6
+ ASVG); nie geringfügig (§ 5 Abs 3 ASVG)."""
+ employee = self._employee({'l10n_at_lehrling': True})
+ self._regel('LFD', 300.0)
+ slip = self._slip(employee, 1)
+ self.assertFalse(slip._l10n_at_geringfuegig())
+ self.assertAlmostEqual(slip._l10n_at_svdn('ak'), 0.0, places=2)
+ self.assertAlmostEqual(slip._l10n_at_svdg('uv'), 0.0, places=2)
+ self.assertAlmostEqual(slip._l10n_at_svdg_uv_geringfuegig(), 0.0)
+ # KV/PV/WF regulär; AV 0 (§ 2a), AK 0, IESG 0 (§ 12 Abs 2 IESG):
+ # DN 14,62 % / DG 19,78 %
+ self.assertAlmostEqual(
+ slip._l10n_at_svdn_gesamt(), 300.0 * 0.1462, places=2)
+ self.assertAlmostEqual(
+ slip._l10n_at_svdg_gesamt(), 300.0 * 0.1978, places=2)
+
+ # ------------------------------------------------------------------
+ # § 49 Abs 3 ASVG — beitragsfreie Bezugsbestandteile (Kumulative)
+ # ------------------------------------------------------------------
+ def test_07_sv49_mahlzeit(self):
+ """Mahlzeiten-Gutscheine 8 €/Arbeitstag (Z 12): 110 € auf
+ 10 Arbeitstage → 80 € beitragsfrei, 30 € Umwidmung."""
+ employee = self._employee()
+ self._regel('LFD', 2059.41)
+ self._regel('MAH', 11.0, sv49='mahlzeit', quantity='10.0')
+ slip = self._slip(employee, 1)
+ self.assertAlmostEqual(
+ slip._l10n_at_sv49_freie_laufen(), 80.0, places=2)
+ basis = slip._l10n_at_sv_basis_roh()
+ self.assertAlmostEqual(basis, 2059.41 + 110.0 - 80.0, places=2)
+ # DN-AV 0 (§ 2a: Basis 2.089,41 ≤ 2.225) → DN-Gesamt 15,12 %
+ self.assertAlmostEqual(
+ slip._l10n_at_svdn_gesamt(), basis * 0.1512, places=2)
+
+ def test_08_sv49_jahrespools(self):
+ """Jahres-Kumulative: Betriebsveranstaltung 365 €/Jahr und
+ Homeoffice-Pauschale 3 €/Tag max 100 Tage (YTD-Verbrauch)."""
+ employee = self._employee()
+ self._regel('LFD', 2059.41)
+ self._regel('BVER', 300.0, sv49='betriebsveranstaltung')
+ slip_a = self._slip(employee, 3, confirm=True)
+ self.assertAlmostEqual(
+ slip_a._l10n_at_sv49_freie_laufen(), 300.0, places=2)
+ # April: BVER-Rest 65 €; Homeoffice 70 Tage frei (3 × 70 = 210)
+ self._regel('HO', 3.0, sv49='homeoffice', quantity='70.0')
+ slip_b = self._slip(employee, 4, confirm=True)
+ self.assertAlmostEqual(
+ slip_b._l10n_at_sv49_freie_laufen(), 65.0 + 210.0, places=2)
+ # Mai: BVER-Pool verbraucht; Homeoffice-Rest 30 Tage → 90 €
+ slip_c = self._slip(employee, 5)
+ self.assertAlmostEqual(
+ slip_c._l10n_at_sv49_freie_laufen(), 90.0, places=2)
+
+ # ------------------------------------------------------------------
+ # Altersabhängige Satzausschaltungen (Stichtags-Regeln am Vertrag)
+ # ------------------------------------------------------------------
+ def test_09_alv_entfall(self):
+ """§ 1 Abs 2 lit e AlVG: AV DN+DG und IESG entfallen ab
+ Stichtag (§ 12 Abs 1 Z 4 iVm Abs 2 IESG); Nachzahlungslinien mit
+ früherem Beitragsmonat zahlen weiter AV (Anspruchsprinzip)."""
+ employee = self._employee(
+ {'l10n_at_alv_entfall_ab': date(2026, 1, 1)})
+ self._regel('LFD', 2059.41)
+ slip = self._slip(employee, 1)
+ self.assertAlmostEqual(slip._l10n_at_svdn('av'), 0.0, places=2)
+ self.assertAlmostEqual(slip._l10n_at_svdg('av'), 0.0, places=2)
+ self.assertAlmostEqual(slip._l10n_at_svdg('iesg'), 0.0, places=2)
+ # DG ohne AV+IESG: 20,98 − 2,95 − 0,10 = 17,93 %
+ self.assertAlmostEqual(
+ slip._l10n_at_svdg_gesamt(), 2059.41 * 0.1793, places=2)
+ # DN ohne AV: 18,07 − 2,95 = 15,12 %
+ self.assertAlmostEqual(
+ slip._l10n_at_svdn_gesamt(), 2059.41 * 0.1512, places=2)
+ # Nachzahlungslinie Beitragsmonat 6/2025: AV greift weiter
+ self._regel('NACH', 1000.0, beitragsmonat=date(2025, 6, 1))
+ slip2 = self._slip(employee, 2)
+ self.assertAlmostEqual(
+ slip2._l10n_at_svdg('av'), 1000.0 * 0.0295, places=2)
+
+ def test_10_uv_entfall(self):
+ """§ 51 Abs 6 ASVG: UV entfällt ab 60. LJ — laufend und SZ."""
+ employee = self._employee(
+ {'l10n_at_uv_entfall_ab': date(2026, 1, 1)})
+ self._regel('LFD', 2059.41)
+ self._regel('SZ', 2059.41, bemessung='sonstig')
+ slip = self._slip(employee, 1)
+ self.assertAlmostEqual(slip._l10n_at_svdg('uv'), 0.0, places=2)
+ self.assertAlmostEqual(slip._l10n_at_svdg_sz('uv'), 0.0, places=2)
+ self.assertAlmostEqual(
+ slip._l10n_at_svdg_gesamt(), 2059.41 * 0.1988, places=2)
+
+ def test_11_pv_bonusphase(self):
+ """§ 51 Abs 7 ASVG: PV je zur Hälfte reduziert (DG und DN)."""
+ employee = self._employee(
+ {'l10n_at_pv_bonusphase_ab': date(2026, 1, 1)})
+ self._regel('LFD', 2059.41)
+ slip = self._slip(employee, 1)
+ self.assertAlmostEqual(
+ slip._l10n_at_svdn('pv'), 2059.41 * 0.05125, places=2)
+ self.assertAlmostEqual(
+ slip._l10n_at_svdg('pv'), 2059.41 * 0.06275, places=2)
+
+ # ------------------------------------------------------------------
+ # ALV-DN-Staffel (§ 2a AMPFG — reduziert nur den DN-Anteil)
+ # ------------------------------------------------------------------
+ def test_12_av_staffel(self):
+ """Bis 2.225 €: DN-AV 0 %; bis 2.427: 1 %; bis 2.630: 2 % —
+ DG-AV bleibt stets 2,95 % (ÖGK „Mind. AV auf 0/1/2 %")."""
+ employee_a = self._employee()
+ struktur_a = self._struktur('ST1')
+ self._regel('LFD', 2000.0, struct=struktur_a)
+ slip_a = self._slip(employee_a, 1, struct=struktur_a)
+ self.assertAlmostEqual(slip_a._l10n_at_svdn('av'), 0.0, places=2)
+ self.assertAlmostEqual(
+ slip_a._l10n_at_svdg('av'), 2000.0 * 0.0295, places=2)
+ employee_b = self._employee()
+ struktur_b = self._struktur('ST2')
+ self._regel('LFD', 2500.0, struct=struktur_b)
+ slip_b = self._slip(employee_b, 1, struct=struktur_b)
+ self.assertAlmostEqual(
+ slip_b._l10n_at_svdn('av'), 2500.0 * 0.02, places=2)
+ self.assertAlmostEqual(
+ slip_b._l10n_at_svdg('av'), 2500.0 * 0.0295, places=2)
+ employee_c = self._employee()
+ struktur_c = self._struktur('ST3')
+ self._regel('LFD', 3000.0, struct=struktur_c)
+ slip_c = self._slip(employee_c, 1, struct=struktur_c)
+ self.assertAlmostEqual(
+ slip_c._l10n_at_svdn('av'), 3000.0 * 0.0295, places=2)
+ self.assertAlmostEqual(
+ slip_c._l10n_at_svdg('av'), 3000.0 * 0.0295, places=2)
+
+ # ------------------------------------------------------------------
+ # Beitragsmonats-Allokation (Anspruchsprinzip)
+ # ------------------------------------------------------------------
+ def test_13_beitragsmonat_hbg(self):
+ """Nachzahlungslinie mit Beitragsmonat 2025: HBG historisch
+ versioniert (2025: 6.450 € statt 6.930 €), je Gruppe gecapped."""
+ employee = self._employee()
+ self._regel('LFD', 7000.0)
+ self._regel('NACH', 7000.0, beitragsmonat=date(2025, 6, 1))
+ slip = self._slip(employee, 1)
+ monate = dict(slip._l10n_at_sv_basis_laufen_monate())
+ self.assertAlmostEqual(
+ monate[date(2026, 1, 1)], 6930.0, places=2)
+ self.assertAlmostEqual(
+ monate[date(2025, 6, 1)], 6450.0, places=2)
+ self.assertAlmostEqual(
+ slip._l10n_at_svdg('kv'),
+ (6930.0 + 6450.0) * 0.0378, places=2)
+
+ # ------------------------------------------------------------------
+ # Dienstgeberabgabe geringfügig Beschäftigter (§ 1 DAG)
+ # ------------------------------------------------------------------
+ def test_14_dag(self):
+ """DAG 19,4 %: Lohnsumme (BG ohne SZ) aller Geringfügigen >
+ 826,65 € (= 1,5 × GFG 551,10, TASY MINGRDAGP1) und ≥ 2 Personen."""
+ employee_a = self._employee()
+ struktur_a = self._struktur('DAGA')
+ self._regel('LFD', 500.0, struct=struktur_a)
+ self._slip(employee_a, 1, confirm=True, struct=struktur_a)
+ employee_b = self._employee()
+ struktur_b = self._struktur('DAGB')
+ self._regel('LFD', 550.0, struct=struktur_b)
+ self._slip(employee_b, 1, confirm=True, struct=struktur_b)
+ company = self.env.company
+ dag = company._l10n_at_dag(date(2026, 1, 1))
+ self.assertAlmostEqual(dag['schwelle'], 826.65, places=2)
+ self.assertEqual(dag['anzahl'], 2)
+ self.assertAlmostEqual(dag['lohnsumme'], 1050.0, places=2)
+ self.assertAlmostEqual(dag['abgabe'], 1050.0 * 0.194, places=2)
+ # Zwei Geringfügige unter der Schwelle: keine DAG
+ employee_c = self._employee()
+ struktur_c = self._struktur('DAGC')
+ self._regel('LFD', 400.0, struct=struktur_c)
+ self._slip(employee_c, 2, confirm=True, struct=struktur_c)
+ employee_d = self._employee()
+ struktur_d = self._struktur('DAGD')
+ self._regel('LFD', 400.0, struct=struktur_d)
+ self._slip(employee_d, 2, confirm=True, struct=struktur_d)
+ dag_tief = company._l10n_at_dag(date(2026, 2, 1))
+ self.assertEqual(dag_tief['anzahl'], 2)
+ self.assertAlmostEqual(dag_tief['lohnsumme'], 800.0, places=2)
+ self.assertAlmostEqual(dag_tief['abgabe'], 0.0, places=2)
+ # Nur ein Geringfügiger: keine DAG
+ dag_einzel = company._l10n_at_dag(date(2026, 3, 1))
+ self.assertAlmostEqual(dag_einzel['abgabe'], 0.0, places=2)
\ No newline at end of file