Files
odoo-at-payroll/addons/l10n_at_hr_payroll_private/models/abfertigung_private.py
T
fegger 0ea7f54682 [IMP] l10n_at_hr_payroll_private: GP5 Abfertigung Neu (BVG-Regel, eigene BV-Grundlage, Anwartschaft/Report/Wizard)
Laufender Beitrag (§ 6 BMSVG): Regel BVG (Kategorie ATP_DG, ohne Nettoeffekt) auf EIGENER, ungecappter BV-Grundlage — §-49-bereinigt, ohne HBG-/Jahresdeckel (§ 6 Abs 5 iVm § 49 ASVG) und ohne §-67-Abs-3/6-Zeilen (§ 49 Abs 3 Z 7 ASVG); bewusst kein Rückgriff auf die SV-SZ-Basis des Kerns (Nutzer-Entscheidung eigene BV-Grundlage; deren Z-7-Ausschluss fehlt — GP4-Beobachtung bleibt offen dokumentiert).

Beitragsfreier erster Monat taggenau: Fenster [Eintritt, Eintritt + 1 Monat), Beitragspflicht ab Monatsjubiläum (15. 06. → 15. 07.; KB-Beispiel lb-vor-03: 4.500/30 × 17 = 2.550 — die ab-Folgetag-Lesart war ein Off-by-one und wurde verworfen); 30-Tage-Aliquotierung über die Lohnsteuertage (Teilmonate ohne Doppelabzug); Monatsende-Klippe (Eintritt 31. 01.): Fenster bis Monatsultimo, Pflicht ab Folgemonatserstem (ABGB-Fristrechnung); AV ≤ 1 Monat zur Gänze beitragsfrei.

Wiederzulassung binnen 12 Monaten → Beitragspflicht ab dem 1. Tag; Vor-AV beliebiger Dauer (auch < 1 Monat, Nutzer-Klarstellung) und schlägt auch die Kurz-AV-Freiheit des laufenden AV (OGH 9 ObA 30/16a; Suche über frühere hr.version mit contract_date_end im 12-Monats-Fenster). Sonderzahlungen werden nicht um das Fenster gekürzt, bleiben frei nur bei Beitragsmonat (voller Datumstag, taggenau) im Fenster. Geringfügig Beschäftigte und Lehrlinge sind erfasst — BVG läuft auch im §-53a-Fall.

Anwartschaft (§ 15: gutgebrachte Beiträge + Erträge, keine Monatsentgelt-Staffel) on-the-fly aus validierten Läufen; Anwartschaftsreport (QWeb auf hr.employee) und Endabrechnungs-Wizard (§§ 14-17: §-14-Abs-2-Verfügungsmatrix, < 36 Beitragsmonate sperrt immer, §-16-Fälligkeit am Ende des zweitfolgenden Monats, 6-Monats-Erklärungsfrist, 5 Verfügungsoptionen) — die Auszahlung erfolgt durch die MV-Kasse, nicht über den Payslip.

Stammdaten: Vertrags-Flag l10n_at_abfertigung_neu (§ 46) + Modell l10n.at.bv.kasse (Leitzahl für eSV/mBGM; Seed der 7 Kassen-Namen noupdate, lb-gsv-11 — Leitzahlen aus Mandats-Unterlagen) + Mitarbeiter-Felder l10n_at_bv_ertraege/l10n_at_bv_monate_extern (Kassen-Auszug).

Bewusst nicht: §-7-BMSVG-Fiktivfälle (dokumentierte Option), Geringfügigen-Jahreszahlung + 2,5 % (GP7), BV-Provider (Option).

Tests: test_bv_private (10 Fälle, RF-BV1-10); gesamt 84/84 grün (frischer -i-Lauf auf Wegwerf-DB). Rechenfälle docs/rechenfaelle/abfertigung.md. 19.0.5.0.0.
2026-09-12 08:22:13 +02:00

