diff --git a/.agents/MEMORY.md b/.agents/MEMORY.md index dfa9015..6784ad1 100644 --- a/.agents/MEMORY.md +++ b/.agents/MEMORY.md @@ -2,8 +2,53 @@ ## Current focus +**AP15-B freiwillige Abfertigung (§ 67 Abs 6) umgesetzt — Kern 55/55, Privat 146/146 Tests grün** +(2026-09-15, uncommittet bis Nutzerfreigabe; Privat **19.0.14.0.0**, Kern **19.0.10.0.0**): + +- **Recht (RIS-Wortlaut § 67 Abs 6 verifiziert, lb-end-12/13/15):** + Z 1 Viertelregelung (¼ der letzten-12-Monats-Bezüge, Deckel 9×HBG) + + Z 2 Zwölftelregelung (Staffel 3→2/12 … 25→12/12 = §-23-Grenzen, + Deckel n × 3 × HBG), Z 3 Kürzung um erhaltene Abfertigungen (Abs 3 + ODER 6) und bestehenden gesetzlichen Anspruch am aktuellen Ende, + Z 5 Sechstel-Exklusion nur für die 6 %-Beträge, Z 6 Überhang + „wie ein laufender Bezug“ zum Zuflussmonat-Tarif, Z 7 (BV-Ausschluss) + seit BGBl I 118/2015 AUFGEHOBEN. SV-frei § 49 Abs 3 Z 7, LNK-frei + (§ 41 Abs 4 lit b FLAG, § 122 Abs 8 WKG, § 5 Abs 2 lit b KommStG) + UNABHÄNGIG von 6 % vs Tarif, BV-frei (§ 6 Abs 5 BMSVG). +- **Kern (19.0.10.0.0):** neuer Regel-Flag `l10n_at_s67_abs6_ueberhang` + (lohnsteuer.py); `_l10n_at_brutto_laufen_ytd` zieht Flag-Regeln aus + der Sechstel-Basis ab (Helfer `_l10n_at_abs6_ueberhang_total`, auch + je YTD-Lauf) — der Überhang läuft über bemessung='laufend' in die + laufende LSt-Bemessung und KZ 210, aber NICHT ins Sechstel; + `LST_ABF_FREI` in KZ-260-Salden. Test test_20 + L16-Test erweitert. +- **Privat:** Regeln `ABF_FREI` (seq 31, neutral/keine/abs6/ + pfandung_beendigung — begünstigter Teil), `ABF_FREI_TARIF` (seq 32, + laufend/keine/abs6 + Überhang-Flag — Tarifrest), `LST_ABF_FREI` + (seq 73, 6 %); Eingabe ATP_ABF_FREI; Helfer in endabrechnung.py: + `_at_bezuege_12_monate` (validierte Vormonats-Läufe + eigene Zeilen, + ohne Überhang-/§-26-Z-4-Regeln — neues privates Regel-Flag + `l10n_at_s26_z4_reise` an REISE_STEUERPFLICHTIG), `_at_abf_frei_split` + (Z 1/Z 2/Deckel aus at_sv_hbg_monatlich/Z-3-Kürzung), + `_at_abf_frei_lst_6`. Wizard-Abschnitt mit auto 12-Monats-Basis + + gesetzlicher Anspruch aus ABF_ALT, Vorschau Z 1/Z 2/Kürzung/ + begünstigt/Überhang/6 %/Netto, Bestätigungspflicht (Anspruchs- + umwandlungs-Hinweis lb-end-15). Pfändungs-Beendigungstopf zieht + LST_ABF + LST_ABF_FREI ab (test_09c). Konten: rule_abf_frei(_tarif) + 6200/6000 vorläufig, rule_lst_abf_frei 3540. RF-ABF-4…6 + offene + Punkte in docs/rechenfaelle/endabrechnung.md. +- **Tests:** endabrechnung 17 (Z 1/Z 2, Überhang ohne Sechstel mit + SV/LNK/BV/LST_LFD-Asserts, Kürzung+Deckel, Wizard-E2E), pfändung 13, + Kern-Suite 55/55, Privat-Suite 146/146 (ap13a_test); ein Testfehler + (zweite `_input`-Eingabezeile statt Update) korrigiert. +- **Verifikationsreste (dokumentiert, nicht geblockt):** KZ-Positionierung + der Abs-6-Bezüge und §-291d-Zuordnung des Überhangs gegen LStR + Rz 1087 ff; geminderte Bezüge in der 12-Monats-Basis (LStR vs VwGH) + bewusst ohne Automatik. +- **Offen:** AP15-C (§ 67 Abs 8 lit a Vergleichssummen). Nicht + committen ohne Nutzeranforderung. + **AP15-A gesetzliche Abfertigung Alt umgesetzt — Privat 141/141, Kern 54/54 Tests grün** -(2026-09-15, uncommittet bis Nutzerfreigabe; Privat **19.0.13.0.0**, Kern **19.0.9.0.0**): +(2026-09-15, Commit **`9add58f`** nach Nutzerfreigabe): - **Recht am Originalwortlaut verifiziert (.ris/):** § 23a Abs 3 AngG verlangt für den Elternaustritt ein **ununterbrochen fünfjähriges** diff --git a/addons/l10n_at_hr_payroll/__manifest__.py b/addons/l10n_at_hr_payroll/__manifest__.py index 8f818e1..d62305d 100644 --- a/addons/l10n_at_hr_payroll/__manifest__.py +++ b/addons/l10n_at_hr_payroll/__manifest__.py @@ -1,7 +1,7 @@ # Part of the odoo-at-payroll project. License: LGPL-3. { 'name': 'Österreich - Personalverrechnung Kern (AT Payroll Core)', - 'version': '19.0.9.0.0', + 'version': '19.0.10.0.0', 'author': 'Florian Egger', 'website': 'https://gem360.at', 'category': 'Human Resources/Payroll', diff --git a/addons/l10n_at_hr_payroll/models/lohnsteuer.py b/addons/l10n_at_hr_payroll/models/lohnsteuer.py index 55adbe5..9d7ea2e 100644 --- a/addons/l10n_at_hr_payroll/models/lohnsteuer.py +++ b/addons/l10n_at_hr_payroll/models/lohnsteuer.py @@ -70,6 +70,15 @@ class HrSalaryRule(models.Model): 'abgezogen; Grenzen als Jahres-Kumulative je Dienstnehmer ' '(at_st_grenzen). Nur der übersteigende Teil ist ' 'steuerpflichtig. Menge = Arbeitstage (Z 17/Z 9).') + l10n_at_s67_abs6_ueberhang = fields.Boolean( + string='AT § 67 Abs 6 Z 6 — Überhang', + help='Übersteigender Teil einer freiwilligen Abfertigung/Abfindung ' + '(§ 67 Abs 6 Z 6 EStG): wird wie ein laufender Bezug zum ' + 'Tarif des Zuflussmonats versteuert, erhöht aber das ' + 'Jahressechstel nicht — Abs 2 ist nur auf die mit 6 % ' + 'versteuerten Z-1/Z-2-Beträge nicht anzuwendbar (Z 5), der ' + 'Überhang ist ein sonstiger Bezug, kein laufender. LNK-frei ' + 'über § 67 Abs 6 (lb-end-12), SV-frei über § 49 Abs 3 Z 7.') class HrVersion(models.Model): @@ -351,11 +360,32 @@ class HrPayslip(models.Model): def _l10n_at_brutto_laufen_ytd(self, result_rules=None): """Kumulierte laufende Bruttobezüge des Jahres (Sechstel-Basis - ist Brutto, § 67 Abs 2 — inkl. Auszahlungsmonat-Laufendem).""" + ist Brutto, § 67 Abs 2 — inkl. Auszahlungsmonat-Laufendem). + Der §-67-Abs-6-Überhang (Flag) läuft tarifisch im Zuflussmonat, + ist aber ein sonstiger Bezug und erhöht das Jahressechstel + nicht.""" self.ensure_one() - total = self._l10n_at_brutto('laufend', result_rules) + total = self._l10n_at_brutto('laufend', result_rules) \ + - self._l10n_at_abs6_ueberhang_total(result_rules) for slip in self._l10n_at_ytd_slaeufe(): - total += slip._l10n_at_brutto('laufend') + total += slip._l10n_at_brutto('laufend') \ + - slip._l10n_at_abs6_ueberhang_total() + return total + + def _l10n_at_abs6_ueberhang_total(self, result_rules=None): + """Summe der als §-67-Abs-6-Z-6-Überhang gekennzeichneten + Regel-Zeilen (tarifisch im Zuflussmonat, ohne Sechstel-Effekt).""" + self.ensure_one() + total = 0.0 + for rule in self.struct_id.rule_ids: + if not rule.l10n_at_s67_abs6_ueberhang: + continue + if result_rules is not None: + if rule.code in result_rules: + total += result_rules[rule.code]['total'] + else: + total += sum(self.line_ids.filtered( + lambda l, c=rule.code: l.code == c).mapped('total')) return total def _l10n_at_sechstel(self, result_rules=None): diff --git a/addons/l10n_at_hr_payroll/models/lohnzettel_l16.py b/addons/l10n_at_hr_payroll/models/lohnzettel_l16.py index 85c1526..aeec63f 100644 --- a/addons/l10n_at_hr_payroll/models/lohnzettel_l16.py +++ b/addons/l10n_at_hr_payroll/models/lohnzettel_l16.py @@ -390,7 +390,7 @@ class L10nAtPayrollL16Line(models.Model): GP3-Kerns (Monats-Caps 400/600 und ÜStd 15/170 eingerechnet); 220 = sonstige § 67 Abs 1/2/5 ohne Abs-3/6; 225 = SV-DN auf KZ 220; 230 = laufende SV-DN + AK + WF; VIEB = 230 + 225 + - 226; 260/BIEL = LST_LFD + LST_SZ + LST_ABF.""" + 226; 260/BIEL = LST_LFD + LST_SZ + LST_ABF + LST_ABF_FREI.""" self.ensure_one() salden = dict.fromkeys( ('kz210', 'kz215', 'kz220', 'kz225', 'kz226', 'kz230', @@ -411,7 +411,8 @@ class L10nAtPayrollL16Line(models.Model): elif line.code in ('SVDN_KV', 'SVDN_PV', 'SVDN_AV', 'SVDN_AK', 'SVDN_WF'): salden['kz230'] += line.total - elif line.code in ('LST_LFD', 'LST_SZ', 'LST_ABF'): + elif line.code in ('LST_LFD', 'LST_SZ', 'LST_ABF', + 'LST_ABF_FREI'): salden['lohnsteuer'] += line.total freibe = slip._l10n_at_freibetraege_68() salden['kz215'] += freibe['szfn'] + freibe['ue_std'] diff --git a/addons/l10n_at_hr_payroll/tests/test_elda_l16.py b/addons/l10n_at_hr_payroll/tests/test_elda_l16.py index 48d35d7..a8813a1 100644 --- a/addons/l10n_at_hr_payroll/tests/test_elda_l16.py +++ b/addons/l10n_at_hr_payroll/tests/test_elda_l16.py @@ -235,6 +235,9 @@ class L16GeneratorTest(TransactionCase): salary_rule_id=abs3_rule, code='ABF_ALT', total=12000.0), types.SimpleNamespace( salary_rule_id=lst_rule, code='LST_ABF', total=720.0), + types.SimpleNamespace( + salary_rule_id=lst_rule, code='LST_ABF_FREI', + total=300.0), ], _l10n_at_freibetraege_68=lambda: {'szfn': 0.0, 'ue_std': 0.0}, ) @@ -243,7 +246,7 @@ class L16GeneratorTest(TransactionCase): self.assertEqual(salden['kz210'], 0.0) self.assertEqual(salden['kz220'], 0.0) - self.assertEqual(salden['lohnsteuer'], 720.0) + self.assertEqual(salden['lohnsteuer'], 1020.0) def test_02_arbeitsstaette(self): """§ 34 Abs 6 ASVG: je AN ein Satz 45 (Länge 300) mit BKNR, diff --git a/addons/l10n_at_hr_payroll/tests/test_lohnsteuer.py b/addons/l10n_at_hr_payroll/tests/test_lohnsteuer.py index e7c4013..3bb0658 100644 --- a/addons/l10n_at_hr_payroll/tests/test_lohnsteuer.py +++ b/addons/l10n_at_hr_payroll/tests/test_lohnsteuer.py @@ -125,6 +125,27 @@ class LohnsteuerTest(TransactionCase): 'contract_date_end': date(2026, 1, 10)}) self.assertEqual(slip._l10n_at_lohnsteuertage(), 10) + # ------------------------------------------------------------------ + # § 67 Abs 6 Z 6 — Überhang (tarifisch, ohne Sechstel-Effekt) + # ------------------------------------------------------------------ + def test_20_abs6_ueberhang_ohne_sechstel(self): + """Der gekennzeichnete Überhang läuft tarifisch in der laufenden + Bemessung, erhöht aber das Jahressechstel nicht.""" + employee = self._employee() + slip_ohne = self._slip(employee, 1) + regel = self._regel('T_ABS6_UEB', 1000.0, sv_art='keine') + regel.l10n_at_s67_abs6_ueberhang = True + slip_mit = self._slip(employee, 2) + + self.assertAlmostEqual( + slip_mit._l10n_at_abs6_ueberhang_total(), 1000.0, places=2) + self.assertAlmostEqual( + slip_mit._l10n_at_bemessung_laufen() + - slip_ohne._l10n_at_bemessung_laufen(), 1000.0, places=2) + self.assertAlmostEqual( + slip_mit._l10n_at_sechstel() - slip_ohne._l10n_at_sechstel(), + 0.0, places=2) + # ------------------------------------------------------------------ # § 68 Freibeträge (Buckets über Regel-Flags) # ------------------------------------------------------------------ diff --git a/addons/l10n_at_hr_payroll_private/__manifest__.py b/addons/l10n_at_hr_payroll_private/__manifest__.py index 1203acb..cddf45f 100644 --- a/addons/l10n_at_hr_payroll_private/__manifest__.py +++ b/addons/l10n_at_hr_payroll_private/__manifest__.py @@ -1,7 +1,7 @@ # Part of the odoo-at-payroll project. License: LGPL-3. { 'name': 'Österreich - Privatwirtschaft Personalverrechnung (General-AT)', - 'version': '19.0.13.0.0', + 'version': '19.0.14.0.0', 'author': 'Florian Egger', 'website': 'https://gem360.at', 'category': 'Human Resources/Payroll', diff --git a/addons/l10n_at_hr_payroll_private/data/input_types_endabrechnung_private.xml b/addons/l10n_at_hr_payroll_private/data/input_types_endabrechnung_private.xml index 281f194..d5885dd 100644 --- a/addons/l10n_at_hr_payroll_private/data/input_types_endabrechnung_private.xml +++ b/addons/l10n_at_hr_payroll_private/data/input_types_endabrechnung_private.xml @@ -35,4 +35,8 @@ Gesetzliche Abfertigung Alt (Brutto) ATP_ABF_ALT + + Freiwillige Abfertigung (Brutto) + ATP_ABF_FREI + \ No newline at end of file diff --git a/addons/l10n_at_hr_payroll_private/data/salary_rules_endabrechnung_private.xml b/addons/l10n_at_hr_payroll_private/data/salary_rules_endabrechnung_private.xml index d4d160f..90780b4 100644 --- a/addons/l10n_at_hr_payroll_private/data/salary_rules_endabrechnung_private.xml +++ b/addons/l10n_at_hr_payroll_private/data/salary_rules_endabrechnung_private.xml @@ -91,6 +91,45 @@ result = inputs['ATP_ABF_ALT'].amount ]]> + + + Freiwillige Abfertigung — begünstigter Teil (§ 67 Abs 6) + ABF_FREI + + + + python + result = 'ATP_ABF_FREI' in inputs and inputs['ATP_ABF_FREI'].amount + neutral + keine + abs6 + + code + + + + + Freiwillige Abfertigung — Überhang (§ 67 Abs 6 Z 6) + ABF_FREI_TARIF + + + + python + result = payslip._at_abf_frei_ueberhang(result_rules) > 0 + laufend + keine + abs6 + + code + + + Zeitguthaben Normalarbeitszeit + 50 % (§ 19e Abs 2 AZG) diff --git a/addons/l10n_at_hr_payroll_private/data/salary_rules_lst_private.xml b/addons/l10n_at_hr_payroll_private/data/salary_rules_lst_private.xml index b66d1cb..052e094 100644 --- a/addons/l10n_at_hr_payroll_private/data/salary_rules_lst_private.xml +++ b/addons/l10n_at_hr_payroll_private/data/salary_rules_lst_private.xml @@ -119,4 +119,19 @@ result = payslip._l10n_at_lst_sonstige(result_rules) result = payslip._at_abf_alt_lohnsteuer(result_rules) ]]> + + Lohnsteuer freiwillige Abfertigung (§ 67 Abs 6) + LST_ABF_FREI + + + + python + result = payslip._at_abf_frei_lst_6(result_rules) > 0 + code + neutral + keine + + \ No newline at end of file diff --git a/addons/l10n_at_hr_payroll_private/data/salary_rules_reisekosten_private.xml b/addons/l10n_at_hr_payroll_private/data/salary_rules_reisekosten_private.xml index b7710ed..27611c0 100644 --- a/addons/l10n_at_hr_payroll_private/data/salary_rules_reisekosten_private.xml +++ b/addons/l10n_at_hr_payroll_private/data/salary_rules_reisekosten_private.xml @@ -33,6 +33,7 @@ result = inputs['EXPENSES'].amount python result = 'ATP_REISE_STEUERPFLICHTIG' in inputs and inputs['ATP_REISE_STEUERPFLICHTIG'].amount laufend + code =', start), + ('date_from', '<', self.date_from.replace(day=1)), + ]).filtered(lambda p: p.state not in ('draft', 'cancel')) + total = 0.0 + for slip in list(vor_slips) + [self]: + for line in slip.line_ids: + rule = line.salary_rule_id + if rule.l10n_at_bemessung != 'laufend': + continue + if (rule.l10n_at_s67_abs6_ueberhang + or rule.l10n_at_s26_z4_reise): + continue + total += line.total + return total + + def _at_abf_frei_zwoelftel(self): + """Zwölftel-Anzahl nach § 67 Abs 6 Z 2: die Dienstzeitstaffel + entspricht den §-23-Grenzen (36→2/12 … 300→12/12).""" + self.ensure_one() + return self._at_abf_alt_staffel( + self.l10n_at_abf_frei_dienstzeit_monate) + + def _at_abf_frei_split(self, result_rules=None): + """§ 67 Abs 6: begünstigter Teil (Viertel- und Zwölftelregelung, + gedeckelt 9 × bzw. n × 3 × monatliche HBG, Z-3-Kürzung um + erhaltene Abfertigungen und den bestehenden gesetzlichen + Anspruch) und der tarifische Überhang (Z 6).""" + self.ensure_one() + brutto = max(self._at_input_amount('ATP_ABF_FREI'), 0.0) + basis = self.l10n_at_abf_frei_basis_12 + if basis <= 0: + basis = self._at_bezuege_12_monate() + basis = max(basis, 0.0) + hbg = float(self._l10n_at_param('at_sv_hbg_monatlich')) + z1 = min(basis / 4.0, 9.0 * hbg) + zwoelftel = self._at_abf_frei_zwoelftel() + z2 = min(basis * zwoelftel / 12.0, zwoelftel * 3.0 * hbg) + kuerzung = max( + self.l10n_at_abf_frei_erhaltene_abfertigungen + + self.l10n_at_abf_frei_gesetzlicher_anspruch, 0.0) + z2 = max(z2 - kuerzung, 0.0) + beguenstigt = min(brutto, z1 + z2) + return { + 'basis': basis, + 'z1': z1, + 'z2': z2, + 'kuerzung': kuerzung, + 'beguenstigt': beguenstigt, + 'ueberhang': brutto - beguenstigt, + } + + def _at_abf_frei_beguenstigt(self, result_rules=None): + self.ensure_one() + return self._at_abf_frei_split(result_rules)['beguenstigt'] + + def _at_abf_frei_ueberhang(self, result_rules=None): + self.ensure_one() + return self._at_abf_frei_split(result_rules)['ueberhang'] + + def _at_abf_frei_lst_6(self, result_rules=None): + """6 % auf den begünstigten Teil (§ 67 Abs 6, fester Steuersatz).""" + self.ensure_one() + satz = float(self._l10n_at_param( + 'at_endabrechnung')['abfertigung_steuersatz']) + return self._at_abf_frei_beguenstigt(result_rules) * satz / 100.0 + # ------------------------------------------------------------------ # Urlaubsersatzleistung — § 67 Abs 8 lit d, lb-url-15/16 # ------------------------------------------------------------------ diff --git a/addons/l10n_at_hr_payroll_private/models/pfaendung.py b/addons/l10n_at_hr_payroll_private/models/pfaendung.py index e3cf90b..9626802 100644 --- a/addons/l10n_at_hr_payroll_private/models/pfaendung.py +++ b/addons/l10n_at_hr_payroll_private/models/pfaendung.py @@ -386,7 +386,8 @@ class HrPayslip(models.Model): 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') + 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) diff --git a/addons/l10n_at_hr_payroll_private/tests/test_endabrechnung_private.py b/addons/l10n_at_hr_payroll_private/tests/test_endabrechnung_private.py index e946317..ee7b5b0 100644 --- a/addons/l10n_at_hr_payroll_private/tests/test_endabrechnung_private.py +++ b/addons/l10n_at_hr_payroll_private/tests/test_endabrechnung_private.py @@ -479,3 +479,135 @@ class EndabrechnungPrivateTest(TransactionCase): }) with self.assertRaises(UserError): wizard.action_payslip() + + # ------------------------------------------------------------------ + # AP15-B: freiwillige Abfertigung (§ 67 Abs 6) + # ------------------------------------------------------------------ + def test_14_abf_frei_viertel_und_zwoelftel(self): + """15 Jahre (6/12), Basis 36.000: Z 1 9.000 + Z 2 18.000; Brutto + 12.000 → voll begünstigt, 6 % = 720; kein Überhang.""" + emp = self._employee() + slip = self._slip(emp) + slip.write({ + 'l10n_at_abf_frei_basis_12': 36000.0, + 'l10n_at_abf_frei_dienstzeit_monate': 180, + }) + self._input(slip, 'ATP_ABF_FREI', 12000.0) + + split = slip._at_abf_frei_split() + self.assertAlmostEqual(split['z1'], 9000.0, places=2) + self.assertAlmostEqual(split['z2'], 18000.0, places=2) + self.assertAlmostEqual(split['beguenstigt'], 12000.0, places=2) + self.assertAlmostEqual(split['ueberhang'], 0.0, places=2) + self.assertAlmostEqual( + self._total(slip, 'ABF_FREI'), 12000.0, places=2) + self.assertAlmostEqual( + self._total(slip, 'LST_ABF_FREI'), 720.0, places=2) + self.assertFalse(self._total(slip, 'ABF_FREI_TARIF')) + + def test_15_abf_frei_ueberhang_tarif_ohne_sechstel(self): + """Brutto 40.000: begünstigt 27.000 (Z 1 + Z 2), Überhang 13.000 + läuft tarifisch über die LSt-Bemessung, ohne das Jahressechstel + zu erhöhen; SV, LNK und BV bleiben unberührt.""" + emp_mit = self._employee() + emp_ohne = self._employee() + slip_mit = self._slip(emp_mit) + slip_ohne = self._slip(emp_ohne) + slip_mit.write({ + 'l10n_at_abf_frei_basis_12': 36000.0, + 'l10n_at_abf_frei_dienstzeit_monate': 180, + }) + self._input(slip_mit, 'ATP_ABF_FREI', 40000.0) + + self.assertAlmostEqual( + self._total(slip_mit, 'ABF_FREI'), 27000.0, places=2) + self.assertAlmostEqual( + self._total(slip_mit, 'ABF_FREI_TARIF'), 13000.0, places=2) + lst6 = self.env.company.currency_id.round(27000.0 * 0.06) + self.assertAlmostEqual( + self._total(slip_mit, 'LST_ABF_FREI'), lst6, places=2) + self.assertAlmostEqual( + slip_mit._l10n_at_bemessung_laufen() + - slip_ohne._l10n_at_bemessung_laufen(), 13000.0, places=2) + self.assertGreater( + self._total(slip_mit, 'LST_LFD'), self._total(slip_ohne, 'LST_LFD')) + self.assertAlmostEqual( + slip_mit._l10n_at_sechstel() - slip_ohne._l10n_at_sechstel(), + 0.0, places=2) + self.assertAlmostEqual( + slip_mit._l10n_at_sv_basis_roh() + - slip_ohne._l10n_at_sv_basis_roh(), 0.0, places=2) + self.assertAlmostEqual( + slip_mit._at_lnk_basis('flaf') + - slip_ohne._at_lnk_basis('flaf'), 0.0, places=2) + self.assertAlmostEqual( + slip_mit._at_bv_basis_sonst() + - slip_ohne._at_bv_basis_sonst(), 0.0, places=2) + self.assertAlmostEqual( + slip_mit._at_pfaendung_basis(None, 'beendigung'), + 27000.0 - lst6, places=2) + + def test_16_abf_frei_kuerzung_und_deckel(self): + """Z 3: erhaltene Abfertigungen plus gesetzlicher Anspruch kürzen + das Z-2-Ausmaß; bei großer Basis greifen die Deckel 9 × HBG + (Z 1) und n × 3 × HBG (Z 2).""" + emp = self._employee() + slip = self._slip(emp) + slip.write({ + 'l10n_at_abf_frei_basis_12': 36000.0, + 'l10n_at_abf_frei_dienstzeit_monate': 180, + 'l10n_at_abf_frei_erhaltene_abfertigungen': 4000.0, + 'l10n_at_abf_frei_gesetzlicher_anspruch': 9000.0, + }) + self._input(slip, 'ATP_ABF_FREI', 20000.0) + + split = slip._at_abf_frei_split() + self.assertAlmostEqual(split['kuerzung'], 13000.0, places=2) + self.assertAlmostEqual(split['z2'], 5000.0, places=2) + self.assertAlmostEqual(split['beguenstigt'], 14000.0, places=2) + self.assertAlmostEqual(split['ueberhang'], 6000.0, places=2) + + slip.write({ + 'l10n_at_abf_frei_basis_12': 12000000.0, + 'l10n_at_abf_frei_erhaltene_abfertigungen': 0.0, + 'l10n_at_abf_frei_gesetzlicher_anspruch': 0.0, + }) + slip.input_line_ids.filtered( + lambda i: i.input_type_id.code == 'ATP_ABF_FREI').write( + {'amount': 300000.0}) + slip.compute_sheet() + split = slip._at_abf_frei_split() + self.assertAlmostEqual(split['z1'], 62370.0, places=2) + self.assertAlmostEqual(split['z2'], 124740.0, places=2) + self.assertAlmostEqual(split['beguenstigt'], 187110.0, places=2) + self.assertAlmostEqual(split['ueberhang'], 112890.0, places=2) + + def test_17_abf_frei_wizard(self): + emp = self._employee() + emp.version_id.write({ + 'structure_type_id': self.env.ref( + 'l10n_at_hr_payroll_private.structure_type_at_privat').id, + 'l10n_at_abfertigung_neu': False, + }) + wizard = self.env['l10n.at.payroll.endabrechnung.wizard'].create({ + 'employee_id': emp.id, + 'austrittsdatum': date(2026, 3, 15), + 'departure_reason_id': self.env.ref( + 'l10n_at_hr_payroll.departure_reason_einvernehmlich').id, + 'abf_frei_aktiv': True, + 'abf_frei_anspruch_bestaetigt': True, + 'abf_frei_betrag': 20000.0, + 'abf_frei_basis_12': 36000.0, + 'abf_frei_dienstzeit_monate': 180, + 'abf_frei_nachweis': 'Vordienstzeiten zum Lohnkonto nachgewiesen', + }) + ergebnis = wizard.action_payslip() + slip = self.env['hr.payslip'].browse(ergebnis['res_id']) + + self.assertAlmostEqual(self._total(slip, 'ABF_FREI'), 20000.0, + places=2) + self.assertAlmostEqual(self._total(slip, 'LST_ABF_FREI'), 1200.0, + places=2) + self.assertEqual(slip.l10n_at_abf_frei_dienstzeit_monate, 180) + self.assertEqual(slip.l10n_at_abf_frei_nachweis, + 'Vordienstzeiten zum Lohnkonto nachgewiesen') diff --git a/addons/l10n_at_hr_payroll_private/tests/test_pfaendung_private.py b/addons/l10n_at_hr_payroll_private/tests/test_pfaendung_private.py index 64658cf..c0b23e0 100644 --- a/addons/l10n_at_hr_payroll_private/tests/test_pfaendung_private.py +++ b/addons/l10n_at_hr_payroll_private/tests/test_pfaendung_private.py @@ -209,6 +209,35 @@ class PfaendungPrivateTest(TransactionCase): self.assertAlmostEqual( self._total(slip, 'PFAENDUNG_END'), masse, places=2) + def test_09c_pfaendung_abf_frei_topf(self): + """AP15-B: begünstigter Teil der freiwilligen Abfertigung läuft + in den §-291d-Topf, abzüglich beider Abfertigungs-LSt-Arten; der + Überhang bleibt über die laufende Bemessung pfändbar.""" + employee = self._employee(datetime.date(2020, 1, 1), unterhalt=0) + self._pfaendung(employee, datetime.date(2026, 2, 1)) + slip = self._slip(employee, 3) + slip.write({ + 'l10n_at_abf_frei_basis_12': 36000.0, + 'l10n_at_abf_frei_dienstzeit_monate': 180, + }) + input_type = self.env.ref( + 'l10n_at_hr_payroll_private.input_type_atp_abf_frei') + self.env['hr.payslip.input'].create({ + 'payslip_id': slip.id, + 'input_type_id': input_type.id, + 'amount': 40000.0, + }) + slip.compute_sheet() + + lst6 = self.env.company.currency_id.round(27000.0 * 0.06) + self.assertAlmostEqual( + self._total(slip, 'ABF_FREI'), 27000.0, places=2) + self.assertAlmostEqual( + self._total(slip, 'ABF_FREI_TARIF'), 13000.0, places=2) + self.assertAlmostEqual( + slip._at_pfaendung_basis(None, 'beendigung'), + 27000.0 - lst6, places=2) + def test_10_rang(self): """Zwei Pfändungen → Rang 1 (früheres Einlangen) und Rang 2.""" employee = self._employee(datetime.date(2026, 1, 1)) diff --git a/addons/l10n_at_hr_payroll_private/views/endabrechnung_views.xml b/addons/l10n_at_hr_payroll_private/views/endabrechnung_views.xml index 6d3a611..64a6cec 100644 --- a/addons/l10n_at_hr_payroll_private/views/endabrechnung_views.xml +++ b/addons/l10n_at_hr_payroll_private/views/endabrechnung_views.xml @@ -44,6 +44,27 @@ + + + + + + + + + + + + + + + + + + + + + @@ -90,6 +111,30 @@ + + hr.payslip.form.abfertigung.frei + hr.payslip + + + + + + + + + + + + + + + + + + + + Endabrechnung (Beendigung) l10n.at.payroll.endabrechnung.wizard diff --git a/addons/l10n_at_hr_payroll_private/wizard/endabrechnung.py b/addons/l10n_at_hr_payroll_private/wizard/endabrechnung.py index bcf8d85..687d4d9 100644 --- a/addons/l10n_at_hr_payroll_private/wizard/endabrechnung.py +++ b/addons/l10n_at_hr_payroll_private/wizard/endabrechnung.py @@ -101,6 +101,43 @@ class L10nAtEndabrechnungWizard(models.TransientModel): string='Nachweis/Begründung', help='Quelle der Dienstzeiten, Anspruchsprüfung, Alt-/Neu-Status ' 'und allfällige manuelle Abweichung dokumentieren.') + abf_frei_aktiv = fields.Boolean( + string='Freiwillige Abfertigung (§ 67 Abs 6)') + abf_frei_anspruch_bestaetigt = fields.Boolean( + string='Prüfung und Nachweise bestätigt', + help='Bestätigung, dass es sich um eine Beendigungsleistung ohne ' + 'schädliche Anspruchsumwandlung handelt (KE/UE/SZ/Zeitguthaben ' + 'müssen getrennt abrechnet werden — GPLB-Risiko, lb-end-15), ' + 'dass die (Vor-)Dienstzeiten lückenlos zum Lohnkonto ' + '(§ 76 EStG) nachgewiesen sind und erhaltene Abfertigungen ' + 'angegeben wurden.') + abf_frei_betrag = fields.Monetary( + string='Bruttobetrag', currency_field='currency_id') + abf_frei_basis_12 = fields.Monetary( + string='Laufende Bezüge letzte 12 Monate', + currency_field='currency_id', + help='Basis der Viertel-/Zwölftelregelung: alle steuerbaren ' + 'Bezugsteile (auch steuerfreie Zuschläge, Sachbezüge), ' + 'ohne Sonderzahlungen und ohne §-26-Z-4-Reisevergütungen; ' + 'wird aus den letzten zwölf validierten Läufen vorgeschlagen ' + 'und ist bestätigbar (lb-end-13).') + abf_frei_dienstzeit_monate = fields.Integer( + string='Nachgewiesene Dienstzeit (Monate)') + abf_frei_erhaltene_abfertigungen = fields.Monetary( + string='Bereits erhaltene Abfertigungen', currency_field='currency_id', + help='In den nachgewiesenen Dienstzeiten erhaltene Abfertigungen ' + '(§ 67 Abs 3 oder Abs 6 EStG) — Kürzung nach Z 3.') + abf_frei_gesetzlicher_anspruch = fields.Monetary( + string='Bestehender gesetzlicher Anspruch', + currency_field='currency_id', + help='Anspruch auf eine gesetzliche Abfertigung am aktuellen ' + 'Dienstverhältnis-Ende (Z 3); bei aktiver Alt-Abfertigung ' + 'automatisch aus dem Alt-Anspruch vorgeschlagen.') + abf_frei_nachweis = fields.Text( + string='Nachweis/Begründung', + help='Vordienstzeiten-Nachweis (Lohnkonto § 76 EStG), erhaltene ' + 'Abfertigungen, Basis-Abweichungen und Anspruchsgrundlage ' + 'dokumentieren.') currency_id = fields.Many2one( 'res.currency', default=lambda self: self.env.company.currency_id) @@ -128,6 +165,27 @@ class L10nAtEndabrechnungWizard(models.TransientModel): abf_alt_netto_vorschau = fields.Monetary( string='Abfertigung Alt netto', currency_field='currency_id', compute='_compute_vorschau') + abf_frei_z1_vorschau = fields.Monetary( + string='Z 1 Viertelregelung (Vorschau)', currency_field='currency_id', + compute='_compute_vorschau') + abf_frei_z2_vorschau = fields.Monetary( + string='Z 2 Zwölftelregelung (Vorschau)', currency_field='currency_id', + compute='_compute_vorschau') + abf_frei_kuerzung_vorschau = fields.Monetary( + string='Z 3 Kürzung (Vorschau)', currency_field='currency_id', + compute='_compute_vorschau') + abf_frei_beguenstigt_vorschau = fields.Monetary( + string='Begünstigter Teil 6 % (Vorschau)', currency_field='currency_id', + compute='_compute_vorschau') + abf_frei_ueberhang_vorschau = fields.Monetary( + string='Überhang zum Tarif (Vorschau)', currency_field='currency_id', + compute='_compute_vorschau') + abf_frei_lst_vorschau = fields.Monetary( + string='LSt 6 % (Vorschau)', currency_field='currency_id', + compute='_compute_vorschau') + abf_frei_netto_vorschau = fields.Monetary( + string='Begünstigter Teil netto (Vorschau)', + currency_field='currency_id', compute='_compute_vorschau') @api.onchange('employee_id') def _onchange_employee(self): @@ -139,12 +197,16 @@ class L10nAtEndabrechnungWizard(models.TransientModel): version.contract_date_end or fields.Date.today()) wizard.departure_reason_id = version.departure_reason_id wizard._at_abf_alt_vorschlag_setzen() + wizard._at_abf_frei_vorschlag_setzen() @api.onchange('austrittsdatum', 'abf_alt_anspruchsart', - 'abf_alt_dienstzeit_monate') + 'abf_alt_dienstzeit_monate', 'abf_alt_aktiv', + 'abf_alt_monatsentgelt', 'abf_alt_mehrfach', + 'abf_frei_aktiv', 'abf_frei_dienstzeit_monate') def _onchange_abf_alt(self): for wizard in self: wizard._at_abf_alt_vorschlag_setzen() + wizard._at_abf_frei_vorschlag_setzen() def _at_abf_alt_vorschlag_setzen(self): self.ensure_one() @@ -168,11 +230,27 @@ class L10nAtEndabrechnungWizard(models.TransientModel): if vorgeschlagen or not self.abf_alt_mehrfach: self.abf_alt_mehrfach = vorgeschlagen + def _at_abf_frei_vorschlag_setzen(self): + self.ensure_one() + if not self.employee_id or not self.austrittsdatum: + return + slip = self._vorschau_payslip() + if self.abf_frei_aktiv and not self.abf_frei_basis_12: + self.abf_frei_basis_12 = slip._at_bezuege_12_monate() + if (self.abf_frei_aktiv and self.abf_alt_aktiv + and not self.abf_frei_gesetzlicher_anspruch): + self.abf_frei_gesetzlicher_anspruch = ( + self.abf_alt_monatsentgelt * self.abf_alt_mehrfach) + @api.depends('employee_id', 'austrittsdatum', 'uel_tage', 'abf_alt_aktiv', 'abf_alt_monatsentgelt', 'abf_alt_laufender_arbeitslohn', 'abf_alt_monatslohnsteuer', 'abf_alt_mehrfach', - 'abf_alt_dienstzeit_monate', 'abf_alt_anspruchsart') + 'abf_alt_dienstzeit_monate', 'abf_alt_anspruchsart', + 'abf_frei_aktiv', 'abf_frei_betrag', 'abf_frei_basis_12', + 'abf_frei_dienstzeit_monate', + 'abf_frei_erhaltene_abfertigungen', + 'abf_frei_gesetzlicher_anspruch') def _compute_vorschau(self): for wizard in self: wizard.uel_lfd_vorschau = 0.0 @@ -183,6 +261,13 @@ class L10nAtEndabrechnungWizard(models.TransientModel): wizard.abf_alt_steuer_6_vorschau = 0.0 wizard.abf_alt_lohnsteuer_vorschau = 0.0 wizard.abf_alt_netto_vorschau = 0.0 + wizard.abf_frei_z1_vorschau = 0.0 + wizard.abf_frei_z2_vorschau = 0.0 + wizard.abf_frei_kuerzung_vorschau = 0.0 + wizard.abf_frei_beguenstigt_vorschau = 0.0 + wizard.abf_frei_ueberhang_vorschau = 0.0 + wizard.abf_frei_lst_vorschau = 0.0 + wizard.abf_frei_netto_vorschau = 0.0 if not wizard.employee_id or not wizard.austrittsdatum: continue slip = wizard._vorschau_payslip() @@ -202,6 +287,19 @@ class L10nAtEndabrechnungWizard(models.TransientModel): wizard.abf_alt_steuer_6_vorschau = steuer_6 wizard.abf_alt_lohnsteuer_vorschau = steuer wizard.abf_alt_netto_vorschau = betrag - steuer + if wizard.abf_frei_aktiv: + split = slip._at_abf_frei_split() + satz = float(slip._l10n_at_param( + 'at_endabrechnung')['abfertigung_steuersatz']) + wizard.abf_frei_z1_vorschau = split['z1'] + wizard.abf_frei_z2_vorschau = split['z2'] + wizard.abf_frei_kuerzung_vorschau = split['kuerzung'] + wizard.abf_frei_beguenstigt_vorschau = split['beguenstigt'] + wizard.abf_frei_ueberhang_vorschau = split['ueberhang'] + wizard.abf_frei_lst_vorschau = \ + split['beguenstigt'] * satz / 100.0 + wizard.abf_frei_netto_vorschau = \ + split['beguenstigt'] - wizard.abf_frei_lst_vorschau def _vorschau_payslip(self): """Flüchtiger Payslip-Kontext für die Vorschau-Berechnung (kein @@ -229,11 +327,21 @@ class L10nAtEndabrechnungWizard(models.TransientModel): self.abf_alt_monatslohnsteuer, 'l10n_at_abf_alt_mehrfach': self.abf_alt_mehrfach, 'l10n_at_abf_alt_nachweis': self.abf_alt_nachweis, + 'l10n_at_abf_frei_basis_12': self.abf_frei_basis_12, + 'l10n_at_abf_frei_dienstzeit_monate': + self.abf_frei_dienstzeit_monate, + 'l10n_at_abf_frei_erhaltene_abfertigungen': + self.abf_frei_erhaltene_abfertigungen, + 'l10n_at_abf_frei_gesetzlicher_anspruch': + self.abf_frei_gesetzlicher_anspruch, + 'l10n_at_abf_frei_nachweis': self.abf_frei_nachweis, }) eingaben = [ ('ATP_UEL_TAGE', self.uel_tage), ('ATP_ABF_ALT', self.abf_alt_monatsentgelt * self.abf_alt_mehrfach if self.abf_alt_aktiv else 0.0), + ('ATP_ABF_FREI', self.abf_frei_betrag + if self.abf_frei_aktiv else 0.0), ] slip.input_line_ids = [ (0, 0, { @@ -256,6 +364,8 @@ class L10nAtEndabrechnungWizard(models.TransientModel): ('ATP_ABRECHNUNG_VORSCHUSS', self.vorschuss_saldo), ('ATP_ABF_ALT', self.abf_alt_monatsentgelt * self.abf_alt_mehrfach if self.abf_alt_aktiv else 0.0), + ('ATP_ABF_FREI', self.abf_frei_betrag + if self.abf_frei_aktiv else 0.0), ] def _abf_alt_validieren(self): @@ -299,11 +409,30 @@ class L10nAtEndabrechnungWizard(models.TransientModel): 'Entlassung. Bitte den Anspruch manuell bestätigen und ' 'begründen.')) + def _abf_frei_validieren(self): + self.ensure_one() + if not self.abf_frei_aktiv: + return + if not self.abf_frei_anspruch_bestaetigt: + raise UserError(_('Bitte Prüfung und Nachweise der freiwilligen ' + 'Abfertigung bestätigen.')) + if self.abf_frei_betrag <= 0: + raise UserError(_('Der Bruttobetrag der freiwilligen ' + 'Abfertigung muss größer als null sein.')) + if (self.abf_frei_basis_12 < 0 + or self.abf_frei_erhaltene_abfertigungen < 0 + or self.abf_frei_gesetzlicher_anspruch < 0 + or self.abf_frei_dienstzeit_monate < 0): + raise UserError(_('12-Monats-Basis, erhaltene Abfertigungen, ' + 'gesetzlicher Anspruch und Dienstzeit dürfen ' + 'nicht negativ sein.')) + def action_payslip(self): """Endabrechnungs-Lauf im Austrittsmonat anlegen und die gesammelten offenen Posten als Eingaben vorbefüllen.""" self.ensure_one() self._abf_alt_validieren() + self._abf_frei_validieren() erster = self.austrittsdatum.replace(day=1) letzter = (erster + timedelta(days=32)).replace(day=1) \ - timedelta(days=1) @@ -335,6 +464,18 @@ class L10nAtEndabrechnungWizard(models.TransientModel): self.abf_alt_mehrfach if self.abf_alt_aktiv else 0.0, 'l10n_at_abf_alt_nachweis': self.abf_alt_nachweis if self.abf_alt_aktiv else False, + 'l10n_at_abf_frei_basis_12': + self.abf_frei_basis_12 if self.abf_frei_aktiv else 0.0, + 'l10n_at_abf_frei_dienstzeit_monate': + self.abf_frei_dienstzeit_monate if self.abf_frei_aktiv else 0, + 'l10n_at_abf_frei_erhaltene_abfertigungen': + self.abf_frei_erhaltene_abfertigungen + if self.abf_frei_aktiv else 0.0, + 'l10n_at_abf_frei_gesetzlicher_anspruch': + self.abf_frei_gesetzlicher_anspruch + if self.abf_frei_aktiv else 0.0, + 'l10n_at_abf_frei_nachweis': + self.abf_frei_nachweis if self.abf_frei_aktiv else False, }) typen = {code: self.env['hr.payslip.input.type'].search( [('code', '=', code)], limit=1)