Files
odoo-at-payroll/addons/l10n_at_hr_payroll_private/models/pfaendung.py
T
fegger 9add58fe83 [IMP] l10n_at_hr_payroll_private: add statutory old-system severance (AP15-A)
Pay the pre-2003 AngG severance from the end-of-employment run as its own neutral benefit line: free of SV (§ 49 Abs 3 Z 7 ASVG) and payroll levies, outside the annual sixth, and collected into the § 291d EO termination pot net of its own wage tax. Taxation follows the binding better-of rule of § 67 Abs 3 EStG: multiple-of-method (tariff wage tax on the running monthly wage × the statutory multiple of the payout) versus the flat 6 % rate, applied mandatorily in favour of the employee. The tariff wage tax is captured as a separately confirmed value instead of deriving it from gross pay through the § 62 engine. The wizard enforces § 23a Abs 3 AngG (parental exit needs five uninterrupted years), blocks BMSVG-new contracts except frozen claims and validates AGRD 02/06 against the claim type. The LNK basis subtracts § 67 Abs 3/6 rules only when they actually run in the tax base, the L16 generator reports LST_ABF in key 260 outside 210/220, and result_rules access in the garnishment paths avoids DefaultDictPayroll materialisation. Verified against the original statute texts in .ris/ (AngG §§ 23/23a, EStG § 67 Abs 3).

Version 19.0.13.0.0 (private), 19.0.9.0.0 (core)
2026-09-15 11:01:55 +02:00

