# Part of the odoo-at-payroll project. License: LGPL-3. """Lohnsteuer-Kern (GP3/AP3): § 66 Monatslohnsteuer kumulativ mit Rollung (Zuflussprinzip), § 67 sonstige Bezüge mit Sechstel- Aufrollung, § 68-Steuerfreiheiten, DN-SV als Bemessungsbestandteil. Architektur (D4-verifiziert): hr.salary.rule.struct_id ist required und rule_ids One2many — Kern-Regeln sind nicht teilbar. Der Kern liefert Compute + Parameter + Flags; die Lokalisationen binden dünne Regeln, die diese Methoden aufrufen (kein Wertefork, keine Logikfork). Laufzeit-Datenfluss: compute_sheet legt die Zeilen erst NACH der Regelschleife an — die Regeln reichen daher die Zwischensummen über ``result_rules`` (localdict, s. hr_payslip._compute_line L1225) an die Compute-Methoden; frühere (validierte) Läufe werden über deren line_ids gelesen (YTD/Rollung). 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 = Arbeitslohn − SV-DN − steuerfreie Bezüge (user-Formel „SV-Beitragsgrundlage − SV-DN"). - Bemessung SZ = sonstiger Bezug − SV-DN(SZ) (Abs 12) − 620 € (jahreskumulativ); 620–Jahressechstel 6 %, darüber LSt-lfd (Abs 10). - Aufrollung: Vormonatskorrekturen rollen über die kumulative Engine in den Zuflussmonat; SV-Allokation auf Beitragsmonate = GP4, Nachtrags-mBGM = GP7. """ from datetime import date, timedelta from odoo import fields, models class HrSalaryRule(models.Model): _inherit = 'hr.salary.rule' l10n_at_bemessung = fields.Selection( [('neutral', 'Kein Arbeitslohn (Abzug/Netto/DG)'), ('laufend', 'Laufender Arbeitslohn (§ 25/§ 66)'), ('sonstig', 'Sonstiger Bezug (§ 67)')], string='AT Bemessungsart', default='neutral', help='Laufend: §-66-Kette (Hochrechnung); sonstig: § 67 ' '(Jahressechstel/Fixsätze); neutral: kein Arbeitslohn.') l10n_at_steuerbefreiung_68 = fields.Selection( [('keine', 'Keine §-68-Steuerfreiheit'), ('szfn', '§ 68 Abs 1 (Sonn-/Feiertags-/Nachtzuschläge)'), ('ue_std', '§ 68 Abs 2 (ÜStd-Zuschläge bis 50 % Grundlohn)')], string='AT § 68-Freibetrag', default='keine') l10n_at_zuschlagsanteil = fields.Float( string='AT Zuschlagsanteil (%)', help='Anteil des Zeilenbetrags, der Zuschlag (und damit ' '§-68-potentiell steuerfrei) ist — z. B. 50 %-Regel mit ' 'Grundvergütung: 0,5/1,5 = 33,33 %.') l10n_at_st_frei = fields.Selection( [('keine', 'Keine § 3/§ 26-Steuerfreiheit'), ('mahlzeit', 'Mahlzeiten-Gutschein (§ 3 Abs 1 Z 17; 8 €/AT)'), ('rabatt', 'Mitarbeiterrabatt (§ 3 Abs 1 Z 21; 1.000 €/J)'), ('betriebsveranstaltung', 'Betriebsveranstaltung (§ 3 Abs 1 Z 14; 365 €/J)'), ('jubilaeum', 'Sachzuwendung/Jubiläum/Geschenk (§ 3 Abs 1 Z 14; 186 €/J)'), ('homeoffice', 'Homeoffice-Pauschale (§ 26 Z 9; 3 €/Tag, max 100 Tage/J)')], string='AT § 3/§ 26 (steuerfrei)', default='keine', help='Steuerfreie Bezugsbestandteile (Freibeträge): der ' 'steuerfreie Anteil wird je Bezugsmonat von der LSt-' 'Bemessungsgrundlage und von der LNK-Bemessungsgrundlage ' 'abgezogen; Grenzen als Jahres-Kumulative je Dienstnehmer ' '(at_st_grenzen). Nur der übersteigende Teil ist ' 'steuerpflichtig. Menge = Arbeitstage (Z 17/Z 9).') class HrVersion(models.Model): _inherit = 'hr.version' l10n_at_familienabsetzbetrag = fields.Selection( [('keine', 'Keine'), ('avb', 'Alleinverdienerabsetzbetrag'), ('avab', 'Alleinerzieherabsetzbetrag')], string='AT Familienabsetzbetrag', default='keine', help='AVB nur bei Partnereinkommen ≤ Grenze (§ 33 Abs 4; ' 'Nachweis mandantenseitig). Betrag nach Anzahl Kinder.') l10n_at_ueberw_nachtarbeit = fields.Boolean( string='AT Überwiegende Nachtarbeit (§ 68 Abs 6)', help='Erhöht den SZFN-Freibetrag 400 → 600 € (Nachtarbeit ' '≥ 3 h zwischen 19 und 7 Uhr, überwiegend in der NAZ).') class ResCompany(models.Model): _inherit = 'res.company' l10n_at_wf_wien = fields.Boolean( string='AT Wien (WF-Zuschlag)', help='Wiener WF-Zuschlag: WF gesamt 1,5 % (DN 0,75 %) ab ' '1. 1. 2026 statt 1,0 % (Wiener WF-Beitragstarif 2018 § 1).') class HrPayslip(models.Model): _inherit = 'hr.payslip' # ------------------------------------------------------------------ # Basis-Helfer # ------------------------------------------------------------------ def _l10n_at_param(self, code, datum=None): return self.env['hr.rule.parameter']._get_parameter_from_code( code, datum or self.date_to) def _l10n_at_linien(self, art): self.ensure_one() return self.line_ids.filtered( lambda l: l.salary_rule_id.l10n_at_bemessung == art) def _l10n_at_brutto(self, art, result_rules=None): """Arbeitslohn je Bemessungsart (user-Formel-Basis): im laufenden compute über result_rules (Zwischensummen), bei abgeschlossenen Läufen über line_ids.""" self.ensure_one() if result_rules is not None: total = 0.0 for rule in self.struct_id.rule_ids: if (rule.l10n_at_bemessung == art and rule.code in result_rules): total += result_rules[rule.code]['total'] return total return sum(self._l10n_at_linien(art).mapped('total')) def _l10n_at_lohnsteuertage(self): """Lohnsteuertage des eigenen LZP: 30 je vollem Monat (user decision; § 66 Abs 3 360-Tage-Jahr); Teilmonat = Kalendertage der Beschäftigung inkl. arbeitsfreier Tage, max 30.""" self.ensure_one() erster = self.date_from.replace(day=1) naechster = (erster + timedelta(days=32)).replace(day=1) vollen_monat = (self.date_from == erster and self.date_to == naechster - timedelta(days=1)) version = self.version_id von = max(self.date_from, version.contract_date_start or self.date_from) bis = min(self.date_to, version.contract_date_end or self.date_to) if von > bis: return 0 if vollen_monat and von == self.date_from and bis == self.date_to: return 30 return min((bis - von).days + 1, 30) def _l10n_at_ytd_slaeufe(self): """Validierte, nicht stornierte Läufe des Kalenderjahres vor diesem LZP (Rollungs-/Aufrollungsbasis; refundbewusst: Negativ-Linien des Refund-Laufs neutralisieren den Original).""" self.ensure_one() jahr = self.date_to.year return self.env['hr.payslip'].search([ ('employee_id', '=', self.employee_id.id), ('date_to', '>=', date(jahr, 1, 1)), ('date_to', '<', self.date_from), ]).filtered(lambda p: p.state not in ('draft', 'cancel')) def _l10n_at_ytd_linien_total(self, code): """YTD-Summe einer Regel über frühere Läufe (refundbewusst).""" self.ensure_one() total = 0.0 for slip in self._l10n_at_ytd_slaeufe(): total += sum( slip.line_ids.filtered( lambda l: l.code == code).mapped('total')) return total def _l10n_at_lohnsteuertage_ytd(self): self.ensure_one() return sum(slip._l10n_at_lohnsteuertage() for slip in self._l10n_at_ytd_slaeufe()) \ + self._l10n_at_lohnsteuertage() # ------------------------------------------------------------------ # § 68 — Steuerfreiheiten (Buckets je Regel-Flag, versionierte Caps) # ------------------------------------------------------------------ def _l10n_at_freibetraege_68(self, result_rules=None): """Steuerfreie Bezüge je LZP: {'szfn': x, 'ue_std': y} — Abs 1: SFN-Zuschläge bis 400 € (600 € überw. Nachtarbeit); Abs 2 iVm § 124b Z 440: ÜStd-Zuschläge bis 50 % Grundlohn, erste 15 ÜStd, max 170 € (2026; ab 2027 10/120 — versioniert).""" self.ensure_one() ergebnis = {'szfn': 0.0, 'ue_std': 0.0} grenzen = self._l10n_at_param('at_68_szfn_grenze') cap_szfn = (grenzen['grenze_ueberwiegend'] if self.version_id.l10n_at_ueberw_nachtarbeit else grenzen['grenze']) ue = self._l10n_at_param('at_68_ue_std') szfn = 0.0 betrag_ue, stunden_ue = 0.0, 0.0 for rule in self.struct_id.rule_ids: werte = None if result_rules is not None: if rule.code in result_rules: werte = result_rules[rule.code] elif rule.l10n_at_bemessung != 'neutral': line = self.line_ids.filtered(lambda l: l.code == rule.code) if line: werte = {'total': sum(line.mapped('total')), 'quantity': sum(line.mapped('quantity'))} if not werte: continue anteil = (rule.l10n_at_zuschlagsanteil or 0.0) / 100.0 if rule.l10n_at_steuerbefreiung_68 == 'szfn': szfn += werte['total'] * anteil elif rule.l10n_at_steuerbefreiung_68 == 'ue_std': betrag_ue += werte['total'] * anteil stunden_ue += werte.get('quantity', 0.0) ergebnis['szfn'] = min(max(szfn, 0.0), cap_szfn) if stunden_ue > float(ue['stunden']): betrag_ue = betrag_ue * float(ue['stunden']) / stunden_ue ergebnis['ue_std'] = min(max(betrag_ue, 0.0), float(ue['betrag'])) return ergebnis # ------------------------------------------------------------------ # § 3/§ 26 — Steuerfreiheit (Buckets je Regel-Flag, versionierte # Caps; Jahres-Kumulative — Spieglung der §-49-SV-Seite) # ------------------------------------------------------------------ def _l10n_at_st_frei_ytd(self, kategorie, menge=False): """Jahresverbrauch einer §-3/§-26-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_st_frei == kategorie) if menge: total += sum(linien.mapped('quantity')) else: total += sum(linien.mapped('total')) return total def _l10n_at_st_frei_freie_laufen(self, result_rules=None): """Steuerfreie Anteile laufender Bezüge dieses LZP: je Kategorie Tages-/Jahresgrenzen (at_st_grenzen) minus Jahresverbrauch; Freibeträge — nur der übersteigende Teil steuerpflichtig (lb-sac-07/17, lb-ges-02, lb-sac-02).""" self.ensure_one() grenzen = self.env['hr.rule.parameter']._get_parameter_from_code( 'at_st_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_st_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_st_frei_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'}.get(kategorie) rest = max( float(grenzen.get(pool)) - self._l10n_at_st_frei_ytd(kategorie), 0.0) frei = min(total, rest) frei_gesamt += max(frei, 0.0) return frei_gesamt # ------------------------------------------------------------------ # DN-SV (Bemessungsbestandteil § 62) — Basis/Sätze/Geringfügigkeit/ # §-49-Kumulative/DG-Seite: models/sozialversicherung.py (GP4) # ------------------------------------------------------------------ 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); Wien-WF über Satzvektor (l10n_at_wf_wien/Bundesland, GP4).""" self.ensure_one() return self._l10n_at_sv_beitrag('dn', komponente, result_rules) def _l10n_at_svdn_gesamt(self, result_rules=None): self.ensure_one() return sum(self._l10n_at_svdn(k, result_rules) for k in ('kv', 'pv', 'av', 'ak', 'wf')) def _l10n_at_svdn_basis_sz(self, result_rules=None): """SV-BG sonstige Bezüge: HBG monatlich und SZ-Jahreshöchstbetrag (13.860) minus YTD-SZ-Basen (Anspruchsprinzip-Vorlauf GP4).""" self.ensure_one() sz = max(self._l10n_at_sv_brutto('sonstig', result_rules), 0.0) hbg = float(self._l10n_at_param('at_sv_hbg_monatlich')) jahreshbg = float(self._l10n_at_param('at_sv_hbg_sz_jaehrlich')) for slip in self._l10n_at_ytd_slaeufe(): jahreshbg -= max(slip._l10n_at_sv_brutto('sonstig'), 0.0) return min(sz, hbg, max(jahreshbg, 0.0)) 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); 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(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 # ------------------------------------------------------------------ # Bemessung (user-Formeln) — laufend / sonstig # ------------------------------------------------------------------ def _l10n_at_bemessung_laufen(self, result_rules=None): """LSt-Bemessung laufend: Arbeitslohn − SV-DN (§ 62-Kette, inkl. AK/WF) − steuerfreie Bezüge § 68 − steuerfreie Sachbezüge § 3/§ 26 (Freibeträge, Jahres-Kumulative). Der volle Arbeitslohn ist lohnsteuerlich unbegrenzt (keine HBG-Cap für die LSt; die Cap gilt nur für die SV-Sätze).""" self.ensure_one() freibe = self._l10n_at_freibetraege_68(result_rules) return (self._l10n_at_brutto('laufend', result_rules) - self._l10n_at_svdn_gesamt(result_rules) - freibe['szfn'] - freibe['ue_std'] - self._l10n_at_st_frei_freie_laufen(result_rules)) def _l10n_at_sz_bemessung(self, result_rules=None): """LSt-Bemessung sonstiger Bezug: Bezug − SV-DN (Abs 12); Freibetrag/Sechstel-Portionierung erfolgt in _l10n_at_lst_sonstige.""" self.ensure_one() return (self._l10n_at_brutto('sonstig', result_rules) - self._l10n_at_svdn_sz(result_rules)) def _l10n_at_brutto_laufen_ytd(self, result_rules=None): """Kumulierte laufende Bruttobezüge des Jahres (Sechstel-Basis ist Brutto, § 67 Abs 2 — inkl. Auszahlungsmonat-Laufendem).""" self.ensure_one() total = self._l10n_at_brutto('laufend', result_rules) for slip in self._l10n_at_ytd_slaeufe(): total += slip._l10n_at_brutto('laufend') return total def _l10n_at_sechstel(self, result_rules=None): """Jahressechstel = kumulierte laufende Brutto × 60 ÷ Lohnsteuertage (YTD inkl. aktueller LZP).""" self.ensure_one() tage = self._l10n_at_lohnsteuertage_ytd() if not tage: return 0.0 return self._l10n_at_brutto_laufen_ytd(result_rules) * 60.0 / tage # ------------------------------------------------------------------ # § 33 Tarif + Absetzbeträge # ------------------------------------------------------------------ def _l10n_at_tarif(self, jbg): """§ 33 Abs 1: Zonentarif auf die Jahresbemessungsgrundlage.""" self.ensure_one() zonen = self._l10n_at_param('at_lst_tarif')['zonen'] steuer = 0.0 for index, (grenze, satz) in enumerate(zonen): obere = zonen[index + 1][0] if index + 1 < len(zonen) else jbg if jbg > grenze: steuer += (min(jbg, obere) - grenze) * float(satz) / 100.0 return steuer def _l10n_at_absetzbetraege(self, jbg): """§ 66 zitierte Absetzbeträge: Verkehrsabsetzbetrag (496 €) + AVB/AVAB je Vertrags-Feld und Kinderzahl (§ 33 Abs 4/5).""" self.ensure_one() absetz = self._l10n_at_param('at_lst_absetzbetraege') betrag = float(absetz['verkehrsabsetzbetrag']) fam = self.version_id.l10n_at_familienabsetzbetrag kinder = self.version_id.children or 0 if fam in ('avb', 'avab') and kinder > 0: if kinder == 1: betrag += float(absetz['avb_kind1']) else: betrag += float(absetz['avb_kinder2']) \ + (kinder - 2) * float(absetz['avb_weiteres_kind']) return betrag # ------------------------------------------------------------------ # § 66 Monatslohnsteuer — kumulativ mit Rollung # ------------------------------------------------------------------ def _l10n_at_lst_laufend(self, result_rules=None): """LSt auf laufende Bezüge (§ 66): kumulative Hochrechnung (360-Tage-Jahr) minus bereits einbehaltene LSt — Vormonats- korrekturen rollen automatisch in den Zuflussmonat (Aufrollung, user decision). §-67-Bezüge unter der Freigrenze sowie der Sechstel-Überschuss (§ 67 Abs 10) laufen tarifisch hier mit.""" self.ensure_one() bem_ytd = self._l10n_at_bemessung_laufen(result_rules) sz_tarifisch_aktuell = self._l10n_at_sz_tarifisch_aktuell(result_rules) for slip in self._l10n_at_ytd_slaeufe(): bem_ytd += (slip._l10n_at_bemessung_laufen() + slip._l10n_at_sz_tarifisch_aktuell()) bem_ytd += sz_tarifisch_aktuell tage = self._l10n_at_lohnsteuertage_ytd() if not tage or bem_ytd <= 0: return 0.0 faktor = 360.0 / tage pauschalen = self._l10n_at_param('at_lst_pauschalen') jbg = (bem_ytd * faktor - float(pauschalen['werbepauschbetrag']) - float(pauschalen['sonderausgaben_pauschbetrag'])) steuer = max(0.0, self._l10n_at_tarif(jbg) - self._l10n_at_absetzbetraege(jbg)) soll_kumuliert = steuer / faktor bereits = self._l10n_at_ytd_linien_total('LST_LFD') return max(0.0, soll_kumuliert - bereits) def _l10n_at_sz_tarifisch_aktuell(self, result_rules=None): """Anteil des aktuellen §-67-Bezugs, der tarifisch (LSt-lfd) läuft: (a) gesamter Bezug bei Sechstel ≤ Freigrenze (2.615 €, Pauschalierung unterbleibt) oder (b) Überschuss über das Jahressechstel (§ 67 Abs 10).""" self.ensure_one() sz_bem = self._l10n_at_sz_bemessung(result_rules) if sz_bem <= 0: return 0.0 sechstel = self._l10n_at_sechstel(result_rules) freigrenze = float(self._l10n_at_param('at_lst_67_freigrenze')) if sechstel <= freigrenze: return sz_bem ytd_sz_bem = self._l10n_at_ytd_sz_bemessung() verbleibend = max(sechstel - ytd_sz_bem, 0.0) return max(sz_bem - verbleibend, 0.0) def _l10n_at_ytd_sz_bemessung(self): """Kumulierte §-67-Bemessungen früherer Läufe (für Sechstel- Verbrauch und Aufrollung).""" self.ensure_one() total = 0.0 for slip in self._l10n_at_ytd_slaeufe(): total += slip._l10n_at_sz_bemessung() return total # ------------------------------------------------------------------ # § 67 — sonstige Bezüge (Fixsätze im Sechstel + Aufrollung) # ------------------------------------------------------------------ def _l10n_at_lst_sonstige(self, result_rules=None): """LSt auf sonstige Bezüge: Fixsätze 0/6/27/35,75 % auf die Bemessung (Abs 12-SV vorher, Freibetrag 620 € jahreskumulativ) innerhalb des Jahressechstels; Neuberechnung des gesamten Jahres-SZ-Steuer bei jeder SZ-Zahlung (Günstig-Aufrollung, lb-son-04): Soll = f(Jahres-Bemessung, aktuelles Sechstel) minus bereits gezahlte SZ-Steuer.""" self.ensure_one() sz_bem = self._l10n_at_sz_bemessung(result_rules) if sz_bem <= 0: return 0.0 sechstel = self._l10n_at_sechstel(result_rules) freigrenze = float(self._l10n_at_param('at_lst_67_freigrenze')) if sechstel <= freigrenze: return 0.0 # tarifisch — läuft über LST_LFD ytd_bem = max(self._l10n_at_ytd_sz_bemessung(), 0.0) total_in_sechstel = min(ytd_bem + sz_bem, sechstel) fixsaetze = self._l10n_at_param('at_lst_67_fixsaetze') freibetrag = float(fixsaetze['freibetrag']) soll_steuer = 0.0 offen = max(total_in_sechstel - freibetrag, 0.0) for breite, satz in fixsaetze['pakete']: portion = min(offen, float(breite)) soll_steuer += portion * float(satz) / 100.0 offen -= portion if offen <= 0: break bereits = self._l10n_at_ytd_linien_total('LST_SZ') return max(soll_steuer - bereits, 0.0) # ------------------------------------------------------------------ # Nachzahlungsfälle (plan 4.3 — Rollung automatisch; Fünftelregelung # § 67 Abs 8 lit c als Utility; 15.2.-Cutoff/Willkür→Tarif GP8) # ------------------------------------------------------------------ def _l10n_at_fuenftelregelung(self, betrag): """§ 67 Abs 8 lit c: zwangsläufige Verschiebung eines sonstigen Bezugs über den 15. 2. — 1/5 des begünstigten Teils steuerfrei, 4/5 mit Tarif (Utility für GP8-Nachzahlungskonfiguration).""" self.ensure_one() return betrag * 4.0 / 5.0