[IMP] l10n_at_hr_payroll_private: Sachbezüge (AP12)

This commit is contained in:
2026-09-14 12:22:15 +02:00
parent 018554b215
commit 8efea77b6c
20 changed files with 1606 additions and 23 deletions
@@ -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)