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
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+