450 lines
20 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.
"""AP11: Lohnpfändung/Exekution — Pfändungsvormerkung (Drittschuldner),
Existenzminimum-Maschine (§ 291a EO) je Pfändungstopf und Kostenersatz
(§ 292h/§ 302 EO).
Verifizierte Grundlagen (RIS-Gate, 2026-09-13):
- **§ 291a EO** (Unpfändbarer Freibetrag/Existenzminimum) — exakter
Wortlaut `.additional_docs/Lexis360_291a_eo.pdf` (NOR40233792, idF
1.7.2021): (1) allgemeiner Grundbetrag = AZR; (2) +1/6 bei keinen
§-290b-Leistungen (erhöhter allgemeiner Grundbetrag) und +20 % je
Unterhaltsperson (max 5, Unterhaltsgrundbetrag); (3) 30 % des
Mehrbetrags + 10 % je Unterhaltsperson (max 5); der Teil über dem
Vierfachen des AZR (Höchstberechnungsgrundlage) ist jedenfalls
pfändbar; (5) Grundbeträge auf volle Euro abrunden, Höchstteil nach
§ 291 Abs 2 (20-€-Schritte).
- **§ 292 EO** (Zusammenrechnung/Sachleistungen) —
`.additional_docs/Lexis360_292_eo.pdf` (NOR40187912): Abs 4 absolutes
Geldexistenzminimum = halber allgemeiner Grundbetrag.
- **AZR** (ASVG § 293 Abs 1 lit a sublit bb) am RIS belegt
(KB-KONFLIKTE A9, 3. RIS-Runde 2026-09-11): 2025 1.273,99 € (BGBl II
417/2024), 2026 1.308,39 € (BGBl II 263/2025).
- **KB** lb-pfa-01…14 (Stand 2026-07), wk-pfa-01: Einlangen/Rang,
Durchführung/§ 304-Zahlungssperre, Kostenersatz, Beendigungsansprüche
(§ 291d EO), Vorschüsse (§ 290c EO), Sachbezüge.
Formel (§ 291a, gegen KB-Rechenbeispiel lb-pfa-03 verifiziert:
Abfertigung 6×6.000 €, 6 % LSt → BG 33.840 €, 2 Unterhaltspflichten,
HöBG 6×5.220 € → EX 16.684 € / pfändbar 17.156 € — exakt):
azr = Parameter at_eo_azr (versioniert)
allg = floor(azr) [allg. Grundbetrag]
erh = floor(azr * 7/6) [erhöhter allg.]
unterhalt = floor(azr * 0.20) [je Person, max 5]
grund = (erh|allg) + n * unterhalt
hoechst_j = floor_to_20(4 * azr) [HöBG/Jahr]
hoechst = hoechst_j * bezugsmonate
mehr = max(0, min(basis, hoechst) - grund)
ex = min(basis, grund + (0.30 + 0.10*n) * mehr)
pfaendbar = basis - ex
"""
import math
from datetime import timedelta
from odoo import api, fields, models
from odoo.exceptions import UserError
def _floor_to(x, step):
"""Abbruch auf das Vielfache von ``step`` (20-€-Schritte § 291 Abs 2)."""
return math.floor(round(x, 6) / step) * step
class L10nAtPayrollPfaendung(models.Model):
_name = 'l10n.at.payroll.pfaendung'
_description = 'Lohnpfändung / Exekution (Drittschuldner)'
_order = 'zustellungsdatum, id'
_rec_name = 'glaubiger'
active = fields.Boolean(default=True)
employee_id = fields.Many2one(
'hr.employee', string='Dienstnehmer:in', required=True, index=True)
company_id = fields.Many2one(
'res.company', string='Company',
related='employee_id.company_id', readonly=True, required=True)
currency_id = fields.Many2one(
'res.currency', related='company_id.currency_id', readonly=True)
glaubiger = fields.Char(
string='Gläubiger', required=True,
help='Betreibende Partei (Name des Gläubigers bzw. der '
'Vollstreckungsbehörde).')
vollstreckung_traeger = fields.Selection(
[('gericht', 'Gericht (Exekutionsbewilligung)'),
('finanz', 'Finanzamt (Abgabenexekution)'),
('sv', 'Sozialversicherungsträger'),
('verwaltung', 'Verwaltungsbehörde'),
('privat', 'Privatrechtlich (Verpfändung/Zession)')],
string='Vollstreckungsträger', default='gericht', required=True,
help='Steuert Pflichtenmatrix (Drittschuldnererklärung, '
'Wartefrist) — lb-pfa-04/06/07.')
pfandung_art = fields.Selection(
[('gewoehnlich', 'Gewöhnliche Exekution'),
('unterhalt', 'Unterhaltsexekution'),
('verpfandung', 'Vertragliche Verpfändung'),
('zession', 'Gehaltsabtretung (Zession)')],
string='Pfändungsart', default='gewoehnlich', required=True,
help='Unterhalts- vs. gewöhnliche Pfändung steuert Tabellenwahl '
'und Unterhaltsmasse (lb-pfa-04/05).')
zustellungsdatum = fields.Date(
string='Zustellungs-/Einlangedatum', required=True, index=True,
help='Einlangen der Pfändung beim Drittschuldner; bestimmt Rang '
'und Lauf der Fristen (lb-pfa-01/07).')
forderungshoehe = fields.Monetary(
string='Forderungshöhe', currency_field='currency_id',
help='Höhe der gepfändeten Forderung (Ranganmerkung).')
status = fields.Selection(
[('aktiv', 'Aktiv'),
('gesetzt', 'Gesetzt (ausgesetzt)'),
('geloest', 'Gelöst / vollstreckt')],
string='Status', default='aktiv', required=True,
help='Aktiv: Einbehalt läuft; Gelöst: keine weiteren Einbehalte '
'(lb-pfa-08 Pfändungsende).')
loesungsdatum = fields.Date(
string='Löse-/Enddatum',
help='Datum der Aufhebung/Vollstreckung (lb-pfa-08).')
verwalter = fields.Boolean(
string='Verwalter bestellt (§ 301 Abs 1 Z 6 EO)',
help='Bestellter Verwalter: Erklärung/Überweisung an ihn; '
'Berechnung des Freibetrags anregbar (lb-pfa-01/04).')
verwalter_name = fields.Char(
string='Verwalter (Name)',
help='Name des bestellten Verwalters.')
zahlungssperre = fields.Boolean(
string='4-Wochen-Zahlungssperre (§ 304 EO)',
help='Exekutionsbewilligung ordnet Wartefrist an: Einbehalt '
'gesperrt, Auszahlung an Gläubiger frühestens 4 Wochen ab '
'Zustellung (lb-pfa-07).')
kostenersatz_variante = fields.Selection(
[('einbehalt', 'Kosteneinbehalt vom Bezug'),
('verzicht', 'Verzicht (kein Kostenersatz)'),
('ueberweisung', 'Überweisung durch den Gläubiger')],
string='Kostenersatz-Variante', default='einbehalt',
help='Geltendmachung des pauschalen Kostenersatzes (§ 292h/§ 302 '
'EO) — lb-pfa-09.')
dse_frist = fields.Date(
string='Drittschuldnererklärung-Frist', compute='_compute_fristen',
store=True, readonly=False,
help='IdR 4 Wochen ab Zustellung (gerichtliche Exekution, § 301 '
'EO) — lb-pfa-06.')
auszahlung_frist = fields.Date(
string='Spätester Auszahlungstermin', compute='_compute_fristen',
store=True, readonly=False,
help='Nächster Bezugstermin, spätestens 8 Wochen nach Einlangen '
'(lb-pfa-07).')
rang = fields.Integer(
string='Rang', compute='_compute_rang', store=True, readonly=True,
help='Rangfolge nach Einlangedatum (lb-pfa-01/10).')
note = fields.Text(string='Bemerkung')
@api.constrains('loesungsdatum', 'zustellungsdatum')
def _check_loesungsdatum(self):
for rec in self:
if rec.loesungsdatum and rec.zustellungsdatum \
and rec.loesungsdatum < rec.zustellungsdatum:
raise UserError(_(
"Das Löse-/Enddatum (%(loes)s) darf nicht vor dem "
"Zustellungsdatum (%(zust)s) liegen.",
loes=rec.loesungsdatum,
zust=rec.zustellungsdatum))
@api.depends('zustellungsdatum')
def _compute_fristen(self):
for rec in self:
if rec.zustellungsdatum:
rec.dse_frist = rec.zustellungsdatum + timedelta(weeks=4)
rec.auszahlung_frist = rec.zustellungsdatum + timedelta(
weeks=8)
else:
rec.dse_frist = None
rec.auszahlung_frist = None
@api.depends('employee_id', 'zustellungsdatum', 'active')
def _compute_rang(self):
for rec in self:
if not (rec.employee_id and rec.zustellungsdatum):
rec.rang = 0
continue
voher = self.search_count([
('employee_id', '=', rec.employee_id.id),
('active', '=', True),
('zustellungsdatum', '<', rec.zustellungsdatum),
('id', '!=', rec.id),
])
rec.rang = voher + 1
def name_get(self):
return [
(rec.id, '%s%s (%s)' % (
rec.glaubiger, rec.employee_id.name or '',
rec.zustellungsdatum or ''))
for rec in self]
def action_geloest(self):
"""Pfändung als gelöst/vollstreckt markieren (lb-pfa-08)."""
today = fields.Date.context_today(self)
for rec in self:
rec.write({'status': 'geloest',
'loesungsdatum': rec.loesungsdatum or today})
return True
def action_aktiv(self):
for rec in self:
rec.write({'status': 'aktiv'})
return True
def action_gesetzt(self):
for rec in self:
rec.write({'status': 'gesetzt'})
return True
class HrVersion(models.Model):
_inherit = 'hr.version'
l10n_at_unterhaltspflichten = fields.Integer(
string='AT gesetzliche Unterhaltspflichten', default=0,
groups="hr_payroll.group_hr_payroll_user", tracking=True,
help='Anzahl gesetzlicher Unterhaltspflichten (max 5 wirksam, '
'§ 291a Abs 2 Z 2 EO) — wirkt auf Grund- und '
'Steigerungsbeträge (lb-pfa-11, Vereinfachungsregel '
'§ 292f Abs 2 EO: Angaben des DN genügen).')
l10n_at_sz_anspruch = fields.Boolean(
string='AT Sonderzahlungsanspruch (13./14.)', default=True,
groups="hr_payroll.group_hr_payroll_user", tracking=True,
help='Besteht ein Anspruch auf 13./14. Sonderbezug? Ohne SZ-'
'Anspruch gilt der erhöhte allgemeine Grundbetrag '
'(§ 291a Abs 2 Z 1 EO, Faktor 7/6) — lb-pfa-05.')
class HrEmployee(models.Model):
_inherit = 'hr.employee'
l10n_at_unterhaltspflichten = fields.Integer(
related='version_id.l10n_at_unterhaltspflichten', inherited=True,
readonly=False, groups="hr_payroll.group_hr_payroll_user")
l10n_at_sz_anspruch = fields.Boolean(
related='version_id.l10n_at_sz_anspruch', inherited=True,
readonly=False, default=True,
groups="hr_payroll.group_hr_payroll_user")
def _at_pfaendungen_aktiv(self, date_to):
"""Aktive Pfändungen zum Stichtag (sortiert nach Einlangedatum)."""
self.ensure_one()
return self.env['l10n.at.payroll.pfaendung'].search([
('employee_id', '=', self.id),
('active', '=', True),
('status', '=', 'aktiv'),
('zustellungsdatum', '<=', date_to),
], order='zustellungsdatum, id')
def _at_unterhaltspflichten(self):
"""Wirksame gesetzliche Unterhaltspflichten (max 5, § 291a
Abs 2 Z 2) — Vereinfachungsregel § 292f Abs 2 EO (lb-pfa-11)."""
self.ensure_one()
return min(self.l10n_at_unterhaltspflichten or 0, 5)
def _at_sz_anspruch(self):
"""Sonderzahlungsanspruch (13./14.) — bestimmt allg. vs. erhöhten
Grundbetrag (§ 291a Abs 2 Z 1: erhöht nur bei KEINEN §-290b-
Leistungen)."""
self.ensure_one()
return bool(self.l10n_at_sz_anspruch)
class HrSalaryRule(models.Model):
_inherit = 'hr.salary.rule'
l10n_at_pfandung_unpfandbar = fields.Boolean(
string='AT unpfändbarer Bezug (§ 291 EO)',
help='Bezugsart, die nicht pfändbar ist und aus der '
'Pfändungsberechnungsgrundlage ausfällt (zB '
'Reisekostenentschädigung) — lb-pfa-05. Schnittstelle '
'AP12 Sachbezüge.')
l10n_at_pfandung_beendigung = fields.Boolean(
string='AT Beendigungsanspruch (§ 291d EO)',
help='Einmalige Beendigungsleistung (Abfertigung, '
'Urlaubsersatzleistung, Abgangsentschädigung, '
'Pensionsabfindung) — eigener Pfändungsberechnungskreis '
'(lb-pfa-03). Schnittstelle AP14/15.')
class HrPayslip(models.Model):
_inherit = 'hr.payslip'
# ------------------------------------------------------------------
# Werte (versioniert, Parameter at_eo_azr)
# ------------------------------------------------------------------
def _at_pfaendung_werte(self, datum=None):
"""AZR + abgeleitete Grund-/Höchstbeträge (§ 291a EO) zum
Stichtag — aus dem versionierten Parameter ``at_eo_azr``."""
self.ensure_one()
datum = datum or self.date_to
werte = self._l10n_at_param('at_eo_azr', datum)
azr = float(werte['azr'])
return {
'azr': azr,
'allg': math.floor(azr),
'erh': math.floor(azr * 7.0 / 6.0),
'unterhalt': math.floor(azr * 0.20),
'hoechst_j': _floor_to(4.0 * azr, 20),
'abs_minimum': math.floor(azr / 2.0),
}
# ------------------------------------------------------------------
# Existenzminimum (§ 291a EO)
# ------------------------------------------------------------------
def _at_pfaendung_existenzminimum(self, basis, unterhaltspflichten,
erhoht, bezugsmonate=1):
"""Unpfändbarer Freibetrag (Existenzminimum) nach § 291a EO.
:param basis: Pfändungsberechnungsgrundlage (Netto, > 0)
:param unterhaltspflichten: n, gesetzliche Unterhaltspflichten
:param erhoht: erhöhter allgemeiner Grundbetrag (kein §-290b /
Beendigungsleistungen)
:param bezugsmonate: Vervielfachung der HöBG (Beendigungs-
ansprüche, lb-pfa-03)
"""
self.ensure_one()
w = self._at_pfaendung_werte()
n = min(unterhaltspflichten or 0, 5)
bezugsmonate = max(1, int(bezugsmonate or 1))
grund = (w['erh'] if erhoht else w['allg']) + n * w['unterhalt']
hoechst = w['hoechst_j'] * bezugsmonate
basis = max(0.0, basis)
if basis <= grund:
return basis
mehr = min(basis, hoechst) - grund
ex = grund + (0.30 + 0.10 * n) * mehr
return min(basis, round(ex, 2))
# ------------------------------------------------------------------
# Pfändungsberechnungsgrundlage (§ 291 EO) je Topf
# ------------------------------------------------------------------
def _at_pfa_rule_amount(self, result_rules, code):
if result_rules is not None:
werte = result_rules[code] if code in result_rules else None
# result_rules enthält ungerundete Rule-Summen; die
# Bezugsart ist der gerundete Zeilenbetrag (Cents) —
# mit diesem wird auch die Pfändungsberechnungsgrundlage
# (§ 291 EO) bestimmt.
return round(float(werte['total']), 2) if werte else 0.0
linie = self.line_ids.filtered(lambda l: l.code == code)
return sum(linie.mapped('total')) if linie else 0.0
def _at_pfa_bruto(self, result_rules, unpfandbar=None):
"""Summe laufender Brutto-Bezüge (Bemessung 'laufend'); optional
nur die unpfändbaren (Flag l10n_at_pfandung_unpfandbar)."""
self.ensure_one()
total = 0.0
for rule in self.struct_id.rule_ids:
if (rule.category_id.code != 'ATP_BRUTTO'
or rule.l10n_at_bemessung != 'laufend'):
continue
if (unpfandbar is not None
and bool(rule.l10n_at_pfandung_unpfandbar) != unpfandbar):
continue
if result_rules is not None:
werte = result_rules.get(rule.code)
if werte:
total += round(float(werte['total']), 2)
else:
linie = self.line_ids.filtered(
lambda l, c=rule.code: l.code == c)
total += sum(linie.mapped('total'))
return total
def _at_pfaendung_basis(self, result_rules, topf='laufend'):
"""Pfändungsberechnungsgrundlage (§ 291 EO) je Topf.
- ``laufend``: laufende Bezüge unpfändbar SV-DN(lfd) LSt(lfd)
- ``sz13``/``sz14``: 13./14. Bezug (eigener Berechnungskreis)
- ``beendigung``: einmalige Beendigungsansprüche (§ 291d EO)
"""
self.ensure_one()
if topf == 'laufend':
bruto = self._at_pfa_bruto(result_rules)
unpf = self._at_pfa_bruto(result_rules, unpfandbar=True)
svdn = sum(self._at_pfa_rule_amount(
result_rules, c) for c in (
'SVDN_KV', 'SVDN_PV', 'SVDN_AV', 'SVDN_AK', 'SVDN_WF'))
lst = self._at_pfa_rule_amount(result_rules, 'LST_LFD')
return round(max(0.0, bruto - unpf - svdn - lst), 2)
if topf in ('sz13', 'sz14'):
code = {'sz13': 'B13', 'sz14': 'B14'}[topf]
return round(max(0.0, self._at_pfa_rule_amount(result_rules, code)), 2)
if topf == 'beendigung':
total = 0.0
for rule in self.struct_id.rule_ids:
if not rule.l10n_at_pfandung_beendigung:
continue
if result_rules is not None:
werte = result_rules[rule.code] \
if rule.code in result_rules else None
if werte:
total += float(werte['total'])
else:
linie = self.line_ids.filtered(
lambda l, c=rule.code: l.code == c)
total += sum(linie.mapped('total'))
steuer = self._at_pfa_rule_amount(result_rules, 'LST_ABF')
return round(max(0.0, total - steuer), 2)
raise UserError('Unbekannter Pfändungstopf: %s' % topf)
# ------------------------------------------------------------------
# Pfändbare Masse je Topf (Basis Existenzminimum)
# ------------------------------------------------------------------
def _at_pfaendung_masse(self, result_rules, topf):
"""Pfändbarer Betrag (= Berechnungsgrundlage Existenzminimum)
je Topf. SZ-Töpfe: allgemeiner Grundbetrag (SZ = §-290b).
Beendigungsansprüche: erhöhter Grundbetrag (lb-pfa-03)."""
self.ensure_one()
basis = self._at_pfaendung_basis(result_rules, topf)
if basis <= 0:
return 0.0
n = self.employee_id._at_unterhaltspflichten()
if topf in ('sz13', 'sz14'):
erhoht = False
elif topf == 'beendigung':
erhoht = True
else:
erhoht = not self.employee_id._at_sz_anspruch()
ex = self._at_pfaendung_existenzminimum(basis, n, erhoht)
return max(0.0, round(basis - ex, 2))
# ------------------------------------------------------------------
# Gesamt-Einbehalt (laufend + SZ) und Kostenersatz (§ 292h/§ 302)
# ------------------------------------------------------------------
def _at_pfaendung_einbehalt(self, result_rules):
"""Gesamter einbehaltener pfändbarer Betrag (laufend + SZ-Töpfe)."""
self.ensure_one()
if not self.employee_id._at_pfaendungen_aktiv(self.date_to):
return 0.0
total = self._at_pfaendung_masse(result_rules, 'laufend')
total += self._at_pfaendung_masse(result_rules, 'sz13')
total += self._at_pfaendung_masse(result_rules, 'sz14')
total += self._at_pfaendung_masse(result_rules, 'beendigung')
return round(total, 2)
def _at_pfaendung_kostenersatz(self, result_rules):
"""Pauschaler Kostenersatz (§ 292h EO): erste Zahlung 2 %/max 8 €,
weitere 1 %/max 4 € — nur vom pfändbaren Bezug, nie vom EX
(lb-pfa-09). Variante 'verzicht' → 0."""
self.ensure_one()
pfaendungen = self.employee_id._at_pfaendungen_aktiv(self.date_to)
if not pfaendungen:
return 0.0
if all(p.kostenersatz_variante == 'verzicht' for p in pfaendungen):
return 0.0
masse = self._at_pfaendung_einbehalt(result_rules)
if masse <= 0:
return 0.0
p = self._l10n_at_param('at_eo_kostenersatz')
erste_max = float(p['erste_max'])
weitere_max = float(p['weitere_max'])
# Erste Zahlung (erstrangig) 2 %/max 8 €, weitere 1 %/max 4 €.
kosten = min(masse * float(p['erste_rate']) / 100.0, erste_max)
if masse > kosten:
kosten += min((masse - kosten) * float(p['weitere_rate'])
/ 100.0, weitere_max)
return round(min(kosten, masse), 2)