257 lines
12 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Part of the odoo-at-payroll project. License: LGPL-3.
"""Abfertigung Neu (BMSVG) — GP5: laufender BV-Beitrag (§ 6) mit
EIGENER BV-Grundlage, Anwartschafts-Fortschreibung (§ 15) und
Endabrechnung (§§ 14-17).
Verifizierte Grundlagen (RECHTSQUELLEN-Privat Abschn. 8 ✅; KB
lb-vor-03/lb-vor-06/lb-end-03, Stand 2026-08):
- § 6 Abs 1: DG-Beitrag 1,53 vH des monatlichen Entgelts und der
Sonderzahlungen an die Mitarbeitervorsorgekasse (Abfuhr über den
KV-Träger, Fälligkeit 15. Folgemonat, Baubereich BUAK — GP7).
- § 6 Abs 5 iVm § 49 ASVG: Bemessung ohne GFG und HBG → eigene,
ungecappte BV-Grundlage. Bewusst KEIN Rückgriff auf die SV-SZ-Basis
des Kerns: deren §-49-Abs-3-Z-7-Ausschluss für §-67-Abs-3/6-Bezüge
(Abfertigung/Abfindung) ist dort nicht umgesetzt; die BV-Grundlage
schließt diese Zeilen korrekt aus (Nutzer-Entscheidung 2026-09-11).
- Geltungsbereich § 46/§ 1 Abs 2 Z 1: private Arbeitsverhältnisse ab
1. 1. 2003 (Flag am Vertrag); geringfügig Beschäftigte und Lehrlinge
sind ERFASST — BVG läuft auch ohne SV-Hauptbeiträge.
- Beitragsfreier erster Monat (§ 6 Abs 1), taggenau: Fenster =
[Eintritt, Eintritt + 1 Monat), Beitragspflicht ab Monatsjubiläum
(Eintritt 15. 06. → frei bis 14. 07., Pflicht ab 15. 07.; KB-Beispiel
4.500/30 × 17 = 2.550). Monatsende-Klippe (Eintritt 31. 01. → kein
31. 02.): Fenster bis Monatsultimo, Pflicht ab Folgemonatserstem
(ABGB-Fristrechnung). AV ≤ 1 Monat → zur Gänze beitragsfrei (auch
Urlaubsersatzleistung). Wiederzulassung binnen 12 Monaten →
Beitragspflicht ab dem 1. TAG; Vor-AV beliebiger Dauer (auch
< 1 Monat; OGH 9 ObA 30/16a) — schlägt auch die Kurz-AV-Freiheit
des laufenden AV (Nutzer-Entscheidung 2026-09-11).
- Sonderzahlungen: keine aliquote Kürzung um den beitragsfreien
Monat; beitragsfrei nur, wenn ihr Beitragsmonat ins Fenster fällt.
- §§ 14-17: Anwartschaft = gutgebrachte Beiträge + Erträge (KEINE
Monatsentgelt-Staffel); Verfügungsausschluss bei AN-Kündigung,
verschuldeter Entlassung, unberechtigtem vorzeitigem Austritt oder
weniger als 36 Beitragsmonaten (alle Beitragszeiten zählen, daher
manuelle Vor-Arbeitgeber-Monate am Mitarbeiter); Fälligkeit am Ende
des zweitfolgenden Monats nach Geltendmachung (§ 16); fünf
Verfügungsoptionen, ohne Erklärung binnen 6 Monaten
Weiterveranlagung (§ 17).
"""
from datetime import timedelta
from dateutil.relativedelta import relativedelta
from odoo import fields, models
class HrVersion(models.Model):
_inherit = 'hr.version'
l10n_at_abfertigung_neu = fields.Boolean(
string='AT Abfertigung Neu (BMSVG)',
default=True,
groups="hr_payroll.group_hr_payroll_user", tracking=True,
help='Dienstverhältnis unterliegt dem BMSVG (§ 46: AV ab '
'1. 1. 2003; § 1 Abs 2 Z 1: Länder/Gemeinden ausgenommen). '
'Für Alt-Verhältnisse (§§ 23 f AngG) und § 47-Übertritte '
'abwählen — dann läuft keine BVG-Regel. Geringfügig '
'Beschäftigte und Lehrlinge sind erfasst.')
l10n_at_bv_kasse_id = fields.Many2one(
'l10n.at.bv.kasse', string='AT Mitarbeitervorsorgekasse',
index=True, groups="hr_payroll.group_hr_payroll_user",
tracking=True,
help='Gewählte BV-Kasse dieses Dienstverhältnisses (lb-vor-02); '
'Anwartschaftskonto und §-17-Verfügungen laufen über diese '
'Kasse.')
class HrEmployee(models.Model):
_inherit = 'hr.employee'
l10n_at_abfertigung_neu = fields.Boolean(
related="version_id.l10n_at_abfertigung_neu", inherited=True,
readonly=False, groups="hr_payroll.group_hr_payroll_user")
l10n_at_bv_kasse_id = fields.Many2one(
related="version_id.l10n_at_bv_kasse_id", inherited=True,
readonly=False, groups="hr_payroll.group_hr_payroll_user")
l10n_at_bv_ertraege = fields.Monetary(
string='AT BV-Erträge (Kassen-Auszug)',
currency_field='currency_id',
groups="hr_payroll.group_hr_payroll_user",
help='Veranlagungserträge der Mitarbeitervorsorgekasse laut '
'Kassen-Auszug (§ 15 BMSVG: Auszahlung der Anwartschaft = '
'gutgebrachte Beiträge + Erträge) — manuell pflegen.')
l10n_at_bv_monate_extern = fields.Integer(
string='AT BV-Beitragsmonate Vor-Arbeitgeber',
groups="hr_payroll.group_hr_payroll_user",
help='Beitragsmonate aus Vor-Beschäftigungen (§ 14 Abs 2 BMSVG: '
'alle Beitragszeiten über Arbeitgeber hinweg summierbar — '
'36-Monats-Kriterium) — laut Auszug der MV-Kasse pflegen.')
def _at_bv_anwartschaft(self):
"""Anwartschafts-Fortschreibung (§ 15 BMSVG): gutgebrachte
Beiträge aus validierten Läufen, je Beitragsmonat/Jahr
gruppiert (Refund-Läufe netten über negative Linien; Monate
zählen auch aus negativen Linien — der Monat ist belegt)."""
self.ensure_one()
monate = set()
total = 0.0
jahre = {}
slips = self.env['hr.payslip'].search([
('employee_id', '=', self.id),
('state', 'not in', ('draft', 'cancel')),
])
for line in slips.line_ids.filtered(
lambda l: l.code == 'BVG'):
if not line.total:
continue
monat = (line.l10n_at_beitragsmonat
or line.date_from).replace(day=1)
monate.add(monat)
total += line.total
eintrag = jahre.setdefault(
monat.year, {'monate': set(), 'summe': 0.0})
eintrag['monate'].add(monat)
eintrag['summe'] += line.total
return {
'monate': len(monate),
'beitraege': total,
'jahre': [(jahr, len(w['monate']), w['summe'])
for jahr, w in sorted(jahre.items())],
}
class HrPayslip(models.Model):
_inherit = 'hr.payslip'
# ------------------------------------------------------------------
# Beitragsfreies Erstfenster (§ 6 Abs 1 BMSVG, taggenau)
# ------------------------------------------------------------------
def _at_bv_fenster(self):
"""(pflichtig_ab, kurz_av) des Arbeitsverhältnisses:
pflichtig_ab = erstes beitragspflichtiges Datum (None =
Beitragspflicht ab dem 1. Tag bzw. ohne bewertbare
Vertragsdaten); kurz_av = True → AV ≤ 1 Monat, zur Gänze
beitragsfrei. Reihenfolge: Wiederzulassung schlägt alles,
dann Kurz-AV-Freiheit, dann taggenaues Fenster."""
self.ensure_one()
version = self.version_id
start = version.contract_date_start
if not start:
# Ohne Vertragsdaten ist die Befreiung nicht bewertbar →
# konservativ Vollpflicht.
return None, False
# Wiederzulassung binnen 12 Monaten: Beitragspflicht ab dem
# 1. Tag; Vor-Dienstverhältnis beliebiger Dauer (auch
# < 1 Monat) löst die Ausnahme aus.
prior = self.env['hr.version'].search([
('employee_id', '=', self.employee_id.id),
('id', '!=', version.id),
('contract_date_end', '!=', False),
('contract_date_end', '>=',
start - relativedelta(months=12)),
('contract_date_end', '<', start),
], limit=1)
if prior:
return None, False
# Monatsjubiläum, taggenau; Monatsende-Klippe: existiert der
# Jubiläumstag im Folgemonat nicht (Eintritt 31. 01.), endet
# das Fenster am Monatsultimo, Pflicht ab Folgemonatserstem.
jubilaeum = start + relativedelta(months=1)
if jubilaeum.day < start.day:
jubilaeum += timedelta(days=1)
end = version.contract_date_end
if end and end < jubilaeum:
# AV ≤ 1 Monat → zur Gänze beitragsfrei (lb-vor-03, auch
# eine bloße Urlaubsersatzleistung).
return None, True
return jubilaeum, False
# ------------------------------------------------------------------
# Eigene BV-Grundlage (§ 6 Abs 5 iVm § 49 ASVG — ohne GFG/HBG)
# ------------------------------------------------------------------
def _at_bv_sonst_monate(self, result_rules=None):
"""[(Beitragsmonat, sonstige BV-Basis)]: sonstige Bezüge ohne
§-67-Abs-3/6-Zeilen (Abfertigung/Abfindung sind nach
§ 49 Abs 3 Z 7 ASVG beitragsfrei, damit auch nicht BV-
pflichtig), gruppiert nach Regel-Beitragsmonat mit voller
Tagesgenauigkeit (Anspruchsprinzip, Muster
_l10n_at_sv_basis_laufen_monate) — die Fenster-Prüfung braucht
die Fälligkeit taggenau (SZ fällig 20. 07. ist bei Fenster-
Ende 15. 07. pflichtig, der Monatsanfang wäre es nicht)."""
self.ensure_one()
gruppen = {}
for rule in self.struct_id.rule_ids:
if (rule.l10n_at_bemessung != 'sonstig'
or rule.l10n_at_s67_abs != 'keine'):
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'))}
if not werte:
continue
monat = rule.l10n_at_beitragsmonat or self.date_from
gruppen[monat] = gruppen.get(monat, 0.0) + werte['total']
return sorted(gruppen.items())
def _at_bv_basis_laufen(self, result_rules=None):
"""Laufende BV-Grundlage: §-49-bereinigtes Entgelt ohne
HBG-Cap (§ 6 Abs 5), aliquotiert um das beitragsfreie
Erstfenster — 30-Tage-Monat (KB-Beispiel 4.500/30 × 17 =
2.550); Teilmonate über die Lohnsteuertage, um Doppelabzüge
zu vermeiden (das Entgelt ist für die Teilzeit bereits
aliquotiert)."""
self.ensure_one()
pflichtig_ab, kurz_av = self._at_bv_fenster()
if kurz_av:
return 0.0
basis = self._l10n_at_sv_basis_roh(result_rules)
if pflichtig_ab is None or pflichtig_ab <= self.date_from:
return basis
if pflichtig_ab > self.date_to:
# LZP liegt zur Gänze im beitragsfreien Erstfenster.
return 0.0
ende = min(self.date_to,
self.version_id.contract_date_end or self.date_to)
tage = (ende - pflichtig_ab).days + 1
if tage <= 0:
return 0.0
return basis * tage / max(self._l10n_at_lohnsteuertage(), 1)
def _at_bv_basis_sonst(self, result_rules=None):
"""BV-Grundlage sonstiger Bezüge (Sonderzahlungen & Co.):
ungecappt (kein HBG-/Jahresdeckel, § 6 Abs 5) und ohne
§-67-Abs-3/6-Zeilen; keine aliquote Kürzung um das freie
Fenster — frei bleibt nur, was im Fenster fällig wird
(Beitragsmonat im Fenster, lb-vor-03)."""
self.ensure_one()
pflichtig_ab, kurz_av = self._at_bv_fenster()
if kurz_av:
return 0.0
total = 0.0
for monat, basis in self._at_bv_sonst_monate(result_rules):
if pflichtig_ab is not None and monat < pflichtig_ab:
continue
total += basis
return total
def _at_bv_beitrag(self, result_rules=None):
"""BV-Beitrag (§ 6 Abs 1 BMSVG): 1,53 % DG (at_sv_bv_dg) auf
die eigene Grundlage laufend + sonstig; ATP_DG-Zeile ohne
Nettoeffekt (FI-Zuordnung über hr_payroll_account, GP6).
Läuft bewusst auch für geringfügig Beschäftigte und
Lehrlinge (§ 46, § 1 Abs 2 Z 1; Jahreszahlung + 2,5 %
Zinsenzuschlag der Geringfügigen = Zahlungsmodus GP7/GP8)."""
self.ensure_one()
if not self.version_id.l10n_at_abfertigung_neu:
return 0.0
basis = (self._at_bv_basis_laufen(result_rules)
+ self._at_bv_basis_sonst(result_rules))
satz = float(self._l10n_at_param('at_sv_bv_dg'))
return basis * satz / 100.0