From 8efea77b6cf5c598be975aeb013b6af5a277b5b0 Mon Sep 17 00:00:00 2001 From: fegger Date: Mon, 14 Sep 2026 12:22:15 +0200 Subject: [PATCH] =?UTF-8?q?[IMP]=20l10n=5Fat=5Fhr=5Fpayroll=5Fprivate:=20S?= =?UTF-8?q?achbez=C3=BCge=20(AP12)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .agents/MEMORY.md | 99 ++++++- addons/l10n_at_hr_payroll/__manifest__.py | 2 +- .../data/rule_parameters_lst_2026.xml | 17 ++ .../l10n_at_hr_payroll/models/lohnsteuer.py | 102 ++++++- .../models/sozialversicherung.py | 42 ++- .../tests/test_lohnsteuer.py | 117 +++++++- .../__manifest__.py | 7 +- .../data/input_types_private.xml | 43 +++ .../data/rule_parameters_sb2_private.xml | 45 +++ .../data/rule_parameters_sb_private.xml | 24 ++ .../data/salary_rules_sb2_private.xml | 81 +++++ .../data/salary_rules_sb_private.xml | 129 ++++++++ .../docs/rechenfaelle/sachbezuege.md | 173 +++++++++++ .../models/__init__.py | 1 + .../models/payslip_private.py | 15 +- .../models/sachbezuege_private.py | 224 ++++++++++++++ .../tests/__init__.py | 2 + .../tests/test_sachbezuege2_private.py | 187 ++++++++++++ .../tests/test_sachbezuege_private.py | 279 ++++++++++++++++++ .../views/sachbezuege_views.xml | 40 +++ 20 files changed, 1606 insertions(+), 23 deletions(-) create mode 100644 addons/l10n_at_hr_payroll_private/data/rule_parameters_sb2_private.xml create mode 100644 addons/l10n_at_hr_payroll_private/data/rule_parameters_sb_private.xml create mode 100644 addons/l10n_at_hr_payroll_private/data/salary_rules_sb2_private.xml create mode 100644 addons/l10n_at_hr_payroll_private/data/salary_rules_sb_private.xml create mode 100644 addons/l10n_at_hr_payroll_private/docs/rechenfaelle/sachbezuege.md create mode 100644 addons/l10n_at_hr_payroll_private/models/sachbezuege_private.py create mode 100644 addons/l10n_at_hr_payroll_private/tests/test_sachbezuege2_private.py create mode 100644 addons/l10n_at_hr_payroll_private/tests/test_sachbezuege_private.py create mode 100644 addons/l10n_at_hr_payroll_private/views/sachbezuege_views.xml diff --git a/.agents/MEMORY.md b/.agents/MEMORY.md index 3efc6d8..9e16bd4 100644 --- a/.agents/MEMORY.md +++ b/.agents/MEMORY.md @@ -2,6 +2,84 @@ ## Current focus +**AP 12b wertgebundene Sachbezüge umgesetzt — 166/166 Tests grün** +(2026-09-14, freigegeben): + +- **Kern** `l10n_at_hr_payroll` (19.0.8.0.0): neues + `hr.salary.rule.l10n_at_sv_art` (`folgt` Default = rückwärtskompatibel, + `laufend`/`sonstig`/`keine`) trennt die SV- von der LSt-Zuordnung; + `_l10n_at_sv_art` / `_l10n_at_sv_brutto` und die SV-Basis-/§49-/SZ- + Pfade angepasst. Notwendig für Hardware-Verkauf (LSt sonstig/SV + laufend) und Bonusmeilen (LSt neutral/SV laufend). Kritischer Fix: + bei Odoo `DefaultDictPayroll` nur nach `code in result_rules` lesen — + `.get()` materialisiert eine leere künftige Regel und brach die LSt- + Engine mit `KeyError('total')`. Tests test_17/18 in test_lohnsteuer. +- **Privat** `l10n_at_hr_payroll_private` (19.0.9.7.0): neues + `models/sachbezuege_private.py` — Model B `hr.version`-Stammdaten + + Mitarbeiter-Relateds für Wagen, Wohnung, Parkplatz, Handyprofil; + Werte `at_sb_wagen`/`_co2`/`_wohnung`/`_parkplatz`; Regeln + `SB_PARKPLATZ`/`_WOHNUNG`/`_WAGEN`/`_GEBRAUCHT`/`_BONUSMEILEN`/ + `_HANDY`, Eingaben `ATP_SB_HANDY`/`_GEBRAUCHT`/`_BONUSMEILEN`, View + `views/sachbezuege_views.xml`; E2E tests test_sachbezuege2_private.py. +- **Abgabenlogik**: Wagen/Wohnung/Parkplatz sowie Handy-Mehrkosten + laufend voll abgabenpflichtig. Handy-Verkauf unter Buchwert: SV- + laufend + LSt-sonstig + LNK. Bonusmeilen: Drittlohn, nur SV-laufend + (Kategorie ATP_DG, also kein Auszahlungsnetto-Brutto), kein LSt-/LNK- + Brutto; darauf entfallende DN-SV mindert aber nach §62-Kette die LSt- + BG und damit die laufende LSt. Parkplatz als laufend: ⚠ Quelltext + lb-sac-18 nennt die Abgabenkategorie nicht ausdrücklich — vor + Produktivsetzung RIS/LStR prüfen. +- **Recht/MVP-Grenzen**: Wagen §4 SBWVO (CO₂ EZJ, E=0, voller/halber + Wert/Kostenbeitrag); Wohnung §2 Abs7a (m² BL, Norm/Besorger kombiniert + 55,5 %, klein ≤35=0, >35–45 −35 % erste 12 Mon.); Parkplatz §4a + (14,53), Handy lb-sac-15, Bonusmeilen lb-sac-08. Nicht automatisiert: + Wagen Mini/Pool/Wechsel/Mehrfach, Wohnungs-12-Monats-Retro/Mehrnutzer, + Lohnkonto-Nachweise. Rechenfälle RF-SB-7…9 in + `docs/rechenfaelle/sachbezuege.md`. +- AP12 ist damit abgeschlossen; **AP13 Jobticket** als nächster AP. + **Kein Commit** (nur auf Nutzer-Aufforderung). + +**AP 12a steuerfreie Sachbezüge umgesetzt — 159/159 Tests grün** +(2026-09-13, Freigabe 1/2: Ja/„erst a dann b“): + +- **Kern** `l10n_at_hr_payroll` (19.0.7.0.0): `hr.salary.rule` Flag + `l10n_at_st_frei` (mahlzeit/rabatt/betriebsveranstaltung/jubilaeum/ + homeoffice), `_l10n_at_st_frei_ytd`/`_l10n_at_st_frei_freie_laufen` + (Spieglung der §-49-SV-Seite, YTD je validierter Lauf), Anbindung + `_l10n_at_bemessung_laufen` (−st_frei), Parameter `at_st_grenzen` + (8 €/AT · 1.000 €/J · 365 €/J · 186 €/J · 3 €/AT + 100 Tage); + Tests test_13…16 in test_lohnsteuer.py. +- **Privat** `l10n_at_hr_payroll_private` (19.0.9.6.0): Eingaben + `ATP_SB_MAHLZEIT`/`_BETRIEBSVERANSTALTUNG`/`_SACHZUWENDUNG`/`_RABATT`/ + `_HOMEOFFICE` (Mahlzeit/HO is_quantity), Regeln `SB_*` (seq 41–44/48, + setzen SV- UND LSt-Flag), Parameter `at_sb_werte` (mahlzeit_wert 8.0 = + tatsächl. Bonwert, homeoffice_wert 3.0), `_at_lnk_basis` −st_frei + (lohnnebenkostenfrei); 6 E2E-Tests tests/test_sachbezuege_private.py + (RF-SB-1…6). +- **Zwei Bug-Funde (Testlauf)**: (a) Odoo-19-Engine: Zeilen-Total = + amount × qty × rate/100 — tagesbasierte Regeln MÜSSEN `result = wert` + (Tageswert) + `result_qty = tage` setzen (erste Version `result = + wert × tage` = Doppelzählung, Zeile 800 statt 80); (b) Test-Helfer + muss Zeilen nach REGEL-Code (SB_*) filtern, nicht nach Input-Code + (ATP_SB_*). (c) LSt-Bemessung: ΔLSt + ΔSVDN = Überhang (SV-DN + abzugsfähig) — nicht pauschal ΔLSt = Überhang. +- **Doku**: `docs/rechenfaelle/sachbezuege.md` (RF-SB-1…6; + Rechtsquellen lb-sac-17/07/02, lb-ges-02, lb-tel-04/05 — Homeoffice- + Anchor war in einem XML-Kommentar fälschlich lb-sac-15 = Handy, + korrigiert). +- **Vorbestehend (nicht AP12a)**: `salary_rules_bvg_private.xml` steht + ZWEIMAL in der Data-List des privaten Manifests (Log-Warnung + „imported twice“) — beim nächsten Touch bereinigen. +- **Nächster Schritt AP12b** (wertgebundene Sachbezüge): Dienstwagen + (SBV § 4, CO₂), Kleinwohnung (SBV § 2 Abs 7a, ≤35 m² = 0, 35–45 m² + = −35 % ≤12 Mon.), Parkplatz (SBV § 4a € 14,53 — SV/LSt-Kategorie + RIS-klären lb-sac-18), Handy (§ 26 Z 9, lb-sac-15), Bonusmeilen + (nur SV, lb-sac-08). Freigegebene Stammdaten-Entscheidung: + **Model B (`hr.version`-Felder) für wertgebundene, Model A + (Eingaben je Lauf) für ereignisgesteuerte** + versionierte SBV- + Parameter (CO₂/m² je BL). Jobticket → AP13. **Kein Commit** (Regel: + nur auf Nutzer-Aufforderung). + **AP 11 Lohnpfändung umgesetzt — 103/103 Tests grün** (2026-09-13, freigegeben 2026-09-12/13, Plan: `personalverrechnung/PLAN-Restluecken-AP11.md` §AP11): @@ -28,9 +106,10 @@ freigegeben 2026-09-12/13, Plan: `personalverrechnung/PLAN-Restluecken-AP11.md` in `_at_pfa_rule_amount`/`_at_pfa_bruto` (result_rules-Pfad) + finales `round` in `_at_pfaendung_basis` (alle Töpfe). - **Offene Reste** (dokumentiert, nicht geblockt): Unterhaltsexekution - 75 %/Koinzidenz, Vorschüsse § 290c, Sachbezüge (AP12), Beendigungs- - Regeldetails (AP14/15), 2027-AZR-Werte. **Kein Commit** (Nutzer- - Freigabe ausstehend). + 75 %/Koinzidenz, Vorschüsse § 290c, Sachbezüge (AP12 — AP12a jetzt + umgesetzt, AP12b folgt), Beendigungs-Regeldetails (AP14/15), + 2027-AZR-Werte. **Commit `018554b`** (2026-09-13, nach Nutzer- + Freigabe „commit, dann ap12"). **AP 8 Arbeits-/Entgeltbestätigungen umgesetzt — 152/152 Tests grün — damit ist der AP0–AP10-Nutzer-Katalog komplett** (2026-09-12, @@ -1364,6 +1443,20 @@ B17-Inhalt/B18; Kleinverifikationen s. KB-KONFLIKTE „Nächste Schritte“). ## Decisions & conventions +- **RIS-Referenzquelle `.ris/`** (2026-09-13, Nutzer): der neue lokale + Ordner `.ris/` ist ZUKUNFT die verbindliche Referenzquelle für + Gesetzestexte und übernimmt die Rolle von `.additional_docs/ris_normtexte/` + (2026-09-11) bzw. `.firecrawl/ris/` als Norm-Verifikationsbasis. Format: + je Norm ein Markdown-Auszug mit den in der Lexis360-WKB zitierten + Paragraphen (Kopf mit RIS-GesNr + GeltendeFassung-URL, je § Fundstelle + + Inkrafttretensdatum + NormDokument-URL), tagesaktuelle Fassung, Abruf + 2026-09-13; Bestand: ABGB, APSG, APflG, BMSVG, BS-V, BauV, DHG, FLAG, + GmbHG, MSchG, MSchV, RStDG, Sachbezugswerteverordnung, StPO. + **Auszug ≠ Volltext:** fehlt ein benötigter §, NICHT aus Trainingswerten + ergänzen — Beschaffung (firecrawl-RIS-Download, findok-Regel) oder + Nutzer-Beleg, Ablage in `.ris/`; gitignored wie die anderen + D1-Quellordner. + - **GP4-Konventionen (2026-09-11, Nutzer-freigegebener Bauplan +** **Quellenverifikation):** (1) **§ 2a AMPFG wirkt nur DN-seitig** (tiers+dn_voll im Parameter; Grenzen nicht aliquotieren; gilt auch diff --git a/addons/l10n_at_hr_payroll/__manifest__.py b/addons/l10n_at_hr_payroll/__manifest__.py index 97823f0..b7d38e9 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.6.0.0', + 'version': '19.0.8.0.0', 'author': 'Florian Egger', 'website': 'https://gem360.at', 'category': 'Human Resources/Payroll', 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 index cc0e1fd..7e10289 100644 --- a/addons/l10n_at_hr_payroll/data/rule_parameters_lst_2026.xml +++ b/addons/l10n_at_hr_payroll/data/rule_parameters_lst_2026.xml @@ -103,4 +103,21 @@ 2027-01-01 {'stunden': 10.0, 'betrag': 120.0} + + + + + + + + + Steuerfreie Sachbezüge § 3/§ 26 EStG (Grenzen) + at_st_grenzen + + + + + 2026-01-01 + {'mahlzeit_tag': 8.0, 'rabatt_jahr': 1000.0, 'betriebsveranstaltung_jahr': 365.0, 'jubilaeum_jahr': 186.0, 'homeoffice_tag': 3.0, 'homeoffice_tage': 100} + \ 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 1c63f9f..646cae0 100644 --- a/addons/l10n_at_hr_payroll/models/lohnsteuer.py +++ b/addons/l10n_at_hr_payroll/models/lohnsteuer.py @@ -52,6 +52,24 @@ class HrSalaryRule(models.Model): 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): @@ -197,6 +215,76 @@ class HrPayslip(models.Model): 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) @@ -217,11 +305,11 @@ class HrPayslip(models.Model): """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) + 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_brutto('sonstig'), 0.0) + 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): @@ -242,14 +330,16 @@ class HrPayslip(models.Model): # ------------------------------------------------------------------ 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).""" + 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']) + - 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); diff --git a/addons/l10n_at_hr_payroll/models/sozialversicherung.py b/addons/l10n_at_hr_payroll/models/sozialversicherung.py index b3b81b3..7c710a7 100644 --- a/addons/l10n_at_hr_payroll/models/sozialversicherung.py +++ b/addons/l10n_at_hr_payroll/models/sozialversicherung.py @@ -37,6 +37,18 @@ from odoo.tools.safe_eval import safe_eval class HrSalaryRule(models.Model): _inherit = 'hr.salary.rule' + l10n_at_sv_art = fields.Selection( + [('folgt', 'Folgt AT LSt-Bemessungsart'), + ('laufend', 'Laufender SV-Bezug'), + ('sonstig', 'Sonstiger SV-Bezug'), + ('keine', 'Kein SV-Bezug')], + string='AT SV-Bemessungsart', + default='folgt', + help='Steuert ausschließlich die SV-Zuordnung. Standard „Folgt“ ' + 'übernimmt die AT LSt-Bemessungsart und ist damit ' + 'rückwärtskompatibel. Abweichung für Drittlohn oder ' + 'abweichende LSt-/SV-Zuordnung, z. B. Bonusmeilen oder ' + 'Hardware-Verkauf.') l10n_at_sv49_frei = fields.Selection( [('keine', 'Keine §-49-Abs-3-Befreiung'), ('mahlzeit', 'Mahlzeiten-Gutschein (§ 49 Abs 3 Z 12; 8 €/AT)'), @@ -254,7 +266,7 @@ class HrPayslip(models.Model): 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'): + or self._l10n_at_sv_art(rule) != 'laufend'): continue werte = None if result_rules is not None: @@ -297,11 +309,33 @@ class HrPayslip(models.Model): # ------------------------------------------------------------------ # Laufende Beitragsgrundlage — je Beitragsmonat (Anspruchsprinzip) # ------------------------------------------------------------------ + def _l10n_at_sv_art(self, rule): + """Effektive SV-Art einer Regel; Standard folgt der LSt-Art.""" + self.ensure_one() + return (rule.l10n_at_bemessung if rule.l10n_at_sv_art == 'folgt' + else rule.l10n_at_sv_art) + + def _l10n_at_sv_brutto(self, art, result_rules=None): + """SV-Brutto je Art; abweichende SV-Zuordnung bleibt von der + LSt-Bemessungsart getrennt (Drittlohn/Sonderfälle).""" + self.ensure_one() + total = 0.0 + for rule in self.struct_id.rule_ids: + if self._l10n_at_sv_art(rule) != art: + continue + if result_rules is not None: + if rule.code in result_rules: + total += result_rules[rule.code].get('total', 0.0) + else: + total += sum(self.line_ids.filtered( + lambda l: l.code == rule.code).mapped('total')) + return total + def _l10n_at_sv_basis_roh(self, result_rules=None): - """Laufender Arbeitslohn minus beitragsfreie §-49-Anteile + """Laufendes SV-Brutto minus beitragsfreie §-49-Anteile (vor HBG-Cap; Geringfügigkeits-/Staffel-Basis).""" self.ensure_one() - roh = self._l10n_at_brutto('laufend', result_rules) \ + roh = self._l10n_at_sv_brutto('laufend', result_rules) \ - self._l10n_at_sv49_freie_laufen(result_rules) return max(roh, 0.0) @@ -312,7 +346,7 @@ class HrPayslip(models.Model): self.ensure_one() gruppen = {} for rule in self.struct_id.rule_ids: - if rule.l10n_at_bemessung != 'laufend': + if self._l10n_at_sv_art(rule) != 'laufend': continue werte = None if result_rules is not None: diff --git a/addons/l10n_at_hr_payroll/tests/test_lohnsteuer.py b/addons/l10n_at_hr_payroll/tests/test_lohnsteuer.py index cfaf55d..d5a49bd 100644 --- a/addons/l10n_at_hr_payroll/tests/test_lohnsteuer.py +++ b/addons/l10n_at_hr_payroll/tests/test_lohnsteuer.py @@ -27,7 +27,8 @@ class LohnsteuerTest(TransactionCase): }) def _regel(self, code, betrag, bemessung='laufend', flag68='keine', - anteil=0.0, quantity='1.0', python=None): + anteil=0.0, quantity='1.0', python=None, st_frei='keine', + sv_art='folgt'): vals = { 'name': code, 'code': code, @@ -37,6 +38,8 @@ class LohnsteuerTest(TransactionCase): 'l10n_at_bemessung': bemessung, 'l10n_at_steuerbefreiung_68': flag68, 'l10n_at_zuschlagsanteil': anteil, + 'l10n_at_st_frei': st_frei, + 'l10n_at_sv_art': sv_art, } if python: vals.update({'amount_select': 'code', @@ -282,4 +285,114 @@ class LohnsteuerTest(TransactionCase): 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 + slip._l10n_at_lst_sonstige(), (4000.0 - 620.0) * 0.06, places=1) + + # ------------------------------------------------------------------ + # § 3/§ 26 — steuerfreie Sachbezüge (Freibeträge, Jahres-Kumulative) + # ------------------------------------------------------------------ + def test_13_st_frei_mahlzeit(self): + """Mahlzeiten-Gutschein 11 €/AT × 10 AT = 110 €: 80 € steuerfrei + (8 €/AT, § 3 Abs 1 Z 17), 30 € steuerpflichtig; LSt-Bemessung + um 80 € reduziert (lb-sac-17).""" + employee = self._employee() + self._regel('LFD', 2000.0) + self._regel('MAH', 11.0, quantity='10.0', st_frei='mahlzeit') + slip = self._slip(employee, 1) + self.assertAlmostEqual( + slip._l10n_at_st_frei_freie_laufen(), 80.0, places=2) + svdn = slip._l10n_at_svdn_gesamt() + self.assertAlmostEqual( + slip._l10n_at_bemessung_laufen(), + 2000.0 + 110.0 - svdn - 80.0, places=2) + + def test_14_st_frei_jahrespools(self): + """Jahres-Kumulative: Betriebsveranstaltung 365 €/J (Freibetrag) + und Sachzuwendung/Jubiläum 186 €/J als separate Pools + (§ 3 Abs 1 Z 14; lb-sac-07, lb-ges-02).""" + employee = self._employee() + self._regel('LFD', 2000.0) + self._regel('BV', 200.0, st_frei='betriebsveranstaltung') + slip_a = self._slip(employee, 1, confirm=True) + self.assertAlmostEqual( + slip_a._l10n_at_st_frei_freie_laufen(), 200.0, places=2) + # Februar: 165 € Rest (365 − 200) → 165 frei, 35 steuerpflichtig + slip_b = self._slip(employee, 2, confirm=True) + self.assertAlmostEqual( + slip_b._l10n_at_st_frei_freie_laufen(), 165.0, places=2) + # März: Pool verbraucht → 0 frei, 200 steuerpflichtig + slip_c = self._slip(employee, 3) + self.assertAlmostEqual( + slip_c._l10n_at_st_frei_freie_laufen(), 0.0, places=2) + # Sachzuwendung separater Pool: 300 € → 186 frei, 114 pflichtig + self._regel('GSC', 300.0, st_frei='jubilaeum') + slip_d = self._slip(employee, 4) + self.assertAlmostEqual( + slip_d._l10n_at_st_frei_freie_laufen(), 186.0, places=2) + + def test_15_st_frei_homeoffice(self): + """Homeoffice 3 €/Tag, max 100 Tage/Jahr (§ 26 Z 9): 70 Tage im + Jänner (210 frei), Februar nur 30 Tage frei (90), 40 Tage × 3 = + 120 steuerpflichtig.""" + employee = self._employee() + self._regel('LFD', 2000.0) + self._regel('HO', 3.0, quantity='70.0', st_frei='homeoffice') + slip_a = self._slip(employee, 1, confirm=True) + self.assertAlmostEqual( + slip_a._l10n_at_st_frei_freie_laufen(), 210.0, places=2) + # Februar: 30 Tage Rest (100 − 70) → 90 frei, 120 steuerpflichtig + slip_b = self._slip(employee, 2) + self.assertAlmostEqual( + slip_b._l10n_at_st_frei_freie_laufen(), 90.0, places=2) + + def test_16_st_frei_neutral(self): + """Ohne st_frei-Flag keine Wirkung auf die LSt-Bemessung + (Regression).""" + employee = self._employee() + self._regel('LFD', 2000.0) + slip = self._slip(employee, 1) + self.assertAlmostEqual(slip._l10n_at_st_frei_freie_laufen(), 0.0) + svdn = slip._l10n_at_svdn_gesamt() + self.assertAlmostEqual( + slip._l10n_at_bemessung_laufen(), 2000.0 - svdn, places=2) + + def test_17_sv_art_entkoppelt_lst_art(self): + """SV-Art folgt standardmäßig der LSt-Art; Drittlohn kann nur + SV-laufend sein, Hardware-Verkauf LSt-sonstig/SV-laufend.""" + employee = self._employee() + self._regel('LFD', 100.0) + self._regel('DRITT', 300.0, bemessung='neutral', sv_art='laufend') + self._regel('HW', 200.0, bemessung='sonstig', sv_art='laufend') + slip = self._slip(employee, 1) + self.assertAlmostEqual( + slip._l10n_at_sv_brutto('laufend'), 600.0, places=2) + self.assertAlmostEqual( + slip._l10n_at_sv_brutto('sonstig'), 0.0, places=2) + self.assertAlmostEqual(slip._l10n_at_svdn_basis_sz(), 0.0, places=2) + self.assertAlmostEqual( + slip._l10n_at_brutto('laufend'), 100.0, places=2) + self.assertAlmostEqual( + slip._l10n_at_brutto('sonstig'), 200.0, places=2) + + def test_18_sv_art_folgt_ist_neutral(self): + """Der Default 'folgt' erhält bestehende laufende und sonstige + SV-Zuordnungen unverändert.""" + employee = self._employee() + self._regel('LFD', 100.0) + self._regel('SZ', 200.0, bemessung='sonstig') + slip = self._slip(employee, 1) + self.assertAlmostEqual( + slip._l10n_at_sv_brutto('laufend'), 100.0, places=2) + self.assertAlmostEqual( + slip._l10n_at_sv_brutto('sonstig'), 200.0, places=2) + + def test_19_sv_art_keine_nur_lst(self): + """SV-Art 'keine' schließt eine Regel ausschließlich aus der + SV aus; die deklarierte LSt-Bemessung bleibt unverändert.""" + employee = self._employee() + self._regel('LFD', 100.0) + self._regel('NUR_LST', 300.0, sv_art='keine') + slip = self._slip(employee, 1) + self.assertAlmostEqual( + slip._l10n_at_sv_brutto('laufend'), 100.0, places=2) + self.assertAlmostEqual( + slip._l10n_at_brutto('laufend'), 400.0, places=2) diff --git a/addons/l10n_at_hr_payroll_private/__manifest__.py b/addons/l10n_at_hr_payroll_private/__manifest__.py index 13662ea..7408df9 100644 --- a/addons/l10n_at_hr_payroll_private/__manifest__.py +++ b/addons/l10n_at_hr_payroll_private/__manifest__.py @@ -1,7 +1,7 @@ # Part of the odoo-at-payroll project. License: LGPL-3. { 'name': 'Österreich - Privatwirtschaft Personalverrechnung (General-AT)', - 'version': '19.0.9.5.0', + 'version': '19.0.9.7.0', 'author': 'Florian Egger', 'website': 'https://gem360.at', 'category': 'Human Resources/Payroll', @@ -110,6 +110,8 @@ gem360-Brücke). 'data/salary_rules_msch_private.xml', 'data/salary_rules_pfaendung_private.xml', + 'data/salary_rules_sb_private.xml', + 'data/salary_rules_sb2_private.xml', 'data/salary_rules_sv_private.xml', 'data/salary_rules_bvg_private.xml', 'data/salary_rules_bvg_private.xml', @@ -119,6 +121,8 @@ gem360-Brücke). 'data/rule_parameters_efzg_private.xml', 'data/rule_parameters_msch_private.xml', 'data/rule_parameters_pfaendung_private.xml', + 'data/rule_parameters_sb_private.xml', + 'data/rule_parameters_sb2_private.xml', 'data/bv_kasse_seed.xml', 'views/kv_views.xml', 'views/bv_views.xml', @@ -127,6 +131,7 @@ gem360-Brücke). 'views/mitarbeiter_views.xml', 'views/aeb_views.xml', 'views/pfaendung_views.xml', + 'views/sachbezuege_views.xml', 'report/gp8_reports.xml', 'report/buchungsbeleg_report.xml', 'report/bv_report.xml', diff --git a/addons/l10n_at_hr_payroll_private/data/input_types_private.xml b/addons/l10n_at_hr_payroll_private/data/input_types_private.xml index bcdd271..7e8ad59 100644 --- a/addons/l10n_at_hr_payroll_private/data/input_types_private.xml +++ b/addons/l10n_at_hr_payroll_private/data/input_types_private.xml @@ -7,4 +7,47 @@ Prämie/Ausgleich (manuell) ATP_PRAMIE + + + + + + + Sachbezug Mahlzeiten — Anz. Gutschein-Arbeitstage (manuell) + ATP_SB_MAHLZEIT + + + + Sachbezug Betriebsveranstaltung — Gesamtvalue (manuell) + ATP_SB_BETRIEBSVERANSTALTUNG + + + Sachbezug Sachzuwendung/Geschenk — Gesamtvalue (manuell) + ATP_SB_SACHZUWENDUNG + + + Sachbezug Mitarbeiterrabatt — Gesamtvalue (manuell) + ATP_SB_RABATT + + + Sachbezug Homeoffice — Anz. Tage (manuell) + ATP_SB_HOMEOFFICE + + + + + + Sachbezug Handy — private Mehrkosten (manuell) + ATP_SB_HANDY + + + Sachbezug Arbeitsmittel-Verkauf unter Buchwert (manuell) + ATP_SB_GEBRAUCHT + + + Sachbezug Bonusmeilen — Einlösungswert (manuell) + ATP_SB_BONUSMEILEN + \ No newline at end of file diff --git a/addons/l10n_at_hr_payroll_private/data/rule_parameters_sb2_private.xml b/addons/l10n_at_hr_payroll_private/data/rule_parameters_sb2_private.xml new file mode 100644 index 0000000..ee77fdb --- /dev/null +++ b/addons/l10n_at_hr_payroll_private/data/rule_parameters_sb2_private.xml @@ -0,0 +1,45 @@ + + + + + Sachbezug Dienstwagen — Sätze und Höchstwerte + at_sb_wagen + + + + + 2026-01-01 + {'voll_hoch_satz': 2.0, 'voll_hoch_cap': 960.0, 'voll_niedrig_satz': 1.5, 'voll_niedrig_cap': 720.0, 'halb_hoch_satz': 1.0, 'halb_hoch_cap': 480.0, 'halb_niedrig_satz': 0.75, 'halb_niedrig_cap': 360.0} + + + Sachbezug Dienstwagen — CO₂-Grenzwerte + at_sb_wagen_co2 + + + + + 2026-01-01 + {'vor_2016': 130.0, '2017': 127.0, '2018': 124.0, '2019': 121.0, '2020': 141.0, '2021': 138.0, '2022': 135.0, '2023': 132.0, '2024': 129.0, 'ab_2025': 126.0} + + + Sachbezug Dienstwohnung — m²-Werte und Abschläge + at_sb_wohnung + + + + + 2026-01-01 + {'burgenland': 6.09, 'kaernten': 7.81, 'niederoesterreich': 6.85, 'oberoesterreich': 7.23, 'salzburg': 9.22, 'steiermark': 9.21, 'tirol': 8.14, 'vorarlberg': 10.25, 'wien': 6.67, 'nicht_norm_abschlag': 0.30, 'besorger_abschlag': 0.35, 'kombiniert_abschlag': 0.555, 'klein_frei_m2': 35.0, 'klein_abschlag_bis_m2': 45.0, 'klein_abschlag': 0.35} + + + Sachbezug Parkplatz — Monatswert + at_sb_parkplatz + + + + + 2026-01-01 + 14.53 + + diff --git a/addons/l10n_at_hr_payroll_private/data/rule_parameters_sb_private.xml b/addons/l10n_at_hr_payroll_private/data/rule_parameters_sb_private.xml new file mode 100644 index 0000000..b5ece19 --- /dev/null +++ b/addons/l10n_at_hr_payroll_private/data/rule_parameters_sb_private.xml @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + Sachbezüge — tagesbasierte Werte + at_sb_werte + + + + + 2026-01-01 + {'mahlzeit_wert': 8.0, 'homeoffice_wert': 3.0} + + diff --git a/addons/l10n_at_hr_payroll_private/data/salary_rules_sb2_private.xml b/addons/l10n_at_hr_payroll_private/data/salary_rules_sb2_private.xml new file mode 100644 index 0000000..6283cf6 --- /dev/null +++ b/addons/l10n_at_hr_payroll_private/data/salary_rules_sb2_private.xml @@ -0,0 +1,81 @@ + + + + + Sachbezug Parkplatz am Dienstort + SB_PARKPLATZ + + + + python + result = payslip.version_id._l10n_at_sb_parkplatz_wert(payslip.date_to) > 0 + code + laufend + result = payslip.version_id._l10n_at_sb_parkplatz_wert(payslip.date_to) + + + Sachbezug Dienstwohnung + SB_WOHNUNG + + + + python + result = payslip.version_id._l10n_at_sb_wohnung_wert(payslip.date_to) > 0 + code + laufend + result = payslip.version_id._l10n_at_sb_wohnung_wert(payslip.date_to) + + + Sachbezug Dienstwagen Privatnutzung + SB_WAGEN + + + + python + result = payslip.version_id._l10n_at_sb_wagen_wert(payslip.date_to) > 0 + code + laufend + result = payslip.version_id._l10n_at_sb_wagen_wert(payslip.date_to) + + + Sachbezug Arbeitsmittel-Verkauf unter Buchwert + SB_GEBRAUCHT + + + + python + result = 'ATP_SB_GEBRAUCHT' in inputs and inputs['ATP_SB_GEBRAUCHT'].amount + code + sonstig + laufend + result = inputs['ATP_SB_GEBRAUCHT'].amount + + + Sachbezug Bonusmeilen (Drittlohn, nur SV) + SB_BONUSMEILEN + + + + + python + result = 'ATP_SB_BONUSMEILEN' in inputs and inputs['ATP_SB_BONUSMEILEN'].amount + code + neutral + laufend + result = inputs['ATP_SB_BONUSMEILEN'].amount + + + Sachbezug Handy — private Mehrkosten + SB_HANDY + + + + python + result = payslip.version_id.l10n_at_sb_handy_profil == 'ueberwiegend_privat' and 'ATP_SB_HANDY' in inputs and inputs['ATP_SB_HANDY'].amount + code + laufend + result = inputs['ATP_SB_HANDY'].amount + + diff --git a/addons/l10n_at_hr_payroll_private/data/salary_rules_sb_private.xml b/addons/l10n_at_hr_payroll_private/data/salary_rules_sb_private.xml new file mode 100644 index 0000000..8526f99 --- /dev/null +++ b/addons/l10n_at_hr_payroll_private/data/salary_rules_sb_private.xml @@ -0,0 +1,129 @@ + + + + + + + + + + + + + + + + + + + + + Sachbezug Mahlzeiten-Gutschein (manuell) + SB_MAHLZEIT + + + + python + result = 'ATP_SB_MAHLZEIT' in inputs and inputs['ATP_SB_MAHLZEIT'].amount + code + laufend + mahlzeit + mahlzeit + + + + + Sachbezug Betriebsveranstaltung (manuell) + SB_BETRIEBSVERANSTALTUNG + + + + python + result = 'ATP_SB_BETRIEBSVERANSTALTUNG' in inputs and inputs['ATP_SB_BETRIEBSVERANSTALTUNG'].amount + code + laufend + betriebsveranstaltung + betriebsveranstaltung + + + + + Sachbezug Sachzuwendung/Jubiläum/Geschenk (manuell) + SB_SACHZUWENDUNG + + + + python + result = 'ATP_SB_SACHZUWENDUNG' in inputs and inputs['ATP_SB_SACHZUWENDUNG'].amount + code + laufend + jubilaeum + jubilaeum + + + + + Sachbezug Mitarbeiterrabatt (manuell) + SB_RABATT + + + + python + result = 'ATP_SB_RABATT' in inputs and inputs['ATP_SB_RABATT'].amount + code + laufend + rabatt + rabatt + + + + + Sachbezug Homeoffice-Pauschale (manuell) + SB_HOMEOFFICE + + + + python + result = 'ATP_SB_HOMEOFFICE' in inputs and inputs['ATP_SB_HOMEOFFICE'].amount + code + laufend + homeoffice + homeoffice + + + diff --git a/addons/l10n_at_hr_payroll_private/docs/rechenfaelle/sachbezuege.md b/addons/l10n_at_hr_payroll_private/docs/rechenfaelle/sachbezuege.md new file mode 100644 index 0000000..4213662 --- /dev/null +++ b/addons/l10n_at_hr_payroll_private/docs/rechenfaelle/sachbezuege.md @@ -0,0 +1,173 @@ +# Rechenfälle steuerfreie Sachbezüge (AP12a) + +Kern: Feld `hr.salary_rule.l10n_at_st_frei` + Methoden +`_l10n_at_st_frei_ytd`/`_l10n_at_st_frei_freie_laufen` (Anbindung +`_l10n_at_bemessung_laufen`) in `l10n_at_hr_payroll/models/lohnsteuer.py`, +Parameter `at_st_grenzen` (`l10n_at_hr_payroll/data/rule_parameters_lst_2026.xml`). +Privat: Eingabetypen `ATP_SB_*` (`data/input_types_private.xml`), Regeln +`SB_MAHLZEIT`/`SB_BETRIEBSVERANSTALTUNG`/`SB_SACHZUWENDUNG`/`SB_RABATT`/ +`SB_HOMEOFFICE` (`data/salary_rules_sb_private.xml`), Parameter +`at_sb_werte` (`data/rule_parameters_sb_private.xml`), LNK-Anbindung +`_at_lnk_basis` (`models/payslip_private.py`). SV-Seite: bestehendes +`l10n_at_sv49_frei`/`at_sv49_grenzen` (die SB-Regeln setzen beide Flags). + +## Rechtsquellen (verifiziert 2026-09-13) + +- **Mahlzeiten** (lb-sac-17): § 3 Abs 1 Z 17 EStG / § 49 Abs 3 Z 12 ASVG — + „großer" Essensbon **€ 8,00/Arbeitstag** steuer- und beitragsfrei + (Voraussetzungen: keine Verpflegungszulage u. a., sonst nur € 2,00); + Jahresbemessung 220 Arbeitstage; Übersteigerung pflichtig. +- **Betriebsveranstaltungen** (lb-sac-07, lb-sva-03): § 3 Abs 1 Z 14 EStG / + § 49 Abs 3 Z 17 ASVG — Teilnahme bis **€ 365,–/AN/Kalenderjahr** frei; + keine Barablöse. +- **Sachzuwendungen/Geschenke** (lb-ges-02): § 3 Abs 1 Z 14 EStG — + Weihnachtsgeschenke sowie Dienst-/Firmenjubiläen je bis **€ 186,–/Jahr** + (gesonderter Pool neben der BV); Barzuwendungen immer pflichtig. +- **Mitarbeiterrabatte** (lb-sac-02): § 3 Abs 1 Z 21 EStG / § 49 Abs 3 + Z 29 ASVG — Rabatte **über 20 %** mit **€-1.000,–-Freibetrag/Jahr** je + AN/AG; darunter keine Bezug; darüber abgabenpflichtig (laufend oder + jährlicher Sachbezug). +- **Homeoffice/Telearbeit** (lb-tel-04/05): § 26 Z 9 EStG / § 49 Abs 3 + Z 31 ASVG — Telearbeit-Pauschale **€ 3/Tag, max 100 Tage/Jahr** + (= € 300/Jahr) nicht steuerbar; Lohnkonto: Anzahl Telearbeitstage + erfassen. +- Alle Freibeträge: **nur der übersteigende Teil** ist beitrags-/steuer- + pflichtig (Freibeträge, keine Freigrenzen), lohnnebenkostenfrei. + +## Maschinenmodell (2026) + +- **Eingabe-Semantik** (manuell, `hr.payslip.input.amount`): + - tagesbasiert (Mahlzeiten/Homeoffice): `amount` = **Anz. Arbeitstage**; + Tageswert aus `at_sb_werte` (`mahlzeit_wert` Default 8,0 = tatsächl. + Bonwert, `homeoffice_wert` 3,0 = gesetzlich fix); Regel: `result = + wert`, `result_qty = tage` → Engine: `total = wert × tage`, + `quantity = tage` (für die Cap-Berechnung Cap × Tage). + - poolbasiert (BV/Sachzuwendung/Rabatt): `amount` = **Gesamtwert des + Monats**; Regel: `result = amount` (`quantity` = 1). +- **Freibetrag je Lauf** (Kern, `_l10n_at_st_frei_freie_laufen`, + Spieglung `_l10n_at_sv49_freie_laufen`): tagesbasiert + `frei = min(total, cap × menge)`; Homeoffice zusätzlich + `rest_tage = 100 − YTD-Menge`, poolbasiert `frei = min(total, + pool − YTD)`. YTD = Summe der Zeilen (Menge bzw. Total) **validierter** + früherer Läufe des Kalenderjahres je DN (`_l10n_at_ytd_slaeufe`). +- **Bemessungen**: LSt-BG laufend = Arbeitslohn − SVDN − § 68 − + `st_frei`; LNK-BG = (laufend + im Monat gezahlter § 67) − `st_frei` + − § 67 Abs 3/6 → SV-DN sind aus der LSt-BG abzugsfähig, aus der + LNK-BG nicht (Zuflussprinzip, kein SV-Abzug). +- **Caps** (`at_st_grenzen`, `at_sv49_grenzen`, 2026): Mahlzeit 8,0 €/AT · + Rabatt 1.000 €/J · BV 365 €/J · Jubiläum 186 €/J · Homeoffice + 3,0 €/AT, 100 Tage/J. + +## RF-SB-1 — Mahlzeiten voll im Freibetrag + +10 Gutschein-Arbeitstage à 8,0 € (`mahlzeit_wert`): + +- `SB_MAHLZEIT`: total **80,00 €**, quantity 10. +- frei (SV + LSt) = min(80, 8 × 10) = **80,00 €** → SV-BG, LSt-BG und + LNK-BG **unverändert** gegenüber dem Lauf ohne SB (Tests test_01). + +## RF-SB-2 — Mahlzeiten mit Überhang (Bonwert 11,00 €) + +`at_sb_werte.mahlzeit_wert` = 11,00 (versioniert), 10 AT: + +- `SB_MAHLZEIT`: total **110,00 €** (11 × 10), quantity 10. +- frei = min(110, 8 × 10) = **80,00 €** → Überhang **30,00 €**. +- SV-BG +30,00 · LNK-BG +30,00 · LSt-BG +30,00 **abzüglich** der darauf + entfallenden SV-DN (SV abzugsfähig: ΔLSt + ΔSVDN = 30,00 €) (test_02). + +## RF-SB-3 — Betriebsveranstaltungen (365-€-Pool) + +Jänner 300 € (validiert), Februar 100 €: + +- Jänner: frei min(300, 365−0) = **300 €** → voll frei. +- Februar: YTD 300 → frei min(100, 365−300) = **65 €**, Überhang **35 €** + in SV-/LSt-(netto SVDN)/LNK-Bemessung (test_03). + +## RF-SB-4 — Eigene Pools: Sachzuwendung (186) & Rabatt (1.000) + +- **Sachzuwendung**: Jänner 100 € (frei), Februar 100 € → frei + 186−100 = **86 €**, Überhang **14 €** (test_04). +- **Rabatt**: Jänner 700 € (frei, über 20 %), Februar 500 € → frei + 1.000−700 = **300 €**, Überhang **200 €** (test_04). +- Pools sind unabhängig (BV 365 ≠ Sachzuwendung 186). + +## RF-SB-5 — Homeoffice (100-Tage-Limit) + +70 Tage (validiert), dann 80 Tage: + +- Jänner: 70 × 3 = **210 €** frei (70 ≤ 100 Tage). +- Februar: rest 100−70 = 30 Tage → frei 30 × 3 = **90 €**; Zeile + 80 × 3 = 240 € → Überhang **150 €** pflichtig (test_05). + +## RF-SB-6 — Ohne SB-Eingaben neutral + +Keine `ATP_SB_*`-Eingaben → keine `SB_*`-Zeilen, +`_l10n_at_st_frei_freie_laufen()` = `_l10n_at_sv49_freie_laufen()` = 0 +→ Bemessungen wie ohne AP12a (test_06). + +## AP12b — Wertgebundene Sachbezüge (2026) + +Stammdaten liegen auf `hr.version` (ein Fahrzeug/eine Wohnung pro +Dienstverhältnis im MVP), ereignisbezogene Werte auf `ATP_SB_*`-Eingaben. +Die Werte sind vollständig versioniert: `at_sb_wagen`, `at_sb_wagen_co2`, +`at_sb_wohnung`, `at_sb_parkplatz`. + +- **Dienstwagen** (lb-sac-03/04, § 4 SBWVO): AK × 2 % (über Grenzwert, + cap 960) bzw. 1,5 % (Grenzwert erfüllt, cap 720); halber SB bei + nachgewiesenen ≤ 500 Privat-km/Monat: 1 %/0,75 % (caps 480/360). + CO₂-Grenzwert folgt dem Erstzulassungsjahr; 0 g/km = kein Ansatz; + laufender Kostenbeitrag mindert nach § 4 Abs 7. +- **Dienstwohnung** (lb-sac-09, § 2 Abs 7a SBWVO): m² × Richtwert je + Bundesland; Normwohnung −30 %, Hausbesorger −35 % (kombiniert 55,5 %); + arbeitsplatznah/nicht Lebensmittelpunkt: ≤ 35 m² = 0, über 35–45 m² + −35 % während der ersten 12 Überlassungsmonate. +- **Parkplatz** (lb-sac-18, § 4a SBWVO): 14,53 €/Monat minus DN- + Kostenbeitrag, soweit keine dokumentierte Ausnahme vorliegt. AP12b + behandelt ihn als laufenden Bezug; der Quelltext nennt die Kategorie + nicht explizit (⚠ Annahme vor Produktivsetzung gegen LStR/RIS prüfen). +- **Handy** (lb-sac-15): beruflich/fallweise privat = 0; überwiegend + private Mehrkosten sind laufender manueller Bezug. Verkauf unter + Buchwert ist als Eingabe „Buchwert minus Kaufpreis“: SV **laufend**, + LSt **sonstig (§ 67)**, LNK-pflichtig. +- **Bonusmeilen** (lb-sac-08): Einlösungswert ist Drittlohn, ausschließlich + SV-laufend (§ 49 Abs 1 ASVG), kein eigenes LSt-/LNK-Brutto. Die darauf + entfallende DN-SV mindert jedoch gemäß bestehender §-62-Kette die LSt-BG. + +## RF-SB-7 — Dienstwagen + +AK 36.700 €, EZ 2025, CO₂ 112 g/km (≤ 126): 36.700 × 1,5 % = +**550,50 €**. Halber Sachbezug: **275,25 €**. Bei 130 g/km: +36.700 × 2 % = **734,00 €**. E-Fahrzeug (0 g/km) = **0 €**. +Kostenbeitrag 300 €: 550,50 − 300 = **250,50 €**. + +## RF-SB-8 — Dienstwohnung und Parkplatz + +- Wien, 72,4 m²: 72,4 × 6,67 = **482,91 €**. +- Arbeitsplatznahe Kleinwohnung: 30 m² = **0 €**; 40 m² im Monat 1: + 40 × 6,67 × 65 % = **173,42 €**. +- Parkplatz: 14,53 € minus 5 € Kostenbeitrag = **9,53 €**. + +## RF-SB-9 — Handy und Bonusmeilen + +- Private Handy-Mehrkosten 80 € nur bei Profil „überwiegend privat“: + **80 € laufend**; berufliches Profil erzeugt keine Zeile. +- Arbeitsmittel-Verkauf (Buchwert 800 € − Preis 100 €): **700 €**; + SV-Basis laufend +700, SV-SZ-Basis 0, LSt-sonstig und LNK +700. +- Bonusmeilen, Einlösungswert 300 €: SV-Basis +300, LNK 0, kein eigenes + LSt-Brutto; Auszahlungsnetto vermindert sich nur um die DN-SV abzüglich + der daraus folgenden LSt-Entlastung. + +## Offene Punkte / dokumentierte Reste + +- **Dienstwagen:** Mini-Sachbezug (Fahrtenbuch/km-Wert/Jahresvergleich), + Fahrzeugwechsel, Pool/Fahrgemeinschaft, Mehrfachfahrzeuge und einmalige + Kostenbeiträge (lb-sac-04) folgen außerhalb des MVP. +- **Kleinwohnung:** Aufrollung/Nachversteuerung beim Überschreiten der + 12-Monats-Frist (§ 67 Abs 10), Mehrnutzer- und Gemeinschaftsflächen- + aufteilung sind noch nicht automatisiert. +- **Jobticket** (SBV § 4b): erst ab AP13. +- **Lohnkonto-Aufzeichnungspflichten** (Mahlzeitenvoraussetzungen + lb-sac-17, Telearbeitstage lb-tel-05, Rabatte > 20 % lb-sac-02 sowie + Nachweise/Erklärungen AP12b): Arbeitgeber-Prüffeld, nicht Engine. +- **2027-Werte**: alle `at_st_grenzen`/`at_sv49_grenzen`, `at_sb_werte` + und AP12b-Parameter am Jahreswechsel nachführen (Wartung/RUNBOOK). diff --git a/addons/l10n_at_hr_payroll_private/models/__init__.py b/addons/l10n_at_hr_payroll_private/models/__init__.py index 55e6649..ee15a35 100644 --- a/addons/l10n_at_hr_payroll_private/models/__init__.py +++ b/addons/l10n_at_hr_payroll_private/models/__init__.py @@ -17,3 +17,4 @@ from . import mutterschutz from . import aeb from . import pfaendung from . import pfaendung_report +from . import sachbezuege_private diff --git a/addons/l10n_at_hr_payroll_private/models/payslip_private.py b/addons/l10n_at_hr_payroll_private/models/payslip_private.py index 1a79f7d..67cc52e 100644 --- a/addons/l10n_at_hr_payroll_private/models/payslip_private.py +++ b/addons/l10n_at_hr_payroll_private/models/payslip_private.py @@ -339,14 +339,17 @@ class HrPayslip(models.Model): """LNK-Bemessungsgrundlage nach Freibetragsstaffel: monatlich gezahlte Arbeitslöhne (laufend + im Monat gezahlte §-67-Bezüge, Zuflussprinzip; kein SV-Abzug), ohne §-67-Abs-3/6-Bezüge - (Abfertigung/Abfindung). 'flaf'/'dz' zusätzlich ohne lit-f-AN - (Arbeitslöhne ab Folgemonat nach Vollendung des 60. Lebensjahres, - § 41 Abs 4 lit f FLAG — die Kommunalsteuer hat KEINE lit-f- - Befreiung). Freibetrag: ≤ 1.095 € keine Abgabe, bis 1.460 € → - Freibetrag 1.095 €, darüber volle BG (at_lnk_freibetrag).""" + (Abfertigung/Abfindung), ohne steuerfreie Sachbezüge + § 3/§ 26 EStG (Freibeträge — lohnnebenkostenfrei, lb-sac-07/02). + 'flaf'/'dz' zusätzlich ohne lit-f-AN (Arbeitslöhne ab Folgemonat + nach Vollendung des 60. Lebensjahres, § 41 Abs 4 lit f FLAG — + die Kommunalsteuer hat KEINE lit-f-Befreiung). Freibetrag: + ≤ 1.095 € keine Abgabe, bis 1.460 € → Freibetrag 1.095 €, darüber + volle BG (at_lnk_freibetrag).""" self.ensure_one() basis = (self._l10n_at_brutto('laufend', result_rules) - + self._l10n_at_brutto('sonstig', result_rules)) + + self._l10n_at_brutto('sonstig', result_rules) + - self._l10n_at_st_frei_freie_laufen(result_rules)) for rule in self.struct_id.rule_ids: if rule.l10n_at_s67_abs in ('abs3', 'abs6'): if result_rules is not None and rule.code in result_rules: diff --git a/addons/l10n_at_hr_payroll_private/models/sachbezuege_private.py b/addons/l10n_at_hr_payroll_private/models/sachbezuege_private.py new file mode 100644 index 0000000..1c0fac5 --- /dev/null +++ b/addons/l10n_at_hr_payroll_private/models/sachbezuege_private.py @@ -0,0 +1,224 @@ +# Part of the odoo-at-payroll project. License: LGPL-3. +"""AP12b: wertgebundene Sachbezüge auf dem Dienstverhältnis. + +Ein Fahrzeug bzw. eine Wohnung je Version ist bewusst MVP-Grenze. Wechsel, +Pool-/Mehrfachfahrzeuge, Mini-Sachbezug und Kleinwohnungs-Retroaktivität +bleiben dokumentierte Folgefälle (lb-sac-03/04/09/18/15/08). +""" +from dateutil.relativedelta import relativedelta + +from odoo import fields, models + + +BUNDESLAENDER = [ + ('burgenland', 'Burgenland'), ('kaernten', 'Kärnten'), + ('niederoesterreich', 'Niederösterreich'), + ('oberoesterreich', 'Oberösterreich'), ('salzburg', 'Salzburg'), + ('steiermark', 'Steiermark'), ('tirol', 'Tirol'), + ('vorarlberg', 'Vorarlberg'), ('wien', 'Wien'), +] + + +class HrVersion(models.Model): + _inherit = 'hr.version' + + l10n_at_sb_wagen_aktiv = fields.Boolean( + string='AT Dienstwagen privat nutzbar', tracking=True, + groups='hr_payroll.group_hr_payroll_user') + l10n_at_sb_wagen_ak = fields.Monetary( + string='AT Dienstwagen Anschaffungskosten', currency_field='currency_id', + tracking=True, groups='hr_payroll.group_hr_payroll_user', + help='Maßgebliche AK inkl. USt/NoVA nach § 4 SBWVO; bei ' + 'Gebrauchtwagen historischer Neupreis (lb-sac-03).') + l10n_at_sb_wagen_erstzulassung = fields.Date( + string='AT Dienstwagen Erstzulassung', tracking=True, + groups='hr_payroll.group_hr_payroll_user', + help='Bestimmt den CO₂-Grenzwert; bei Gebrauchtwagen ist das ' + 'Erstzulassungsjahr maßgeblich (lb-sac-03).') + l10n_at_sb_wagen_co2 = fields.Float( + string='AT Dienstwagen CO₂ (g/km)', tracking=True, + groups='hr_payroll.group_hr_payroll_user') + l10n_at_sb_wagen_halb = fields.Boolean( + string='AT Dienstwagen halber Sachbezug', tracking=True, + groups='hr_payroll.group_hr_payroll_user', + help='Nachweislich höchstens 500 Privat-km/Monat bzw. 6.000/Jahr; ' + 'Nachweis/Fahrtenbuch durch Arbeitgeber prüfen (lb-sac-03).') + l10n_at_sb_wagen_kostenbeitrag = fields.Monetary( + string='AT Dienstwagen Kostenbeitrag monatlich', currency_field='currency_id', + tracking=True, groups='hr_payroll.group_hr_payroll_user', + help='Laufender DN-Kostenbeitrag; mindert nach § 4 Abs 7 SBWVO ' + 'den Sachbezugswert (nicht einzelne Betriebskosten).') + + l10n_at_sb_wohnung_aktiv = fields.Boolean( + string='AT Dienstwohnung überlassen', tracking=True, + groups='hr_payroll.group_hr_payroll_user') + l10n_at_sb_wohnung_flaeche = fields.Float( + string='AT Dienstwohnung Fläche (m²)', tracking=True, + groups='hr_payroll.group_hr_payroll_user') + l10n_at_sb_wohnung_bundesland = fields.Selection( + BUNDESLAENDER, string='AT Dienstwohnung Bundesland', tracking=True, + groups='hr_payroll.group_hr_payroll_user') + l10n_at_sb_wohnung_norm = fields.Boolean( + string='AT Dienstwohnung Normwohnung', default=True, tracking=True, + groups='hr_payroll.group_hr_payroll_user') + l10n_at_sb_wohnung_besorger = fields.Boolean( + string='AT Dienstwohnung Hausbesorger/Betreuer', tracking=True, + groups='hr_payroll.group_hr_payroll_user') + l10n_at_sb_wohnung_arbeitsplatznah = fields.Boolean( + string='AT Dienstwohnung arbeitsplatznah (≤ 15 Min.)', tracking=True, + groups='hr_payroll.group_hr_payroll_user') + l10n_at_sb_wohnung_kein_mittelpunkt = fields.Boolean( + string='AT Dienstwohnung nicht Mittelpunkt Lebensinteressen', tracking=True, + groups='hr_payroll.group_hr_payroll_user') + l10n_at_sb_wohnung_ueberlassung_ab = fields.Date( + string='AT Dienstwohnung überlassen ab', tracking=True, + groups='hr_payroll.group_hr_payroll_user') + l10n_at_sb_wohnung_kostenbeitrag = fields.Monetary( + string='AT Dienstwohnung Kostenbeitrag monatlich', currency_field='currency_id', + tracking=True, groups='hr_payroll.group_hr_payroll_user') + + l10n_at_sb_parkplatz_aktiv = fields.Boolean( + string='AT Parkplatz in Kurzparkzone berechtigt', tracking=True, + groups='hr_payroll.group_hr_payroll_user') + l10n_at_sb_parkplatz_ausnahme = fields.Boolean( + string='AT Parkplatz sachbezugsbefreit', tracking=True, + groups='hr_payroll.group_hr_payroll_user', + help='Nachtdienst ohne Gebührenpflicht, Behinderung mit Pauschbetrag ' + 'oder kontrollierter Verzicht/Nichtparken (lb-sac-18).') + l10n_at_sb_parkplatz_kostenbeitrag = fields.Monetary( + string='AT Parkplatz Kostenbeitrag monatlich', currency_field='currency_id', + tracking=True, groups='hr_payroll.group_hr_payroll_user') + + l10n_at_sb_handy_profil = fields.Selection( + [('beruflich', 'Beruflich/fallweise privat (kein Sachbezug)'), + ('ueberwiegend_privat', 'Überwiegend privat (tatsächliche Kosten)'), + ('private_ueberlassung', 'Private Überlassung (Rabatt-Regime)')], + string='AT Handy-/Arbeitsmittel-Nutzungsprofil', default='beruflich', + tracking=True, groups='hr_payroll.group_hr_payroll_user') + + def _l10n_at_sb_wagen_wert(self, datum): + self.ensure_one() + if not (self.l10n_at_sb_wagen_aktiv and self.l10n_at_sb_wagen_ak): + return 0.0 + co2 = self.l10n_at_sb_wagen_co2 + if co2 == 0.0: + return 0.0 + werte = self.env['hr.rule.parameter']._get_parameter_from_code( + 'at_sb_wagen', datum, raise_if_not_found=False) or {} + grenzen = self.env['hr.rule.parameter']._get_parameter_from_code( + 'at_sb_wagen_co2', datum, raise_if_not_found=False) or {} + ez_jahr = (self.l10n_at_sb_wagen_erstzulassung or datum).year + grenze = float(grenzen.get('ab_2025' if ez_jahr >= 2025 + else str(ez_jahr), + grenzen.get('vor_2016', 0.0))) + niedrig = bool(grenze and co2 <= grenze) + art = 'niedrig' if niedrig else 'hoch' + prefix = 'halb' if self.l10n_at_sb_wagen_halb else 'voll' + wert = self.l10n_at_sb_wagen_ak * float( + werte.get('%s_%s_satz' % (prefix, art), 0.0)) / 100.0 + wert = min(wert, float(werte.get('%s_%s_cap' % (prefix, art), 0.0))) + return max(wert - self.l10n_at_sb_wagen_kostenbeitrag, 0.0) + + def _l10n_at_sb_wohnung_wert(self, datum): + self.ensure_one() + if not (self.l10n_at_sb_wohnung_aktiv + and self.l10n_at_sb_wohnung_flaeche + and self.l10n_at_sb_wohnung_bundesland): + return 0.0 + werte = self.env['hr.rule.parameter']._get_parameter_from_code( + 'at_sb_wohnung', datum, raise_if_not_found=False) or {} + flaeche = self.l10n_at_sb_wohnung_flaeche + klein = (self.l10n_at_sb_wohnung_arbeitsplatznah + and self.l10n_at_sb_wohnung_kein_mittelpunkt) + if klein and flaeche <= float(werte.get('klein_frei_m2', 0.0)): + return 0.0 + wert = flaeche * float(werte.get( + self.l10n_at_sb_wohnung_bundesland, 0.0)) + if (not self.l10n_at_sb_wohnung_norm + and self.l10n_at_sb_wohnung_besorger): + wert *= 1.0 - float(werte.get('kombiniert_abschlag', 0.0)) + elif not self.l10n_at_sb_wohnung_norm: + wert *= 1.0 - float(werte.get('nicht_norm_abschlag', 0.0)) + elif self.l10n_at_sb_wohnung_besorger: + wert *= 1.0 - float(werte.get('besorger_abschlag', 0.0)) + if (klein and flaeche <= float(werte.get('klein_abschlag_bis_m2', 0.0)) + and self.l10n_at_sb_wohnung_ueberlassung_ab + and datum < self.l10n_at_sb_wohnung_ueberlassung_ab + + relativedelta(months=12)): + wert *= 1.0 - float(werte.get('klein_abschlag', 0.0)) + return max(wert - self.l10n_at_sb_wohnung_kostenbeitrag, 0.0) + + def _l10n_at_sb_parkplatz_wert(self, datum): + self.ensure_one() + if not (self.l10n_at_sb_parkplatz_aktiv + and not self.l10n_at_sb_parkplatz_ausnahme): + return 0.0 + wert = float(self.env['hr.rule.parameter']._get_parameter_from_code( + 'at_sb_parkplatz', datum, raise_if_not_found=False) or 0.0) + return max(wert - self.l10n_at_sb_parkplatz_kostenbeitrag, 0.0) + + +class HrEmployee(models.Model): + _inherit = 'hr.employee' + + l10n_at_sb_wagen_aktiv = fields.Boolean( + related='version_id.l10n_at_sb_wagen_aktiv', inherited=True, + readonly=False, groups='hr_payroll.group_hr_payroll_user') + l10n_at_sb_wagen_ak = fields.Monetary( + related='version_id.l10n_at_sb_wagen_ak', inherited=True, + readonly=False, currency_field='currency_id', + groups='hr_payroll.group_hr_payroll_user') + l10n_at_sb_wagen_erstzulassung = fields.Date( + related='version_id.l10n_at_sb_wagen_erstzulassung', inherited=True, + readonly=False, groups='hr_payroll.group_hr_payroll_user') + l10n_at_sb_wagen_co2 = fields.Float( + related='version_id.l10n_at_sb_wagen_co2', inherited=True, + readonly=False, groups='hr_payroll.group_hr_payroll_user') + l10n_at_sb_wagen_halb = fields.Boolean( + related='version_id.l10n_at_sb_wagen_halb', inherited=True, + readonly=False, groups='hr_payroll.group_hr_payroll_user') + l10n_at_sb_wagen_kostenbeitrag = fields.Monetary( + related='version_id.l10n_at_sb_wagen_kostenbeitrag', inherited=True, + readonly=False, currency_field='currency_id', + groups='hr_payroll.group_hr_payroll_user') + l10n_at_sb_wohnung_aktiv = fields.Boolean( + related='version_id.l10n_at_sb_wohnung_aktiv', inherited=True, + readonly=False, groups='hr_payroll.group_hr_payroll_user') + l10n_at_sb_wohnung_flaeche = fields.Float( + related='version_id.l10n_at_sb_wohnung_flaeche', inherited=True, + readonly=False, groups='hr_payroll.group_hr_payroll_user') + l10n_at_sb_wohnung_bundesland = fields.Selection( + related='version_id.l10n_at_sb_wohnung_bundesland', inherited=True, + readonly=False, groups='hr_payroll.group_hr_payroll_user') + l10n_at_sb_wohnung_norm = fields.Boolean( + related='version_id.l10n_at_sb_wohnung_norm', inherited=True, + readonly=False, groups='hr_payroll.group_hr_payroll_user') + l10n_at_sb_wohnung_besorger = fields.Boolean( + related='version_id.l10n_at_sb_wohnung_besorger', inherited=True, + readonly=False, groups='hr_payroll.group_hr_payroll_user') + l10n_at_sb_wohnung_arbeitsplatznah = fields.Boolean( + related='version_id.l10n_at_sb_wohnung_arbeitsplatznah', inherited=True, + readonly=False, groups='hr_payroll.group_hr_payroll_user') + l10n_at_sb_wohnung_kein_mittelpunkt = fields.Boolean( + related='version_id.l10n_at_sb_wohnung_kein_mittelpunkt', inherited=True, + readonly=False, groups='hr_payroll.group_hr_payroll_user') + l10n_at_sb_wohnung_ueberlassung_ab = fields.Date( + related='version_id.l10n_at_sb_wohnung_ueberlassung_ab', inherited=True, + readonly=False, groups='hr_payroll.group_hr_payroll_user') + l10n_at_sb_wohnung_kostenbeitrag = fields.Monetary( + related='version_id.l10n_at_sb_wohnung_kostenbeitrag', inherited=True, + readonly=False, currency_field='currency_id', + groups='hr_payroll.group_hr_payroll_user') + l10n_at_sb_parkplatz_aktiv = fields.Boolean( + related='version_id.l10n_at_sb_parkplatz_aktiv', inherited=True, + readonly=False, groups='hr_payroll.group_hr_payroll_user') + l10n_at_sb_parkplatz_ausnahme = fields.Boolean( + related='version_id.l10n_at_sb_parkplatz_ausnahme', inherited=True, + readonly=False, groups='hr_payroll.group_hr_payroll_user') + l10n_at_sb_parkplatz_kostenbeitrag = fields.Monetary( + related='version_id.l10n_at_sb_parkplatz_kostenbeitrag', inherited=True, + readonly=False, currency_field='currency_id', + groups='hr_payroll.group_hr_payroll_user') + l10n_at_sb_handy_profil = fields.Selection( + related='version_id.l10n_at_sb_handy_profil', inherited=True, + readonly=False, groups='hr_payroll.group_hr_payroll_user') diff --git a/addons/l10n_at_hr_payroll_private/tests/__init__.py b/addons/l10n_at_hr_payroll_private/tests/__init__.py index 0550855..83617cb 100644 --- a/addons/l10n_at_hr_payroll_private/tests/__init__.py +++ b/addons/l10n_at_hr_payroll_private/tests/__init__.py @@ -13,3 +13,5 @@ from . import test_efzg_private from . import test_mutterschutz_private from . import test_aeb_private from . import test_pfaendung_private +from . import test_sachbezuege_private +from . import test_sachbezuege2_private diff --git a/addons/l10n_at_hr_payroll_private/tests/test_sachbezuege2_private.py b/addons/l10n_at_hr_payroll_private/tests/test_sachbezuege2_private.py new file mode 100644 index 0000000..1b5e96d --- /dev/null +++ b/addons/l10n_at_hr_payroll_private/tests/test_sachbezuege2_private.py @@ -0,0 +1,187 @@ +# Part of the odoo-at-payroll project. License: LGPL-3. +"""AP12b: wertgebundene Sachbezüge — Dienstwagen, Dienstwohnung, +Parkplatz, Handy und Bonusmeilen (lb-sac-03/04/09/18/15/08).""" +from datetime import date, timedelta + +from odoo.tests import TransactionCase, tagged + + +@tagged('post_install', '-at_install') +class Sachbezug2PrivateTest(TransactionCase): + + def setUp(self): + super().setUp() + self.structure = self.env.ref( + 'l10n_at_hr_payroll_private.structure_at_privat') + self.kv = self.env.ref('l10n_at_hr_payroll_private.kv_si_2203') + self.gruppe = self.env.ref( + 'l10n_at_hr_payroll_private.kv2203_grp_ii') + + def _employee(self, values=None): + employee = self.env['hr.employee'].create({'name': 'Test AP12b'}) + version = self.env['hr.version'].search( + [('employee_id', '=', employee.id)], limit=1) + version.write({ + 'contract_date_start': date(2020, 1, 1), + 'kv_id': self.kv.id, + 'kv_gruppe_id': self.gruppe.id, + 'erfahrungsstufe': 1, + **(values or {}), + }) + return employee + + def _slip(self, employee, monat=1): + erster = date(2026, monat, 1) + naechster = (erster + timedelta(days=32)).replace(day=1) + slip = self.env['hr.payslip'].create({ + 'name': 'Test-AP12b %d' % monat, + 'employee_id': employee.id, + 'struct_id': self.structure.id, + 'date_from': erster, + 'date_to': naechster - timedelta(days=1), + }) + slip.compute_sheet() + return slip + + def _input(self, slip, code, amount): + input_type = self.env['hr.payslip.input.type'].search( + [('code', '=', code)], limit=1) + self.env['hr.payslip.input'].create({ + 'payslip_id': slip.id, + 'input_type_id': input_type.id, + 'amount': amount, + }) + slip.compute_sheet() + + @staticmethod + def _total(slip, code): + return sum(slip.line_ids.filtered( + lambda line: line.code == code).mapped('total')) + + def test_01_dienstwagen_co2_halb_und_elektro(self): + """§ 4 SBWVO: 36.700 €, EZ 2025, 112 g/km → 1,5 % = 550,50; + halber SB 275,25; CO₂ 130 → 2 % = 734; 0-g-Fahrzeug = 0.""" + basis = { + 'l10n_at_sb_wagen_aktiv': True, + 'l10n_at_sb_wagen_ak': 36700.0, + 'l10n_at_sb_wagen_erstzulassung': date(2025, 1, 1), + } + emp_niedrig = self._employee({**basis, 'l10n_at_sb_wagen_co2': 112}) + self.assertAlmostEqual( + self._total(self._slip(emp_niedrig), 'SB_WAGEN'), 550.50, places=2) + emp_halb = self._employee({**basis, 'l10n_at_sb_wagen_co2': 112, + 'l10n_at_sb_wagen_halb': True}) + self.assertAlmostEqual( + self._total(self._slip(emp_halb), 'SB_WAGEN'), 275.25, places=2) + emp_hoch = self._employee({**basis, 'l10n_at_sb_wagen_co2': 130}) + self.assertAlmostEqual( + self._total(self._slip(emp_hoch), 'SB_WAGEN'), 734.0, places=2) + emp_elektrisch = self._employee({**basis, 'l10n_at_sb_wagen_co2': 0}) + self.assertAlmostEqual( + self._total(self._slip(emp_elektrisch), 'SB_WAGEN'), 0.0, places=2) + + def test_02_dienstwagen_kostenbeitrag(self): + """Laufender Kostenbeitrag mindert nach § 4 Abs 7 SBWVO: + 550,50 − 300 = 250,50 € (lb-sac-04).""" + employee = self._employee({ + 'l10n_at_sb_wagen_aktiv': True, + 'l10n_at_sb_wagen_ak': 36700.0, + 'l10n_at_sb_wagen_erstzulassung': date(2025, 1, 1), + 'l10n_at_sb_wagen_co2': 112.0, + 'l10n_at_sb_wagen_kostenbeitrag': 300.0, + }) + self.assertAlmostEqual( + self._total(self._slip(employee), 'SB_WAGEN'), 250.50, places=2) + + def test_03_dienstwohnung_und_parkplatz(self): + """§ 2/§ 4a SBWVO: Wien 72,4 m² = 482,91; Kleinwohnung 30 m² + = 0, 40 m² = 173,42; Parkplatz 14,53 abzüglich Kostenbeitrag.""" + common = { + 'l10n_at_sb_wohnung_aktiv': True, + 'l10n_at_sb_wohnung_bundesland': 'wien', + 'l10n_at_sb_wohnung_arbeitsplatznah': True, + 'l10n_at_sb_wohnung_kein_mittelpunkt': True, + 'l10n_at_sb_wohnung_ueberlassung_ab': date(2026, 1, 1), + } + employee = self._employee({**common, 'l10n_at_sb_wohnung_flaeche': 72.4}) + self.assertAlmostEqual( + self._total(self._slip(employee), 'SB_WOHNUNG'), 482.91, places=2) + employee_klein = self._employee({**common, 'l10n_at_sb_wohnung_flaeche': 30}) + self.assertAlmostEqual( + self._total(self._slip(employee_klein), 'SB_WOHNUNG'), 0.0, places=2) + employee_kombiniert = self._employee({ + 'l10n_at_sb_wohnung_aktiv': True, + 'l10n_at_sb_wohnung_bundesland': 'wien', + 'l10n_at_sb_wohnung_flaeche': 100, + 'l10n_at_sb_wohnung_norm': False, + 'l10n_at_sb_wohnung_besorger': True, + }) + self.assertAlmostEqual( + self._total(self._slip(employee_kombiniert), 'SB_WOHNUNG'), + 296.815, places=2) + employee_abschlag = self._employee({**common, 'l10n_at_sb_wohnung_flaeche': 40}) + self.assertAlmostEqual( + self._total(self._slip(employee_abschlag), 'SB_WOHNUNG'), 173.42, + places=2) + employee_parkplatz = self._employee({ + 'l10n_at_sb_parkplatz_aktiv': True, + 'l10n_at_sb_parkplatz_kostenbeitrag': 5.0, + }) + self.assertAlmostEqual( + self._total(self._slip(employee_parkplatz), 'SB_PARKPLATZ'), 9.53, + places=2) + + def test_04_handy_und_gebrauchtverkauf(self): + """Handy-Mehrkosten sind nur bei überwiegend privater Nutzung + laufend; Verkauf unter Buchwert ist SV-laufend/LSt-sonstig.""" + emp_handy = self._employee({ + 'l10n_at_sb_handy_profil': 'ueberwiegend_privat'}) + slip_handy = self._slip(emp_handy) + self._input(slip_handy, 'ATP_SB_HANDY', 80.0) + self.assertAlmostEqual(self._total(slip_handy, 'SB_HANDY'), 80.0) + emp_beruflich = self._employee() + slip_beruflich = self._slip(emp_beruflich) + self._input(slip_beruflich, 'ATP_SB_HANDY', 80.0) + self.assertAlmostEqual(self._total(slip_beruflich, 'SB_HANDY'), 0.0) + + emp_verkauf = self._employee() + slip_verkauf = self._slip(emp_verkauf) + slip_basis = self._slip(self._employee()) + self._input(slip_verkauf, 'ATP_SB_GEBRAUCHT', 700.0) + self.assertAlmostEqual(self._total(slip_verkauf, 'SB_GEBRAUCHT'), 700.0) + self.assertAlmostEqual( + slip_verkauf._l10n_at_sv_basis_roh() + - slip_basis._l10n_at_sv_basis_roh(), 700.0, places=2) + self.assertAlmostEqual( + slip_verkauf._l10n_at_svdn_basis_sz(), 0.0, places=2) + self.assertAlmostEqual( + slip_verkauf._l10n_at_brutto('sonstig') + - slip_basis._l10n_at_brutto('sonstig'), 700.0, places=2) + self.assertAlmostEqual( + slip_verkauf._at_lnk_basis('flaf') + - slip_basis._at_lnk_basis('flaf'), 700.0, places=2) + + def test_05_bonusmeilen_nur_sv(self): + """Drittlohn aus privat eingelösten Bonusmeilen: ausschließlich + laufende SV, kein LSt-/LNK- oder Auszahlungsnettoanspruch.""" + slip = self._slip(self._employee()) + basis = self._slip(self._employee()) + self._input(slip, 'ATP_SB_BONUSMEILEN', 300.0) + self.assertAlmostEqual(self._total(slip, 'SB_BONUSMEILEN'), 300.0) + self.assertAlmostEqual( + slip._l10n_at_sv_basis_roh() - basis._l10n_at_sv_basis_roh(), + 300.0, places=2) + self.assertAlmostEqual( + slip._at_lnk_basis('flaf') - basis._at_lnk_basis('flaf'), + 0.0, places=2) + svdn_diff = (slip._l10n_at_svdn_gesamt() + - basis._l10n_at_svdn_gesamt()) + # Der Drittlohn selbst ist nicht LSt-pflichtig; seine DN-SV ist + # jedoch nach der bestehenden §-62-Kette LSt-BG-mindernd. + self.assertAlmostEqual( + slip._l10n_at_bemessung_laufen() + - basis._l10n_at_bemessung_laufen(), -svdn_diff, places=2) + lst_diff = self._total(slip, 'LST_LFD') - self._total(basis, 'LST_LFD') + self.assertAlmostEqual( + self._total(slip, 'NET') - self._total(basis, 'NET'), + -svdn_diff - lst_diff, places=1) diff --git a/addons/l10n_at_hr_payroll_private/tests/test_sachbezuege_private.py b/addons/l10n_at_hr_payroll_private/tests/test_sachbezuege_private.py new file mode 100644 index 0000000..275b38d --- /dev/null +++ b/addons/l10n_at_hr_payroll_private/tests/test_sachbezuege_private.py @@ -0,0 +1,279 @@ +# Part of the odoo-at-payroll project. License: LGPL-3. +"""AP12a: steuerfreie Sachbezüge auf der privatwirtschaftlichen +Structure end-to-end — manuelle SB-Eingaben (ATP_SB_*) → SB_*-Regeln +→ §-49-Abs-3-SV- und §-3/§-26-LSt-Freibeträge (Freibeträge, Jahres- +Kumulative je DN, lohnnebenkostenfrei): Mahlzeiten (lb-sac-17), +Betriebsveranstaltungen (lb-sac-07), Sachzuwendungen (lb-ges-02), +Mitarbeiterrabatte (lb-sac-02), Homeoffice/Telearbeit (lb-tel-04/05).""" +from datetime import date, timedelta + +from odoo.tests import TransactionCase, tagged + + +@tagged('post_install', '-at_install') +class SachbezugPrivateTest(TransactionCase): + + SB_CODES = ('SB_MAHLZEIT', 'SB_BETRIEBSVERANSTALTUNG', + 'SB_SACHZUWENDUNG', 'SB_RABATT', 'SB_HOMEOFFICE') + + def setUp(self): + super().setUp() + self.structure = self.env.ref( + 'l10n_at_hr_payroll_private.structure_at_privat') + self.kv2203 = self.env.ref( + 'l10n_at_hr_payroll_private.kv_si_2203') + self.gruppe_ii = self.env.ref( + 'l10n_at_hr_payroll_private.kv2203_grp_ii') + self.calendar = self.env['resource.calendar'].create({ + 'name': 'AT Test SB 40h Mo-Fr', + 'tz': 'Europe/Vienna', + 'hours_per_day': 8, + 'attendance_ids': [ + (0, 0, {'name': 'Tag %d' % wd, 'dayofweek': str(wd), + 'hour_from': 8.0, 'hour_to': 16.0}) + for wd in range(5) + ], + }) + self.env.company.resource_calendar_id = self.calendar + + def _employee(self): + employee = self.env['hr.employee'].create({ + 'name': 'Test SB privat', + }) + version = self.env['hr.version'].search( + [('employee_id', '=', employee.id)], limit=1) + if not version: + version = self.env['hr.version'].create({ + 'employee_id': employee.id, + 'date_start': date(2020, 1, 1)}) + version.write({ + 'contract_date_start': date(2020, 1, 1), + 'kv_id': self.kv2203.id, + 'kv_gruppe_id': self.gruppe_ii.id, + 'erfahrungsstufe': 1, + 'resource_calendar_id': self.calendar.id, + }) + return employee + + def _slip(self, employee, monat, jahr=2026): + erster = date(jahr, monat, 1) + naechster = (erster + timedelta(days=32)).replace(day=1) + slip = self.env['hr.payslip'].create({ + 'name': 'Test-SB-Privat %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() + return slip + + def _sb_input(self, slip, code, amount): + """SB-Eingabe anhängen und Lauf neu berechnen; liefert die + zugehörige SB-Zeile (Regel-Code = SB_ + Eingabe-Code-Suffix).""" + typ = self.env['hr.payslip.input.type'].search( + [('code', '=', code)], limit=1) + self.env['hr.payslip.input'].create({ + 'payslip_id': slip.id, + 'input_type_id': typ.id, + 'amount': amount, + }) + slip.compute_sheet() + rule_code = 'SB_%s' % code.removeprefix('ATP_SB_') + return slip.line_ids.filtered(lambda l: l.code == rule_code) + + # ------------------------------------------------------------------ + # RF-SB-1/2: Mahlzeiten-Gutscheine (8 €/AT, lb-sac-17) + # ------------------------------------------------------------------ + def test_01_mahlzeit_voll_frei(self): + """10 Gutschein-Arbeitstage à 8 € (at_sb_werte.mahlzeit_wert): + SB-Zeile 80 €, SV- und LSt-Basis wie ohne SB, LNK frei + (§ 3 Z 17 / § 49 Z 12, lb-sac-17).""" + emp_mit = self._employee() + emp_ohne = self._employee() + slip_mit = self._slip(emp_mit, 1) + slip_ohne = self._slip(emp_ohne, 1) + linie = self._sb_input(slip_mit, 'ATP_SB_MAHLZEIT', 10) + self.assertAlmostEqual(linie.total, 80.0, places=2) + self.assertAlmostEqual(linie.quantity, 10.0, places=2) + self.assertAlmostEqual( + slip_mit._l10n_at_sv49_freie_laufen(), 80.0, places=2) + self.assertAlmostEqual( + slip_mit._l10n_at_st_frei_freie_laufen(), 80.0, places=2) + # Voll im Freibetrag: keine Wirkung auf SV-, LSt- und LNK-Basis + self.assertAlmostEqual( + slip_mit._l10n_at_sv_basis_roh() + - slip_ohne._l10n_at_sv_basis_roh(), 0.0, places=2) + self.assertAlmostEqual( + slip_mit._l10n_at_bemessung_laufen() + - slip_ohne._l10n_at_bemessung_laufen(), 0.0, places=2) + self.assertAlmostEqual( + slip_mit._at_lnk_basis('flaf') + - slip_ohne._at_lnk_basis('flaf'), 0.0, places=2) + + def test_02_mahlzeit_ueberhang(self): + """Tatsächlicher Bonwert 11 €/AT (at_sb_werte ab 6/2026): + 10 AT → SB-Zeile 110 €, frei 80 €, Überhang 30 € SV-/LSt-/ + LNK-pflichtig (lb-sac-17).""" + self.env['hr.rule.parameter.value'].create({ + 'rule_parameter_id': self.env.ref( + 'l10n_at_hr_payroll_private.hr_rule_parameter_at_sb_werte' + ).id, + 'date_from': date(2026, 6, 1), + 'parameter_value': + "{'mahlzeit_wert': 11.0, 'homeoffice_wert': 3.0}", + }) + emp_mit = self._employee() + emp_ohne = self._employee() + slip_mit = self._slip(emp_mit, 6) + slip_ohne = self._slip(emp_ohne, 6) + linie = self._sb_input(slip_mit, 'ATP_SB_MAHLZEIT', 10) + self.assertAlmostEqual(linie.total, 110.0, places=2) + self.assertAlmostEqual( + slip_mit._l10n_at_sv49_freie_laufen(), 80.0, places=2) + self.assertAlmostEqual( + slip_mit._l10n_at_st_frei_freie_laufen(), 80.0, places=2) + # Überhang 30 € in SV- und LNK-Bemessung; LSt-Bemessung: + # Überhang abzüglich der darauf entfallenden SV-DN (SV ist aus + # der LSt-BG abzuziehen — §-62-Kette) + self.assertAlmostEqual( + slip_mit._l10n_at_sv_basis_roh() + - slip_ohne._l10n_at_sv_basis_roh(), 30.0, places=2) + svdn_diff = (slip_mit._l10n_at_svdn_gesamt() + - slip_ohne._l10n_at_svdn_gesamt()) + self.assertAlmostEqual( + slip_mit._l10n_at_bemessung_laufen() + - slip_ohne._l10n_at_bemessung_laufen() + svdn_diff, + 30.0, places=2) + self.assertAlmostEqual( + slip_mit._at_lnk_basis('flaf') + - slip_ohne._at_lnk_basis('flaf'), 30.0, places=2) + + # ------------------------------------------------------------------ + # RF-SB-3/4: Jahrespools (§ 3 Z 14/Z 21, § 49 Z 17/Z 29) + # ------------------------------------------------------------------ + def test_03_betriebsveranstaltung_pool(self): + """365 €/J-Pool je DN (lb-sac-07): Jänner 300 € voll frei + (validiert), Februar 100 € → frei 65 €, Überhang 35 € + pflichtig.""" + emp = self._employee() + slip_jan = self._slip(emp, 1) + linie_jan = self._sb_input( + slip_jan, 'ATP_SB_BETRIEBSVERANSTALTUNG', 300) + self.assertAlmostEqual(linie_jan.total, 300.0, places=2) + self.assertAlmostEqual( + slip_jan._l10n_at_st_frei_freie_laufen(), 300.0, places=2) + slip_jan.action_payslip_done() + + emp_ohne = self._employee() + slip_feb = self._slip(emp, 2) + linie_feb = self._sb_input( + slip_feb, 'ATP_SB_BETRIEBSVERANSTALTUNG', 100) + self.assertAlmostEqual(linie_feb.total, 100.0, places=2) + self.assertAlmostEqual( + slip_feb._l10n_at_st_frei_ytd('betriebsveranstaltung'), + 300.0, places=2) + self.assertAlmostEqual( + slip_feb._l10n_at_st_frei_freie_laufen(), 65.0, places=2) + self.assertAlmostEqual( + slip_feb._l10n_at_sv49_freie_laufen(), 65.0, places=2) + slip_ohne = self._slip(emp_ohne, 2) + self.assertAlmostEqual( + slip_feb._l10n_at_sv_basis_roh() + - slip_ohne._l10n_at_sv_basis_roh(), 35.0, places=2) + svdn_diff = (slip_feb._l10n_at_svdn_gesamt() + - slip_ohne._l10n_at_svdn_gesamt()) + self.assertAlmostEqual( + slip_feb._l10n_at_bemessung_laufen() + - slip_ohne._l10n_at_bemessung_laufen() + svdn_diff, + 35.0, places=2) + + def test_04_sachzuwendung_und_rabatt_pools(self): + """Eigene Pools neben der Betriebsveranstaltung: + Sachzuwendung 186 €/J (lb-ges-02), Rabatt 1.000 €/J + (lb-sac-02) — Jänner-Verbrauch mindert den Februar-Freibetrag, + der Überhang ist pflichtig.""" + emp_g = self._employee() + slip_g_jan = self._slip(emp_g, 1) + self._sb_input(slip_g_jan, 'ATP_SB_SACHZUWENDUNG', 100) + self.assertAlmostEqual( + slip_g_jan._l10n_at_st_frei_freie_laufen(), 100.0, places=2) + slip_g_jan.action_payslip_done() + emp_g_ohne = self._employee() + slip_g_feb = self._slip(emp_g, 2) + self._sb_input(slip_g_feb, 'ATP_SB_SACHZUWENDUNG', 100) + # frei: 186 − 100 = 86 → Überhang 14 + self.assertAlmostEqual( + slip_g_feb._l10n_at_st_frei_freie_laufen(), 86.0, places=2) + slip_g_ohne = self._slip(emp_g_ohne, 2) + self.assertAlmostEqual( + slip_g_feb._l10n_at_sv_basis_roh() + - slip_g_ohne._l10n_at_sv_basis_roh(), 14.0, places=2) + + emp_r = self._employee() + slip_r_jan = self._slip(emp_r, 1) + self._sb_input(slip_r_jan, 'ATP_SB_RABATT', 700) + self.assertAlmostEqual( + slip_r_jan._l10n_at_st_frei_freie_laufen(), 700.0, places=2) + slip_r_jan.action_payslip_done() + emp_r_ohne = self._employee() + slip_r_feb = self._slip(emp_r, 2) + self._sb_input(slip_r_feb, 'ATP_SB_RABATT', 500) + # frei: 1.000 − 700 = 300 → Überhang 200 + self.assertAlmostEqual( + slip_r_feb._l10n_at_st_frei_freie_laufen(), 300.0, places=2) + slip_r_ohne = self._slip(emp_r_ohne, 2) + self.assertAlmostEqual( + slip_r_feb._l10n_at_sv_basis_roh() + - slip_r_ohne._l10n_at_sv_basis_roh(), 200.0, places=2) + + # ------------------------------------------------------------------ + # RF-SB-5: Homeoffice/Telearbeit (3 €/AT, max 100 Tage/J) + # ------------------------------------------------------------------ + def test_05_homeoffice_jahreslimit(self): + """§ 26 Z 9: 3 €/AT, max 100 Tage/J (lb-tel-04/05) — 70 Tage + (210 €) frei (validiert), dann 80 Tage → nur 30 Tage frei + (90 €), Überhang 150 € pflichtig.""" + emp = self._employee() + slip_jan = self._slip(emp, 1) + linie_jan = self._sb_input(slip_jan, 'ATP_SB_HOMEOFFICE', 70) + self.assertAlmostEqual(linie_jan.total, 210.0, places=2) + self.assertAlmostEqual(linie_jan.quantity, 70.0, places=2) + self.assertAlmostEqual( + slip_jan._l10n_at_st_frei_freie_laufen(), 210.0, places=2) + slip_jan.action_payslip_done() + + emp_ohne = self._employee() + slip_feb = self._slip(emp, 2) + linie_feb = self._sb_input(slip_feb, 'ATP_SB_HOMEOFFICE', 80) + self.assertAlmostEqual(linie_feb.total, 240.0, places=2) + self.assertAlmostEqual( + slip_feb._l10n_at_st_frei_ytd('homeoffice', menge=True), + 70.0, places=2) + self.assertAlmostEqual( + slip_feb._l10n_at_st_frei_freie_laufen(), 90.0, places=2) + slip_ohne = self._slip(emp_ohne, 2) + self.assertAlmostEqual( + slip_feb._l10n_at_sv_basis_roh() + - slip_ohne._l10n_at_sv_basis_roh(), 150.0, places=2) + svdn_diff = (slip_feb._l10n_at_svdn_gesamt() + - slip_ohne._l10n_at_svdn_gesamt()) + self.assertAlmostEqual( + slip_feb._l10n_at_bemessung_laufen() + - slip_ohne._l10n_at_bemessung_laufen() + svdn_diff, + 150.0, places=2) + + # ------------------------------------------------------------------ + # RF-SB-6: ohne SB-Eingaben neutral + # ------------------------------------------------------------------ + def test_06_ohne_sb_eingaben_neutral(self): + """Ohne SB-Eingaben: keine SB-Zeilen und keine Freibeträge — + Bemessungen wie ohne AP12a (flag 'keine' ist wirkungslos).""" + emp = self._employee() + slip = self._slip(emp, 1) + self.assertFalse( + slip.line_ids.filtered(lambda l: l.code in self.SB_CODES)) + self.assertAlmostEqual( + slip._l10n_at_st_frei_freie_laufen(), 0.0, places=2) + self.assertAlmostEqual( + slip._l10n_at_sv49_freie_laufen(), 0.0, places=2) diff --git a/addons/l10n_at_hr_payroll_private/views/sachbezuege_views.xml b/addons/l10n_at_hr_payroll_private/views/sachbezuege_views.xml new file mode 100644 index 0000000..0c7a555 --- /dev/null +++ b/addons/l10n_at_hr_payroll_private/views/sachbezuege_views.xml @@ -0,0 +1,40 @@ + + + + hr.employee.form.sachbezuege + hr.employee + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +