Files
odoo-at-payroll/addons/l10n_at_hr_payroll_private/models/pfaendung.py
T
fegger 4b86bcb790 [IMP] l10n_at_hr_payroll_private: add voluntary severance under § 67 Abs 6 (AP15-B)
Split the voluntary severance into its privileged part and a tariff remainder on the exit run: the privileged part combines the quarter rule (1/4 of the last twelve months' running pay, capped at nine times the monthly HBG) with the twelfth rule (2/12 to 12/12 by proven service, capped at n × 3 × HBG), reduced per Z 3 by severance already received and the statutory claim due at this termination, all taxed at the flat 6 % rate. The excess runs as its own rule flagged l10n_at_s67_abs6_ueberhang: taxed with the running tariff of the payment month but excluded from the annual sixth, since Z 5 exempts only the Z 1/Z 2 amounts and the excess is a special payment, not running pay. The twelve-month basis aggregates validated prior runs plus own lines, excluding the flagged excess and § 26 Z 4 travel overhang via a dedicated rule flag. Free of SV (§ 49 Abs 3 Z 7 ASVG), payroll levies regardless of the 6 %/tarif split (§ 41 Abs 4 lit b FLAG, § 122 Abs 8 WKG, § 5 Abs 2 lit b KommStG) and BV contributions; the former BV-Anwartschaft exclusion Z 7 is repealed (BGBl I 118/2015). The wizard proposes the rolling basis and the statutory claim, previews Z 1/Z 2/reduction/privileged/excess/tax and requires explicit confirmation against claim-conversion risk. The § 291d EO termination pot nets both severance wage-tax rules; key 260 now includes LST_ABF_FREI. Verified against the original statute text in .ris/ (EStG § 67 Abs 6) and the knowledge-base sources lb-end-12/13/15; KZ positioning of Abs-6 amounts and the excess's § 291d pot allocation remain documented verification points against LStR Rz 1087 ff.

Version 19.0.14.0.0 (private), 19.0.10.0.0 (core)
2026-09-15 11:34:09 +02:00

451 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 = sum(self._at_pfa_rule_amount(result_rules, c)
for c in ('LST_ABF', 'LST_ABF_FREI'))
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)