# 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)