# 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))