From 547c48e45699dc326950b3431cc4a83efe5b4415 Mon Sep 17 00:00:00 2001 From: fegger Date: Fri, 11 Sep 2026 22:24:03 +0200 Subject: [PATCH] [IMP] l10n_at_hr_payroll: GP3 Lohnsteuer-Kern (tariff, cumulative monthly tax, 67 sechstel engine, 68 buckets) + TASY runtime fixes --- addons/l10n_at_hr_payroll/__manifest__.py | 3 +- .../data/rule_parameters_lst_2026.xml | 106 +++++ addons/l10n_at_hr_payroll/models/__init__.py | 3 +- .../l10n_at_hr_payroll/models/lohnsteuer.py | 418 ++++++++++++++++++ addons/l10n_at_hr_payroll/models/tasy.py | 15 +- addons/l10n_at_hr_payroll/tests/__init__.py | 3 +- .../tests/test_lohnsteuer.py | 284 ++++++++++++ .../tests/test_tasy_import.py | 12 +- 8 files changed, 833 insertions(+), 11 deletions(-) create mode 100644 addons/l10n_at_hr_payroll/data/rule_parameters_lst_2026.xml create mode 100644 addons/l10n_at_hr_payroll/models/lohnsteuer.py create mode 100644 addons/l10n_at_hr_payroll/tests/test_lohnsteuer.py diff --git a/addons/l10n_at_hr_payroll/__manifest__.py b/addons/l10n_at_hr_payroll/__manifest__.py index fe81106..09d1f9d 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.1.0.0', + 'version': '19.0.2.0.0', 'author': 'ixsol', 'website': 'https://gem360.at', 'category': 'Human Resources/Payroll', @@ -31,6 +31,7 @@ Kein VRV-Bezug: die Gemeinde-Buchhaltung (Anlage 3b, Ansatz/MVAG) läuft 'data': [ 'security/ir.model.access.csv', 'data/rule_parameters_2026.xml', + 'data/rule_parameters_lst_2026.xml', 'data/tasy_mapping.xml', 'views/tasy_views.xml', ], diff --git a/addons/l10n_at_hr_payroll/data/rule_parameters_lst_2026.xml b/addons/l10n_at_hr_payroll/data/rule_parameters_lst_2026.xml new file mode 100644 index 0000000..cc0e1fd --- /dev/null +++ b/addons/l10n_at_hr_payroll/data/rule_parameters_lst_2026.xml @@ -0,0 +1,106 @@ + + + + + + + + + + + + EStG § 33 Abs 1 Tarif (Zonen) + at_lst_tarif + + + + + 2026-01-01 + {'zonen': [[0.0, 0.0], [13539.0, 20.0], [21992.0, 30.0], [36458.0, 40.0], [70365.0, 48.0], [104859.0, 50.0], [1000000.0, 55.0]]} + + + + + Lohnsteuer Jahrespauschalen (Werbe-/SA-Pauschbetrag) + at_lst_pauschalen + + + + + 2026-01-01 + {'werbepauschbetrag': 132.0, 'sonderausgaben_pauschbetrag': 60.0} + + + + + Lohnsteuer Absetzbeträge (§ 33 Abs 4/5 idF) + at_lst_absetzbetraege + + + + + 2026-01-01 + {'verkehrsabsetzbetrag': 496.0, 'verkehrsabsetzbetrag_pendler': 853.0, 'avb_kind1': 612.0, 'avb_kinder2': 828.0, 'avb_weiteres_kind': 273.0} + + + + + EStG § 67 Abs 1 Fixsätze sonstige Bezüge + at_lst_67_fixsaetze + + + + + 2026-01-01 + {'freibetrag': 620.0, 'pakete': [[24380.0, 6.0], [25000.0, 27.0], [33333.0, 35.75]]} + + + + + EStG § 67 Abs 1 Freigrenze Jahressechstel + at_lst_67_freigrenze + + + + + 2026-01-01 + 2615.0 + + + + + EStG § 68 Abs 1 SZFN-Freibetrag + at_68_szfn_grenze + + + + + 2026-01-01 + {'grenze': 400.0, 'grenze_ueberwiegend': 600.0} + + + + + EStG § 68 Abs 2 ÜStd-Freibetrag (Stunden/Betrag) + at_68_ue_std + + + + + 2026-01-01 + {'stunden': 15.0, 'betrag': 170.0} + + + + 2027-01-01 + {'stunden': 10.0, 'betrag': 120.0} + + \ 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 bdcf067..a2b8219 100644 --- a/addons/l10n_at_hr_payroll/models/__init__.py +++ b/addons/l10n_at_hr_payroll/models/__init__.py @@ -1,2 +1,3 @@ # Part of the odoo-at-payroll project. License: LGPL-3. -from . import tasy \ No newline at end of file +from . import tasy +from . import lohnsteuer \ 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 new file mode 100644 index 0000000..559ebca --- /dev/null +++ b/addons/l10n_at_hr_payroll/models/lohnsteuer.py @@ -0,0 +1,418 @@ +# 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 %.') + + +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 + + # ------------------------------------------------------------------ + # DN-SV (Bemessungsbestandteil § 62; Werte at_sv_* — GP4 baut + # DG-Seite/Geringfügigkeit/beitragsfreie Kumulative darauf) + # ------------------------------------------------------------------ + 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).""" + 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 + + 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_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_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).""" + self.ensure_one() + 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')) + 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. 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']) + + 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 \ No newline at end of file diff --git a/addons/l10n_at_hr_payroll/models/tasy.py b/addons/l10n_at_hr_payroll/models/tasy.py index 37daa21..2dc713a 100644 --- a/addons/l10n_at_hr_payroll/models/tasy.py +++ b/addons/l10n_at_hr_payroll/models/tasy.py @@ -1,6 +1,8 @@ # Part of the odoo-at-payroll project. License: LGPL-3. from lxml import etree +import base64 + from odoo import _, api, fields, models from odoo.exceptions import UserError @@ -101,7 +103,14 @@ class L10nAtPayrollTasyImportWizard(models.TransientModel): def _tasy_root(self): self.ensure_one() parser = etree.XMLParser(resolve_entities=False, no_network=True) - return etree.fromstring(self.file, parser) + data = self.file or b'' + if isinstance(data, str): + data = data.encode() + # Binary kann XML-Bytes ODER base64 (str/Bytes) liefern: + # dekodieren, wenn kein '<' am Anfang steht. + if not data.lstrip().startswith(b'<'): + data = base64.b64decode(data) + return etree.fromstring(data, parser) @staticmethod def _tasy_text(node, tag): @@ -233,7 +242,9 @@ class L10nAtPayrollTasyImportWizard(models.TransientModel): self.ensure_one() old = parameter_obj._get_parameter_from_code(code, date_from, raise_if_not_found=False) - old_value = old.parameter_value if old else '' + # _get_parameter_from_code liefert den geparsten Wert (float/dict) + # oder None — nicht den Parameter-Record. + old_value = repr(old) if old is not None else '' old_float = None try: old_float = float(old_value) diff --git a/addons/l10n_at_hr_payroll/tests/__init__.py b/addons/l10n_at_hr_payroll/tests/__init__.py index 34982dd..5ced461 100644 --- a/addons/l10n_at_hr_payroll/tests/__init__.py +++ b/addons/l10n_at_hr_payroll/tests/__init__.py @@ -1,2 +1,3 @@ # Part of the odoo-at-payroll project. License: LGPL-3. -from . import test_tasy_import \ No newline at end of file +from . import test_tasy_import +from . import test_lohnsteuer \ 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 new file mode 100644 index 0000000..26b63fd --- /dev/null +++ b/addons/l10n_at_hr_payroll/tests/test_lohnsteuer.py @@ -0,0 +1,284 @@ +# Part of the odoo-at-payroll project. License: LGPL-3. +"""GP3 Lohnsteuer-Kern: § 33 Tarif, Absetzbeträge, § 66 kumulative +Monatslohnsteuer (Rollung), § 68-Freibeträge, § 67 Sechstel/Fixsätze +mit Günstig-Aufrollung, DN-SV-Basis. Rechenfälle s. +personalverrechnung/RECHTSQUELLEN-Privat.md Abschnitt 5 (P0-GP3).""" +from datetime import date, timedelta + +from odoo.tests import TransactionCase, tagged + + +@tagged('post_install', '-at_install') +class LohnsteuerTest(TransactionCase): + + def setUp(self): + super().setUp() + self.category = self.env['hr.salary.rule.category'].create({ + 'name': 'Test AT Kern', + 'code': 'TEST_AT_KERN', + }) + self.structure_type = self.env['hr.payroll.structure.type'].create({ + 'name': 'Test AT Kern Type', + }) + self.structure = self.env['hr.payroll.structure'].create({ + 'name': 'Test AT Lohnsteuer', + 'code': 'TEST_AT_LST', + 'type_id': self.structure_type.id, + }) + + def _regel(self, code, betrag, bemessung='laufend', flag68='keine', + anteil=0.0, quantity='1.0', python=None): + vals = { + 'name': code, + 'code': code, + 'struct_id': self.structure.id, + 'category_id': self.category.id, + 'condition_select': 'none', + 'l10n_at_bemessung': bemessung, + 'l10n_at_steuerbefreiung_68': flag68, + 'l10n_at_zuschlagsanteil': anteil, + } + if python: + vals.update({'amount_select': 'code', + 'amount_python_compute': python}) + else: + vals.update({'amount_select': 'fix', 'amount_fix': betrag, + 'quantity': quantity}) + return self.env['hr.salary.rule'].create(vals) + + def _employee(self, version_vals=None): + employee = self.env['hr.employee'].create({'name': 'Test LSt'}) + version = self.env['hr.version'].search( + [('employee_id', '=', employee.id)], limit=1) + if not version: + version = self.env['hr.version'].create({ + 'employee_id': employee.id, + 'date_start': date(2020, 1, 1)}) + version.write({'contract_date_start': date(2020, 1, 1)}) + if version_vals: + version.write(version_vals) + return employee + + def _slip(self, employee, monat, jahr=2026, confirm=False): + erster = date(jahr, monat, 1) + naechster = (erster + timedelta(days=32)).replace(day=1) + slip = self.env['hr.payslip'].create({ + 'name': 'Test-LSt %d/%d' % (monat, jahr), + 'employee_id': employee.id, + 'struct_id': self.structure.id, + 'date_from': erster, + 'date_to': naechster - timedelta(days=1), + }) + slip.compute_sheet() + if confirm: + slip.action_payslip_done() + return slip + + def _total(self, slip, code): + return sum(slip.line_ids.filtered( + lambda l: l.code == code).mapped('total')) + + # ------------------------------------------------------------------ + # § 33 Tarif / Absetzbeträge + # ------------------------------------------------------------------ + def test_01_tarif_zonen(self): + employee = self._employee() + slip = self._slip(employee, 1) + self.assertAlmostEqual(slip._l10n_at_tarif(13539.0), 0.0, places=2) + # 15.000: 20 % auf 1.461 = 292,20 + self.assertAlmostEqual(slip._l10n_at_tarif(15000.0), 292.20, places=2) + # 30.000: 1.690,60 + (30.000−21.992) × 0,30 = 4.093,00 + self.assertAlmostEqual(slip._l10n_at_tarif(30000.0), 4093.00, places=2) + # 80.000: 1.690,60 + 4.339,80 + 13.562,80 + 4.624,80 = 24.218,00 + self.assertAlmostEqual(slip._l10n_at_tarif(80000.0), 24218.00, places=2) + + def test_02_absetzbetraege(self): + employee = self._employee() + slip = self._slip(employee, 1) + self.assertAlmostEqual(slip._l10n_at_absetzbetraege(20000.0), 496.0, places=2) + version = slip.version_id + version.write({'l10n_at_familienabsetzbetrag': 'avb', 'children': 1}) + self.assertAlmostEqual( + slip._l10n_at_absetzbetraege(20000.0), 496.0 + 612.0, places=2) + version.write({'children': 3}) + self.assertAlmostEqual( + slip._l10n_at_absetzbetraege(20000.0), + 496.0 + 828.0 + 273.0, places=2) + + # ------------------------------------------------------------------ + # Lohnsteuertage (user decision: 30 je vollem Monat) + # ------------------------------------------------------------------ + def test_03_lohnsteuertage(self): + employee = self._employee() + slip = self._slip(employee, 1) + self.assertEqual(slip._l10n_at_lohnsteuertage(), 30) + slip.version_id.write({ + 'contract_date_start': date(2026, 1, 16)}) + # 16.–31. 1. = 16 Kalendertage (inkl. arbeitsfreier, § 66 Abs 3) + self.assertEqual(slip._l10n_at_lohnsteuertage(), 16) + slip.version_id.write({ + 'contract_date_start': date(2026, 1, 1), + 'contract_date_end': date(2026, 1, 10)}) + self.assertEqual(slip._l10n_at_lohnsteuertage(), 10) + + # ------------------------------------------------------------------ + # § 68 Freibeträge (Buckets über Regel-Flags) + # ------------------------------------------------------------------ + def test_68_freibetraege(self): + employee = self._employee() + self._regel('L68A', 300.0, flag68='szfn', anteil=50.0) + self._regel('L68B', 300.0, flag68='szfn', anteil=50.0) # 300 gesamt > 400? 2x150=300 + self._regel('L68C', 41.19, flag68='ue_std', anteil=33.33, quantity='2.0') + slip = self._slip(employee, 1) + freibe = slip._l10n_at_freibetraege_68() + # szfn: 150 + 150 = 300 ≤ 400 + self.assertAlmostEqual(freibe['szfn'], 300.0, places=2) + # ue_std: 41,19 × 2 h? total = 41,19 × 2 = 82,38; Anteil 33,33 % + # → 27,46; 2 h ≤ 15; ≤ 170 + self.assertAlmostEqual(freibe['ue_std'], 27.46, places=2) + slip.version_id.write({'l10n_at_ueberw_nachtarbeit': True}) + freibe = slip._l10n_at_freibetraege_68() + self.assertAlmostEqual(freibe['szfn'], 300.0, places=2) + + def test_68_ue_std_cap(self): + """§ 68 Abs 2 iVm § 124b Z 440: 15 ÜStd/170 € (2026).""" + employee = self._employee() + # 20 ÜStd à 41,19 (Anteil 33,33 %) → Zuschlag 274,60; > 15 h + # → anteilig 15/20; cap 170 greift + self._regel('LUE', 41.19, flag68='ue_std', anteil=33.33, quantity='20.0') + slip = self._slip(employee, 1) + freibe = slip._l10n_at_freibetraege_68() + self.assertAlmostEqual( + freibe['ue_std'], min(41.19 * 20 * 0.3333 * 15 / 20, 170.0), places=2) + self.assertAlmostEqual(freibe['ue_std'], 170.0, places=1) + + # ------------------------------------------------------------------ + # DN-SV / Bemessung (user-Formel) + # ------------------------------------------------------------------ + def test_05_svdn_und_bemessung(self): + """SV-DN 18,07 % (KV/PV/AV/AK/WF) auf laufender Basis; Bemessung + = Arbeitslohn − SV-DN (user-Formel).""" + 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) + bem = slip._l10n_at_bemessung_laufen() + self.assertAlmostEqual(bem, 2059.41 - svdn, places=2) + + def test_06_svdn_wien_wf(self): + employee = self._employee() + self._regel('LFD', 2059.41) + slip = self._slip(employee, 1) + slip.company_id.write({'l10n_at_wf_wien': True}) + self.assertAlmostEqual( + slip._l10n_at_svdn('wf'), 2059.41 * 0.0075, places=2) + + # ------------------------------------------------------------------ + # § 66 kumulative Monatslohnsteuer + Rollung + # ------------------------------------------------------------------ + def test_07_lst_laufend_monat(self): + """Vollmonat: JBG = Bemessung × 12 − 192; Tarif − Verkehrs-AB.""" + employee = self._employee() + self._regel('LFD', 2059.41) + slip = self._slip(employee, 1) + svdn = slip._l10n_at_svdn_gesamt() + bem = 2059.41 - svdn + jbg = bem * 12 - 132 - 60 + erwartet = max(0.0, (0.20 * (jbg - 13539)) - 496.0) / 12 + self.assertAlmostEqual( + slip._l10n_at_lst_laufend(), erwartet, places=2) + + def test_08_lst_rollung_zweiter_monat(self): + """Kumulativ mit LST-Linie: Februar zahlt die Differenz zum + Jänner-Soll (Rollung über result_rules-Mechanik).""" + employee = self._employee() + self._regel('LFD', 2059.41) + self._regel( + 'LST_LFD', 0.0, bemessung='neutral', + python='result = payslip._l10n_at_lst_laufend(result_rules)') + slip_jan = self._slip(employee, 1, confirm=True) + jan = self._total(slip_jan, 'LST_LFD') + slip_feb = self._slip(employee, 2) + feb = self._total(slip_feb, 'LST_LFD') + # Identisches Gehalt: Feb-Soll ≈ Jan-Soll (kumulative Neuberechnung + # minus bereits einbehaltene LSt) + self.assertAlmostEqual(feb, jan, places=2) + # Rollung: dritter Monat zieht die Differenz nach + slip_mar = self._slip(employee, 3) + self.assertAlmostEqual(self._total(slip_mar, 'LST_LFD'), jan, places=2) + + # ------------------------------------------------------------------ + # § 67 sonstige Bezüge + # ------------------------------------------------------------------ + def test_09_lst_sonstige_b13(self): + """B13 über Freigrenze: Fixsatz 6 % nach 620-Freibetrag; + Abs 12: SV-DN vom SZ vorher. SZ-Regel erst NACH dem Oktober- + lauf anlegen (sonst zahlt Oktober fälschlich SZ).""" + employee = self._employee() + self._regel('LFD', 2059.41) + slip_okt = self._slip(employee, 10, confirm=True) + self._regel('SZ', 2059.41, bemessung='sonstig') + slip_nov = self._slip(employee, 11) + sz_bem = slip_nov._l10n_at_sz_bemessung() + sv_sz = 2059.41 * 0.1707 # KV+PV+AV auf SZ-Basis + self.assertAlmostEqual(sz_bem, 2059.41 - sv_sz, places=2) + # Sechstel = 2 × 2059,41 × 60 ÷ 60 = 4.118,82 > Freigrenze + self.assertAlmostEqual( + slip_nov._l10n_at_sechstel(), 2 * 2059.41, places=2) + erwartet = (sz_bem - 620.0) * 0.06 + self.assertAlmostEqual( + slip_nov._l10n_at_lst_sonstige(), erwartet, places=2) + self.assertAlmostEqual(slip_nov._l10n_at_sz_tarifisch_aktuell(), 0.0, places=2) + + def test_10_sechstel_freigrenze_tarifisch(self): + """Sechstel ≤ 2.615 €: Pauschalierung unterbleibt — SZ läuft + tarifisch über die §-66-Kette.""" + employee = self._employee() + self._regel('LFD', 500.0) + self._regel('SZ', 300.0, bemessung='sonstig') + slip = self._slip(employee, 1) + # Sechstel = 500 × 60/30 = 1.000 ≤ 2.615 + self.assertAlmostEqual(slip._l10n_at_sechstel(), 1000.0, places=2) + self.assertAlmostEqual(slip._l10n_at_lst_sonstige(), 0.0, places=2) + sz_bem = slip._l10n_at_sz_bemessung() + self.assertAlmostEqual( + slip._l10n_at_sz_tarifisch_aktuell(), sz_bem, places=2) + + def test_11_guenstig_aufrollung_zweite_sz(self): + """Günstig-Aufrollung: zweite SZ-Zahlung rechnet die Jahres- + SZ-Steuer mit dem dann aktuellen Sechstel neu.""" + employee = self._employee() + self._regel('LFD', 2059.41) + slip_okt = self._slip(employee, 10, confirm=True) + self._regel('SZ', 2059.41, bemessung='sonstig') + self._regel( + 'LST_SZ', 0.0, bemessung='neutral', + python='result = payslip._l10n_at_lst_sonstige(result_rules)') + slip_nov = self._slip(employee, 11, confirm=True) + sz_nov = self._total(slip_nov, 'LST_SZ') + # SV-SZ = 2.059,41 × 17,07 % = 351,54 (KV+PV+AV auf SZ-Basis) + self.assertAlmostEqual( + sz_nov, (2059.41 * (1 - 0.1707) - 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) + soll_jahr = (jahres_bem - 620.0) * 0.06 + self.assertAlmostEqual(sz_dez, soll_jahr - sz_nov, places=1) + + def test_12_sz_ueberschuss_tarifisch(self): + """§ 67 Abs 10: SZ über dem Sechstel läuft tarifisch (LSt-lfd); + Sechstel muss über der Freigrenze 2.615 € liegen.""" + employee = self._employee() + self._regel('LFD', 2000.0) + self._regel('SZ', 6000.0, bemessung='sonstig') + slip = self._slip(employee, 1) + # Sechstel = 2.000 × 60/30 = 4.000 > 2.615 → Pauschalierung + self.assertAlmostEqual(slip._l10n_at_sechstel(), 4000.0, places=2) + sz_bem = slip._l10n_at_sz_bemessung() # 6.000 − SV-SZ (1.024,20) + self.assertAlmostEqual(sz_bem, 6000.0 - 6000.0 * 0.1707, places=2) + self.assertAlmostEqual( + slip._l10n_at_sz_tarifisch_aktuell(), sz_bem - 4000.0, places=2) + self.assertAlmostEqual( + slip._l10n_at_lst_sonstige(), (4000.0 - 620.0) * 0.06, places=1) \ No newline at end of file diff --git a/addons/l10n_at_hr_payroll/tests/test_tasy_import.py b/addons/l10n_at_hr_payroll/tests/test_tasy_import.py index 5385d86..3cf39ab 100644 --- a/addons/l10n_at_hr_payroll/tests/test_tasy_import.py +++ b/addons/l10n_at_hr_payroll/tests/test_tasy_import.py @@ -106,22 +106,22 @@ class TasyImportTest(TransactionCase): parameter = self.env['hr.rule.parameter'] stichtag = date(2026, 7, 1) self.assertEqual( - float(parameter._get_parameter_from_code('at_sv_kv_dn', stichtag).parameter_value), + float(parameter._get_parameter_from_code('at_sv_kv_dn', stichtag)), 3.87) self.assertEqual( - float(parameter._get_parameter_from_code('at_sv_kv_dg', stichtag).parameter_value), + float(parameter._get_parameter_from_code('at_sv_kv_dg', stichtag)), 3.78) self.assertEqual( - float(parameter._get_parameter_from_code('at_sv_gfg_monatlich', stichtag).parameter_value), + float(parameter._get_parameter_from_code('at_sv_gfg_monatlich', stichtag)), 551.10) self.assertEqual( - float(parameter._get_parameter_from_code('at_sv_hbg_monatlich', stichtag).parameter_value), + float(parameter._get_parameter_from_code('at_sv_hbg_monatlich', stichtag)), 6930.00) self.assertEqual( - float(parameter._get_parameter_from_code('at_sv_uv_dg', stichtag).parameter_value), + float(parameter._get_parameter_from_code('at_sv_uv_dg', stichtag)), 1.10) self.assertEqual( - float(parameter._get_parameter_from_code('at_sv_iesg_dg', stichtag).parameter_value), + float(parameter._get_parameter_from_code('at_sv_iesg_dg', stichtag)), 0.10) def test_02_wizard_parse_fixture(self):