Compare commits

...

2 Commits

21 changed files with 1603 additions and 23 deletions
+92 -3
View File
@@ -2,6 +2,80 @@
## 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, >3545 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 Reisekosten & Pendlerförderung
(inkl. Jobticket)** als nächster AP. Commit **`8efea77`** (2026-09-14,
nach Nutzer-Freigabe) enthält AP12a+b.
**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 4144/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.
- **AP12b erledigt** in Commit `8efea77`: Dienstwagen, Kleinwohnung,
Parkplatz, Handy und Bonusmeilen mit Model B (`hr.version`-Felder)
für wertgebundene sowie Model A (Eingaben je Lauf) für
ereignisgesteuerte Werte. Jobticket → AP13.
**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 +102,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, Sachbezugspfändung-Schnittstelle
(AP12 jetzt umgesetzt), 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 AP0AP10-Nutzer-Katalog komplett** (2026-09-12,
@@ -1364,6 +1439,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
+1
View File
@@ -4,6 +4,7 @@ __pycache__/
.lexis360/
.wiku/
.additional_docs/
.ris/
odoo_19.0+e.20260910/
.venv/
.zed-hours/
+1 -1
View File
@@ -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',
@@ -103,4 +103,21 @@
<field name="date_from">2027-01-01</field>
<field name="parameter_value">{'stunden': 10.0, 'betrag': 120.0}</field>
</record>
<!-- Steuerfreie Sachbezüge § 3/§ 26 EStG (Freibeträge, Jahres- -->
<!-- Kumulative je DN; lb-sac-17/07/02, lb-ges-02). Mahlzeiten -->
<!-- 8 €/AT (Z 17), Mitarbeiterrabatte 1.000 €/J (Z 21), -->
<!-- Betriebsveranstaltungen 365 €/J (Z 14), Sachzuwendungen/ -->
<!-- Jubiläum/Geschenke 186 €/J (Z 14), Homeoffice 3 €/Tag max 100 -->
<!-- Tage/J (Z 9). Grenzen als Jahresversionen führen. -->
<record id="hr_rule_parameter_at_st_grenzen" model="hr.rule.parameter">
<field name="name">Steuerfreie Sachbezüge § 3/§ 26 EStG (Grenzen)</field>
<field name="code">at_st_grenzen</field>
<field name="country_id" ref="base.at"/>
</record>
<record id="hr_rule_parameter_value_at_st_grenzen" model="hr.rule.parameter.value">
<field name="rule_parameter_id" ref="hr_rule_parameter_at_st_grenzen"/>
<field name="date_from">2026-01-01</field>
<field name="parameter_value">{'mahlzeit_tag': 8.0, 'rabatt_jahr': 1000.0, 'betriebsveranstaltung_jahr': 365.0, 'jubilaeum_jahr': 186.0, 'homeoffice_tag': 3.0, 'homeoffice_tage': 100}</field>
</record>
</odoo>
+96 -6
View File
@@ -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);
@@ -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:
@@ -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)
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)
@@ -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',
@@ -7,4 +7,47 @@
<field name="name">Prämie/Ausgleich (manuell)</field>
<field name="code">ATP_PRAMIE</field>
</record>
<!-- AP12a: steuerfreie Sachbezüge (§ 3/§ 26 EStG, § 49 Abs 3 ASVG) -->
<!-- als manuelle Eingaben. Menge (number_of_days) = Arbeitstage -->
<!-- (Mahlzeiten/Homeoffice), amount = Tages-/Pauschalwert; die -->
<!-- Freibetrags-/Kumulationslogik läuft über die SB_*-Regeln. -->
<record id="input_type_atp_sb_mahlzeit" model="hr.payslip.input.type">
<field name="name">Sachbezug Mahlzeiten — Anz. Gutschein-Arbeitstage (manuell)</field>
<field name="code">ATP_SB_MAHLZEIT</field>
<field name="is_quantity" eval="True"/>
</record>
<record id="input_type_atp_sb_betriebsveranstaltung" model="hr.payslip.input.type">
<field name="name">Sachbezug Betriebsveranstaltung — Gesamtvalue (manuell)</field>
<field name="code">ATP_SB_BETRIEBSVERANSTALTUNG</field>
</record>
<record id="input_type_atp_sb_sachzuwendung" model="hr.payslip.input.type">
<field name="name">Sachbezug Sachzuwendung/Geschenk — Gesamtvalue (manuell)</field>
<field name="code">ATP_SB_SACHZUWENDUNG</field>
</record>
<record id="input_type_atp_sb_rabatt" model="hr.payslip.input.type">
<field name="name">Sachbezug Mitarbeiterrabatt — Gesamtvalue (manuell)</field>
<field name="code">ATP_SB_RABATT</field>
</record>
<record id="input_type_atp_sb_homeoffice" model="hr.payslip.input.type">
<field name="name">Sachbezug Homeoffice — Anz. Tage (manuell)</field>
<field name="code">ATP_SB_HOMEOFFICE</field>
<field name="is_quantity" eval="True"/>
</record>
<!-- AP12b: ereignisbezogene Werte. Amount ist jeweils der bereits
ermittelte Vorteil (tatsächliche Mehrkosten bzw. Buchwert minus
Kaufpreis); Stammdaten steuern Wagen/Wohnung/Parkplatz. -->
<record id="input_type_atp_sb_handy" model="hr.payslip.input.type">
<field name="name">Sachbezug Handy — private Mehrkosten (manuell)</field>
<field name="code">ATP_SB_HANDY</field>
</record>
<record id="input_type_atp_sb_gebraucht" model="hr.payslip.input.type">
<field name="name">Sachbezug Arbeitsmittel-Verkauf unter Buchwert (manuell)</field>
<field name="code">ATP_SB_GEBRAUCHT</field>
</record>
<record id="input_type_atp_sb_bonusmeilen" model="hr.payslip.input.type">
<field name="name">Sachbezug Bonusmeilen — Einlösungswert (manuell)</field>
<field name="code">ATP_SB_BONUSMEILEN</field>
</record>
</odoo>
@@ -0,0 +1,45 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- AP12b: Sachbezugswerte-VO — Jahresversionen, keine Literale in
den Regeln. Rechtsanker: lb-sac-03/04/09/18. -->
<record id="hr_rule_parameter_at_sb_wagen" model="hr.rule.parameter">
<field name="name">Sachbezug Dienstwagen — Sätze und Höchstwerte</field>
<field name="code">at_sb_wagen</field>
<field name="country_id" ref="base.at"/>
</record>
<record id="hr_rule_parameter_value_at_sb_wagen" model="hr.rule.parameter.value">
<field name="rule_parameter_id" ref="hr_rule_parameter_at_sb_wagen"/>
<field name="date_from">2026-01-01</field>
<field name="parameter_value">{'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}</field>
</record>
<record id="hr_rule_parameter_at_sb_wagen_co2" model="hr.rule.parameter">
<field name="name">Sachbezug Dienstwagen — CO₂-Grenzwerte</field>
<field name="code">at_sb_wagen_co2</field>
<field name="country_id" ref="base.at"/>
</record>
<record id="hr_rule_parameter_value_at_sb_wagen_co2" model="hr.rule.parameter.value">
<field name="rule_parameter_id" ref="hr_rule_parameter_at_sb_wagen_co2"/>
<field name="date_from">2026-01-01</field>
<field name="parameter_value">{'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}</field>
</record>
<record id="hr_rule_parameter_at_sb_wohnung" model="hr.rule.parameter">
<field name="name">Sachbezug Dienstwohnung — m²-Werte und Abschläge</field>
<field name="code">at_sb_wohnung</field>
<field name="country_id" ref="base.at"/>
</record>
<record id="hr_rule_parameter_value_at_sb_wohnung" model="hr.rule.parameter.value">
<field name="rule_parameter_id" ref="hr_rule_parameter_at_sb_wohnung"/>
<field name="date_from">2026-01-01</field>
<field name="parameter_value">{'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}</field>
</record>
<record id="hr_rule_parameter_at_sb_parkplatz" model="hr.rule.parameter">
<field name="name">Sachbezug Parkplatz — Monatswert</field>
<field name="code">at_sb_parkplatz</field>
<field name="country_id" ref="base.at"/>
</record>
<record id="hr_rule_parameter_value_at_sb_parkplatz" model="hr.rule.parameter.value">
<field name="rule_parameter_id" ref="hr_rule_parameter_at_sb_parkplatz"/>
<field name="date_from">2026-01-01</field>
<field name="parameter_value">14.53</field>
</record>
</odoo>
@@ -0,0 +1,24 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- ============================================================ -->
<!-- AP12a: Sachbezüge — tagesbasierte Werte (tatsächlicher -->
<!-- Wert pro Arbeitstag). Mahlzeiten: tatsächlicher Bonwert -->
<!-- (Default = 8 €/AT = Freibetrag, d. h. vollständig frei; -->
<!-- Company setzt den tatsächlichen Bonwert, z. B. 11,0 — der -->
<!-- Überhang (Wert8)×Tage ist steuer-/beitragspflichtig, -->
<!-- lb-sac-17). Homeoffice: gesetzlich fix 3 €/AT (lb-tel-04/05, -->
<!-- § 26 Z 9 EStG). Freibetrags-Caps selbst liegen im Kern -->
<!-- (at_st_grenzen / at_sv49_grenzen). -->
<!-- ============================================================ -->
<record id="hr_rule_parameter_at_sb_werte" model="hr.rule.parameter">
<field name="name">Sachbezüge — tagesbasierte Werte</field>
<field name="code">at_sb_werte</field>
<field name="country_id" ref="base.at"/>
</record>
<record id="hr_rule_parameter_value_at_sb_werte"
model="hr.rule.parameter.value">
<field name="rule_parameter_id" ref="hr_rule_parameter_at_sb_werte"/>
<field name="date_from">2026-01-01</field>
<field name="parameter_value">{'mahlzeit_wert': 8.0, 'homeoffice_wert': 3.0}</field>
</record>
</odoo>
@@ -0,0 +1,81 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- AP12b: wertgebundene Sachbezüge. Wagen/Wohnung/Parkplatz sind
Version-Stammdaten; Handy/Verkauf/Bonusmeilen Ereignis-Eingaben.
Die Regeln stehen vor B13/B14 (50ff.) und SV/LSt (60ff.). -->
<record id="rule_sb_parkplatz" model="hr.salary.rule">
<field name="name">Sachbezug Parkplatz am Dienstort</field>
<field name="code">SB_PARKPLATZ</field>
<field name="sequence" eval="53"/>
<field name="category_id" ref="l10n_at_hr_payroll_private.category_atp_brutto"/>
<field name="struct_id" ref="l10n_at_hr_payroll_private.structure_at_privat"/>
<field name="condition_select">python</field>
<field name="condition_python">result = payslip.version_id._l10n_at_sb_parkplatz_wert(payslip.date_to) &gt; 0</field>
<field name="amount_select">code</field>
<field name="l10n_at_bemessung">laufend</field>
<field name="amount_python_compute">result = payslip.version_id._l10n_at_sb_parkplatz_wert(payslip.date_to)</field>
</record>
<record id="rule_sb_wohnung" model="hr.salary.rule">
<field name="name">Sachbezug Dienstwohnung</field>
<field name="code">SB_WOHNUNG</field>
<field name="sequence" eval="54"/>
<field name="category_id" ref="l10n_at_hr_payroll_private.category_atp_brutto"/>
<field name="struct_id" ref="l10n_at_hr_payroll_private.structure_at_privat"/>
<field name="condition_select">python</field>
<field name="condition_python">result = payslip.version_id._l10n_at_sb_wohnung_wert(payslip.date_to) &gt; 0</field>
<field name="amount_select">code</field>
<field name="l10n_at_bemessung">laufend</field>
<field name="amount_python_compute">result = payslip.version_id._l10n_at_sb_wohnung_wert(payslip.date_to)</field>
</record>
<record id="rule_sb_wagen" model="hr.salary.rule">
<field name="name">Sachbezug Dienstwagen Privatnutzung</field>
<field name="code">SB_WAGEN</field>
<field name="sequence" eval="55"/>
<field name="category_id" ref="l10n_at_hr_payroll_private.category_atp_brutto"/>
<field name="struct_id" ref="l10n_at_hr_payroll_private.structure_at_privat"/>
<field name="condition_select">python</field>
<field name="condition_python">result = payslip.version_id._l10n_at_sb_wagen_wert(payslip.date_to) &gt; 0</field>
<field name="amount_select">code</field>
<field name="l10n_at_bemessung">laufend</field>
<field name="amount_python_compute">result = payslip.version_id._l10n_at_sb_wagen_wert(payslip.date_to)</field>
</record>
<record id="rule_sb_gebraucht" model="hr.salary.rule">
<field name="name">Sachbezug Arbeitsmittel-Verkauf unter Buchwert</field>
<field name="code">SB_GEBRAUCHT</field>
<field name="sequence" eval="56"/>
<field name="category_id" ref="l10n_at_hr_payroll_private.category_atp_brutto"/>
<field name="struct_id" ref="l10n_at_hr_payroll_private.structure_at_privat"/>
<field name="condition_select">python</field>
<field name="condition_python">result = 'ATP_SB_GEBRAUCHT' in inputs and inputs['ATP_SB_GEBRAUCHT'].amount</field>
<field name="amount_select">code</field>
<field name="l10n_at_bemessung">sonstig</field>
<field name="l10n_at_sv_art">laufend</field>
<field name="amount_python_compute">result = inputs['ATP_SB_GEBRAUCHT'].amount</field>
</record>
<record id="rule_sb_bonusmeilen" model="hr.salary.rule">
<field name="name">Sachbezug Bonusmeilen (Drittlohn, nur SV)</field>
<field name="code">SB_BONUSMEILEN</field>
<field name="sequence" eval="57"/>
<!-- Drittlohn erhöht nicht den Auszahlungsnettoanspruch. -->
<field name="category_id" ref="l10n_at_hr_payroll_private.category_atp_dg"/>
<field name="struct_id" ref="l10n_at_hr_payroll_private.structure_at_privat"/>
<field name="condition_select">python</field>
<field name="condition_python">result = 'ATP_SB_BONUSMEILEN' in inputs and inputs['ATP_SB_BONUSMEILEN'].amount</field>
<field name="amount_select">code</field>
<field name="l10n_at_bemessung">neutral</field>
<field name="l10n_at_sv_art">laufend</field>
<field name="amount_python_compute">result = inputs['ATP_SB_BONUSMEILEN'].amount</field>
</record>
<record id="rule_sb_handy" model="hr.salary.rule">
<field name="name">Sachbezug Handy — private Mehrkosten</field>
<field name="code">SB_HANDY</field>
<field name="sequence" eval="58"/>
<field name="category_id" ref="l10n_at_hr_payroll_private.category_atp_brutto"/>
<field name="struct_id" ref="l10n_at_hr_payroll_private.structure_at_privat"/>
<field name="condition_select">python</field>
<field name="condition_python">result = payslip.version_id.l10n_at_sb_handy_profil == 'ueberwiegend_privat' and 'ATP_SB_HANDY' in inputs and inputs['ATP_SB_HANDY'].amount</field>
<field name="amount_select">code</field>
<field name="l10n_at_bemessung">laufend</field>
<field name="amount_python_compute">result = inputs['ATP_SB_HANDY'].amount</field>
</record>
</odoo>
@@ -0,0 +1,129 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- ============================================================ -->
<!-- AP12a: steuerfreie Sachbezüge — Freibeträge nach -->
<!-- § 3/§ 26 EStG, beitragsfrei § 49 Abs 3 ASVG, lohnneben- -->
<!-- kostenfrei (lb-sac-17/07/02, lb-ges-02). -->
<!-- Input-Semantik: -->
<!-- - tagesbasiert (Mahlzeiten/Homeoffice): amount = Anzahl -->
<!-- Arbeitstage; der Tageswert kommt aus dem versionierten -->
<!-- Parameter at_sb_werte (Kern-Mechanik: total = Wert×Tage, -->
<!-- Freibetrag = min(total, Cap×Tage)). -->
<!-- - Pool-basiert (BV/Rabatt/Geschenk): amount = Gesamtvalue -->
<!-- des Monats; Freibetrag = min(total, Jahrespool YTD). -->
<!-- Die Freibetrags-/Jahreskumulationslogik läuft im Kern über -->
<!-- l10n_at_sv49_frei (SV) und l10n_at_st_frei (LSt/LNK). -->
<!-- Nur der übersteigende Teil ist beitrags-/steuerpflichtig -->
<!-- (Freibeträge, keine Freigrenzen). -->
<!-- ============================================================ -->
<record id="rule_sb_mahlzeit" model="hr.salary.rule">
<field name="name">Sachbezug Mahlzeiten-Gutschein (manuell)</field>
<field name="code">SB_MAHLZEIT</field>
<field name="sequence" eval="41"/>
<field name="category_id" ref="l10n_at_hr_payroll_private.category_atp_brutto"/>
<field name="struct_id" ref="l10n_at_hr_payroll_private.structure_at_privat"/>
<field name="condition_select">python</field>
<field name="condition_python">result = 'ATP_SB_MAHLZEIT' in inputs and inputs['ATP_SB_MAHLZEIT'].amount</field>
<field name="amount_select">code</field>
<field name="l10n_at_bemessung">laufend</field>
<field name="l10n_at_sv49_frei">mahlzeit</field>
<field name="l10n_at_st_frei">mahlzeit</field>
<field name="amount_python_compute"><![CDATA[
# § 3 Abs 1 Z 17 EStG / § 49 Abs 3 Z 12 ASVG: Gutschein bis 8 €/
# Arbeitstag frei (Kern-Cap at_st_grenzen.mahlzeit_tag). Input =
# Anz. Arbeitstage; Tageswert = tatsächlicher Bonwert (at_sb_werte).
# total = Wert × Tage (Engine: amount×qty); Überhang (Wert8)×Tage
# pflichtig (lb-sac-17).
tage = inputs['ATP_SB_MAHLZEIT'].amount
wert = float(payslip._l10n_at_param('at_sb_werte')['mahlzeit_wert'])
result = wert
result_qty = tage
]]></field>
</record>
<record id="rule_sb_betriebsveranstaltung" model="hr.salary.rule">
<field name="name">Sachbezug Betriebsveranstaltung (manuell)</field>
<field name="code">SB_BETRIEBSVERANSTALTUNG</field>
<field name="sequence" eval="42"/>
<field name="category_id" ref="l10n_at_hr_payroll_private.category_atp_brutto"/>
<field name="struct_id" ref="l10n_at_hr_payroll_private.structure_at_privat"/>
<field name="condition_select">python</field>
<field name="condition_python">result = 'ATP_SB_BETRIEBSVERANSTALTUNG' in inputs and inputs['ATP_SB_BETRIEBSVERANSTALTUNG'].amount</field>
<field name="amount_select">code</field>
<field name="l10n_at_bemessung">laufend</field>
<field name="l10n_at_sv49_frei">betriebsveranstaltung</field>
<field name="l10n_at_st_frei">betriebsveranstaltung</field>
<field name="amount_python_compute"><![CDATA[
# § 3 Abs 1 Z 14 EStG / § 49 Abs 3 Z 17 ASVG: Teilnahme bis 365 €/
# AN/Kalenderjahr frei (Jahres-Kumulative je DN); Überhang
# lohnsteuer-/beitragspflichtig (Freibetrag, lb-sac-07).
result = inputs['ATP_SB_BETRIEBSVERANSTALTUNG'].amount
]]></field>
</record>
<record id="rule_sb_sachzuwendung" model="hr.salary.rule">
<field name="name">Sachbezug Sachzuwendung/Jubiläum/Geschenk (manuell)</field>
<field name="code">SB_SACHZUWENDUNG</field>
<field name="sequence" eval="43"/>
<field name="category_id" ref="l10n_at_hr_payroll_private.category_atp_brutto"/>
<field name="struct_id" ref="l10n_at_hr_payroll_private.structure_at_privat"/>
<field name="condition_select">python</field>
<field name="condition_python">result = 'ATP_SB_SACHZUWENDUNG' in inputs and inputs['ATP_SB_SACHZUWENDUNG'].amount</field>
<field name="amount_select">code</field>
<field name="l10n_at_bemessung">laufend</field>
<field name="l10n_at_sv49_frei">jubilaeum</field>
<field name="l10n_at_st_frei">jubilaeum</field>
<field name="amount_python_compute"><![CDATA[
# § 3 Abs 1 Z 14 EStG / § 49 Abs 3 Z 17 ASVG: Sachzuwendungen
# (Jubiläums-/Weihnachtsgeschenke) bis 186 €/AN/Kalenderjahr frei —
# eigener Pool neben der Betriebsveranstaltung (lb-ges-02, lb-sac-07);
# Barzuwendungen immer pflichtig (Regel nur für Sachwerte).
result = inputs['ATP_SB_SACHZUWENDUNG'].amount
]]></field>
</record>
<record id="rule_sb_rabatt" model="hr.salary.rule">
<field name="name">Sachbezug Mitarbeiterrabatt (manuell)</field>
<field name="code">SB_RABATT</field>
<field name="sequence" eval="44"/>
<field name="category_id" ref="l10n_at_hr_payroll_private.category_atp_brutto"/>
<field name="struct_id" ref="l10n_at_hr_payroll_private.structure_at_privat"/>
<field name="condition_select">python</field>
<field name="condition_python">result = 'ATP_SB_RABATT' in inputs and inputs['ATP_SB_RABATT'].amount</field>
<field name="amount_select">code</field>
<field name="l10n_at_bemessung">laufend</field>
<field name="l10n_at_sv49_frei">rabatt</field>
<field name="l10n_at_st_frei">rabatt</field>
<field name="amount_python_compute"><![CDATA[
# § 3 Abs 1 Z 21 EStG / § 49 Abs 3 Z 29 ASVG: Rabatte über der
# 20 %-Freigrenze bis 1.000 €/AN/Jahr frei (Jahres-Kumulative);
# darüber pflichtig (lb-sac-02). Aufzeichnungspflicht des
# Arbeitgebers bleibt außen (Lohnkonto-Prüffeld).
result = inputs['ATP_SB_RABATT'].amount
]]></field>
</record>
<record id="rule_sb_homeoffice" model="hr.salary.rule">
<field name="name">Sachbezug Homeoffice-Pauschale (manuell)</field>
<field name="code">SB_HOMEOFFICE</field>
<field name="sequence" eval="48"/>
<field name="category_id" ref="l10n_at_hr_payroll_private.category_atp_brutto"/>
<field name="struct_id" ref="l10n_at_hr_payroll_private.structure_at_privat"/>
<field name="condition_select">python</field>
<field name="condition_python">result = 'ATP_SB_HOMEOFFICE' in inputs and inputs['ATP_SB_HOMEOFFICE'].amount</field>
<field name="amount_select">code</field>
<field name="l10n_at_bemessung">laufend</field>
<field name="l10n_at_sv49_frei">homeoffice</field>
<field name="l10n_at_st_frei">homeoffice</field>
<field name="amount_python_compute"><![CDATA[
# § 26 Z 9 EStG / § 49 Abs 3 Z 31 ASVG: Homeoffice-Pauschale 3 €/
# Arbeitstag, max 100 Tage/Kalenderjahr (Jahres-Kumulative). Input =
# Anz. Tage; Tageswert = 3 € (at_sb_werte.homeoffice_wert).
tage = inputs['ATP_SB_HOMEOFFICE'].amount
wert = float(payslip._l10n_at_param('at_sb_werte')['homeoffice_wert'])
result = wert
result_qty = tage
]]></field>
</record>
</odoo>
@@ -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, 3650) = **300 €** → voll frei.
- Februar: YTD 300 → frei min(100, 365300) = **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
186100 = **86 €**, Überhang **14 €** (test_04).
- **Rabatt**: Jänner 700 € (frei, über 20 %), Februar 500 € → frei
1.000700 = **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 10070 = 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 3545 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).
@@ -17,3 +17,4 @@ from . import mutterschutz
from . import aeb
from . import pfaendung
from . import pfaendung_report
from . import sachbezuege_private
@@ -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:
@@ -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')
@@ -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
@@ -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)
@@ -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)
@@ -0,0 +1,40 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="payroll_hr_employee_view_form_sachbezuege" model="ir.ui.view">
<field name="name">hr.employee.form.sachbezuege</field>
<field name="model">hr.employee</field>
<field name="inherit_id" ref="hr_payroll.payroll_hr_employee_view_form"/>
<field name="arch" type="xml">
<group name="payroll_group" position="inside">
<group name="sachbezug_stammdaten" string="Sachbezüge (Stammdaten)"
groups="hr_payroll.group_hr_payroll_user">
<group string="Dienstwagen (§ 4 SBWVO)">
<field name="l10n_at_sb_wagen_aktiv"/>
<field name="l10n_at_sb_wagen_ak" invisible="not l10n_at_sb_wagen_aktiv"/>
<field name="l10n_at_sb_wagen_erstzulassung" invisible="not l10n_at_sb_wagen_aktiv"/>
<field name="l10n_at_sb_wagen_co2" invisible="not l10n_at_sb_wagen_aktiv"/>
<field name="l10n_at_sb_wagen_halb" invisible="not l10n_at_sb_wagen_aktiv"/>
<field name="l10n_at_sb_wagen_kostenbeitrag" invisible="not l10n_at_sb_wagen_aktiv"/>
</group>
<group string="Dienstwohnung (§ 2 SBWVO)">
<field name="l10n_at_sb_wohnung_aktiv"/>
<field name="l10n_at_sb_wohnung_flaeche" invisible="not l10n_at_sb_wohnung_aktiv"/>
<field name="l10n_at_sb_wohnung_bundesland" invisible="not l10n_at_sb_wohnung_aktiv"/>
<field name="l10n_at_sb_wohnung_norm" invisible="not l10n_at_sb_wohnung_aktiv"/>
<field name="l10n_at_sb_wohnung_besorger" invisible="not l10n_at_sb_wohnung_aktiv"/>
<field name="l10n_at_sb_wohnung_arbeitsplatznah" invisible="not l10n_at_sb_wohnung_aktiv"/>
<field name="l10n_at_sb_wohnung_kein_mittelpunkt" invisible="not l10n_at_sb_wohnung_aktiv"/>
<field name="l10n_at_sb_wohnung_ueberlassung_ab" invisible="not l10n_at_sb_wohnung_aktiv"/>
<field name="l10n_at_sb_wohnung_kostenbeitrag" invisible="not l10n_at_sb_wohnung_aktiv"/>
</group>
<group string="Parkplatz / Arbeitsmittel">
<field name="l10n_at_sb_parkplatz_aktiv"/>
<field name="l10n_at_sb_parkplatz_ausnahme" invisible="not l10n_at_sb_parkplatz_aktiv"/>
<field name="l10n_at_sb_parkplatz_kostenbeitrag" invisible="not l10n_at_sb_parkplatz_aktiv"/>
<field name="l10n_at_sb_handy_profil"/>
</group>
</group>
</group>
</field>
</record>
</odoo>