[IMP] l10n_at_hr_payroll_private: add end-of-employment payroll cycle (AP14-A)

Pay the untaken vacation, dismissal compensation, time balances and open advances from one exit run. The vacation replacement splits natively into a running and a special-payment component per § 67 Abs 8 lit d, only the running part extends the mandatory insurance under § 11 Abs 2 ASVG with sequential windows (dismissal compensation first) and daily-value ceilings. Dismissal compensation follows the one-fifth rule of § 67 Abs 8 lit b without raising the annual sixth, stays fully social-insurable by allocation over the notice period and fully subject to payroll taxes. Advances are offset against net pay only (§ 293 Abs 3 EO) and time credits carry the 50 % statutory surcharge without § 68 relief. Verified against the original statute texts in .ris/; § 67 Abs 6 lump-sum payments are deliberately deferred to AP15.
This commit is contained in:
2026-09-14 16:08:44 +02:00
parent 9ff977e0d4
commit ee94f4664f
14 changed files with 1189 additions and 4 deletions
+54 -2
View File
@@ -2,9 +2,61 @@
## Current focus
**AP13-B Pendlerförderung umgesetzt — 127 Tests grün**
**AP14-A Endabrechnungs-Zyklus umgesetzt — 133 Tests grün**
(2026-09-14, uncommittet bis Nutzerfreigabe):
- **Rechtskorrektur gegen Originalwortlaut (.ris/, RIS-Gate erledigt):**
§ 67 Abs 6 (freiwillige Abfertigung/Abfindung) = 6 %-Bänder
(Z 1 ¼ der letzten-12-Monate-Bezüge, Deckel 9×HBG; Z 2 Dienstzeit-
bänder), NICHT Fünftel → nach AP15 verschoben. Fünftelregelung gilt
§ 67 Abs 8 lit b (Kündigungs-/Abgangsentschädigung) — diese sind
SV-PFLICHTIG (Umlegung auf Kündigungsfrist, § 11 Abs 2 ASVG) und
LNK-PFLICHTIG (FLAG § 41 Abs 4 lit b nennt nur Abs 3 und 6).
§ 11 Abs 2 ASVG verifiziert: PV-Verlängerung für UEL-Bezug UND
Kündigungsentschädigung, bei beiden ZUERST Kündigungsentschädigung,
dann UEL (sequentielle Fenster); 6-WT/5-AT-Tage-Umrechnung ist
Verwaltungspraxis (lb-url-15), nicht Gesetzeswortlaut. § 67 Abs 8
lit d (UEL-Zweikomponenten) wortwörtlich bestätigt. § 19e Abs 2
AZG (+50 % auf NAZ-Guthaben) verifiziert.
- **Umsetzung (Privat 19.0.12.0.0):** `models/endabrechnung.py` mit
Regel-Flags `l10n_at_sv_verlaengerung` (urlaubsersatz/abgang) und
`l10n_at_s67_fuenftel`; UEL-Helfer (Divisor 26/22, SZ-Tageswert
2×SZ-Basis/12); Upstream-Replicas mit Pin-Kommentar für
`_at_lnk_basis` (Abgang VOR der Freibetragsstaffel) und
`_l10n_at_sv_basis_laufen_monate` (Verlängerungsanteile mit
Tageswert-Deckel HBG×Tage/30, Standardanteile volle Monats-HBG);
`_l10n_at_bemessung_laufen` += Fünftel-Tarifteil (4/5, Deckel
12.474 = 9×HBG/5) ohne Sechstel-Effekt.
- **Regeln:** URL_ERSATZ_LFD (laufend, verlaengerung=urlaubsersatz),
URL_ERSATZ_SZ (sonstig), ABGANG (neutral, sv_art=laufend,
verlaengerung=abgang, fuenftel), ZEIT_GUTHABEN (laufend ×1,5,
KEIN §-68), ZEIT_SCHULDEN (negativ), END_ABRECHNUNG_VORSCHUSS
(neutral/keine — Netto-Abzug § 293 Abs 3 EO). Eingaben
ATP_UEL_TAGE/ABGANG/ABGANG_FFRIST_TAGE/ZEIT_GUTHABEN/ZEIT_SCHULDEN/
ABRECHNUNG_VORSCHUSS; Parameter at_endabrechnung (Divisoren).
- **Wizard** `l10n.at.payroll.endabrechnung.wizard` (ACL, Views,
Menü seq 42): sammelt UEL-Tage/Abgang/Frist/Zeit/Vorschuss, Vorschau
über flüchtigen new()-Payslip, Button legt Endabrechnungs-Lauf im
Austrittsmonat mit vorbefüllten Eingaben an. Achtung: hr.version-
Strukturfeld heißt `structure_id` (nicht struct_id).
- **Fund beim Testlauf:** Eingabetypen-Datei fehlte zunächst in der
Manifest-Datenliste → NOT-NULL input_type_id; zwei Testassertionen
mussten SV-DN-Differenzen bzw. den KVGEO-Standardanteil im März
berücksichtigen.
- **Tests** test_endabrechnung_private.py (6): UEL-Zweikomponenten
(1.320/220 €, Sechstel +5.280), SV-Tage-Schema (23→31, 12→14,
6→21) + Monatsverteilung, Abgang-Fünftel (Sechstel unverändert,
LNK +9.000, Umlegung 16/26 Tage), Vorschuss-Netto (Abgaben neutral),
Zeitguthaben ±, Wizard E2E. Suite **133/133** in ap13a_test.
- **Offen AP14-B/AP15:** Vergleich § 67 Abs 8 lit a + freiwillige
Abfertigung § 67 Abs 6 (beides mit Abf-Alt-Günstiger-Maschine,
rollierende 12-Monats-Basis), Prämien-Beendigungs-Rollung,
Meldewesen-Schnittstellen („Ende des Entgeltanspruchs“ in mBGM,
gesonderter Lohnzettel LStR Rz 911a).
**AP13-B Pendlerförderung umgesetzt — 127 Tests grün**
(2026-09-14, Commit **`9ff977e`** nach Nutzerfreigabe):
- Neu `models/pendler.py`: Modell `l10n.at.payroll.pendler.erklaerung`
(effektiv datiert, L-34-/Pendlerrechner-Nachweis als Attachment,
pauschale_art klein/gross, fahrten_stufe drittel/zwei_drittel/voll,
@@ -39,7 +91,7 @@
(Odoo 19: quantity/price_unit/total_amount) — Test-Helfer korrigiert.
- Doku docs/rechenfaelle/pendler.md (RF-PEN-1…5 + offene Punkte: erhöhter
VAB 853 €/Zuschlag 804 € als Quer-LSt-Rest, L-34-1-Monats-Meldeprozess).
- Version **19.0.11.0.0**. Commit ausstehend (Nutzerfreigabe).
- Version **19.0.11.0.0**.
**AP13-A Reisekosten Inland committed — 127-er Suite grün**
(2026-09-14, Commit **`3f307a0`**):
@@ -1,7 +1,7 @@
# Part of the odoo-at-payroll project. License: LGPL-3.
{
'name': 'Österreich - Privatwirtschaft Personalverrechnung (General-AT)',
'version': '19.0.11.0.0',
'version': '19.0.12.0.0',
'author': 'Florian Egger',
'website': 'https://gem360.at',
'category': 'Human Resources/Payroll',
@@ -104,6 +104,7 @@ gem360-Brücke).
'data/work_entry_types_private.xml',
'data/absence_private.xml',
'data/input_types_private.xml',
'data/input_types_endabrechnung_private.xml',
'data/salary_rules_private.xml',
'data/salary_rules_lst_private.xml',
'data/salary_rules_efzg_private.xml',
@@ -113,6 +114,7 @@ gem360-Brücke).
'data/salary_rules_sb_private.xml',
'data/salary_rules_sb2_private.xml',
'data/salary_rules_reisekosten_private.xml',
'data/salary_rules_endabrechnung_private.xml',
'data/salary_rules_sv_private.xml',
'data/salary_rules_bvg_private.xml',
'data/salary_rules_bvg_private.xml',
@@ -126,6 +128,7 @@ gem360-Brücke).
'data/rule_parameters_sb2_private.xml',
'data/rule_parameters_reisekosten_private.xml',
'data/rule_parameters_pendler_private.xml',
'data/rule_parameters_endabrechnung_private.xml',
'data/bv_kasse_seed.xml',
'views/kv_views.xml',
'views/bv_views.xml',
@@ -137,6 +140,7 @@ gem360-Brücke).
'views/sachbezuege_views.xml',
'views/reisekosten_views.xml',
'views/pendler_views.xml',
'views/endabrechnung_views.xml',
'report/gp8_reports.xml',
'report/buchungsbeleg_report.xml',
'report/bv_report.xml',
@@ -0,0 +1,34 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- AP14-A: Endabrechnungs-Eingaben. Mengen-Eingaben (Tage/Stunden)
tragen die Menge im amount-Feld (Muster ATP_SB_MAHLZEIT); die
Regeln leiten daraus die Beträge ab. -->
<record id="input_type_atp_uel_tage" model="hr.payslip.input.type">
<field name="name">Urlaubsersatzleistung — offene Urlaubstage</field>
<field name="code">ATP_UEL_TAGE</field>
<field name="is_quantity" eval="True"/>
</record>
<record id="input_type_atp_abgang" model="hr.payslip.input.type">
<field name="name">Abgangsentschädigung (Brutto)</field>
<field name="code">ATP_ABGANG</field>
</record>
<record id="input_type_atp_abgang_ffrist_tage" model="hr.payslip.input.type">
<field name="name">Abgangsentschädigung — Kündigungsfrist (Kalendertage)</field>
<field name="code">ATP_ABGANG_FFRIST_TAGE</field>
<field name="is_quantity" eval="True"/>
</record>
<record id="input_type_atp_zeit_guthaben" model="hr.payslip.input.type">
<field name="name">Zeitguthaben Normalarbeitszeit (Stunden)</field>
<field name="code">ATP_ZEIT_GUTHABEN</field>
<field name="is_quantity" eval="True"/>
</record>
<record id="input_type_atp_zeit_schulden" model="hr.payslip.input.type">
<field name="name">Zeitschulden (Stunden)</field>
<field name="code">ATP_ZEIT_SCHULDEN</field>
<field name="is_quantity" eval="True"/>
</record>
<record id="input_type_atp_abrechnung_vorschuss" model="hr.payslip.input.type">
<field name="name">Aufrechnung offener Vorschuss (Netto)</field>
<field name="code">ATP_ABRECHNUNG_VORSCHUSS</field>
</record>
</odoo>
@@ -0,0 +1,17 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- AP14-A: Endabrechnungs-Parameter (lb-url-16 Tagewert-Divisoren;
§-10-Aliquotierung läuft über die Urlaubstage-Eingabe, der
Fünftel-Deckel dynamisch aus 9 × at_sv_hbg_monatlich / 5). -->
<record id="hr_rule_parameter_at_endabrechnung" model="hr.rule.parameter">
<field name="name">Endabrechnung — UEL-Tagewert-Divisoren</field>
<field name="code">at_endabrechnung</field>
<field name="country_id" ref="base.at"/>
</record>
<record id="hr_rule_parameter_value_at_endabrechnung"
model="hr.rule.parameter.value">
<field name="rule_parameter_id" ref="hr_rule_parameter_at_endabrechnung"/>
<field name="date_from">2026-01-01</field>
<field name="parameter_value">{'divisor_werktage': 26.0, 'divisor_arbeitstage': 22.0}</field>
</record>
</odoo>
@@ -0,0 +1,132 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- ============================================================ -->
<!-- AP14-A: Endabrechnungs-Regeln. -->
<!-- UEL: § 67 Abs 8 lit d — laufender Anteil als laufender -->
<!-- Bezug (Tarif + Sechstel nativ), SZ-Anteil als sonstiger Bezug -->
<!-- (6 %-Band, Freibetrag/Freigrenze im Kern). SV: nur der -->
<!-- laufende Anteil verlängert die Pflichtversicherung (§ 11 -->
<!-- Abs 2 ASVG, Tage-Umrechnung lb-url-15). -->
<!-- Abgang: § 67 Abs 8 lit b — neutral (kein Sechstel), LSt über -->
<!-- Fünftel-Override; SV-pflichtig mit Umlegung auf die -->
<!-- Kündigungsfrist; LNK-pflichtig (FLAG § 41 Abs 4 nennt nur -->
<!-- § 67 Abs 3 und 6). -->
<!-- ============================================================ -->
<!-- Urlaubsersatzleistung — laufender Anteil (§ 67 Abs 8 lit d). -->
<record id="rule_url_ersatz_lfd" model="hr.salary.rule">
<field name="name">Urlaubsersatzleistung — laufender Anteil (§ 67 Abs 8 lit d)</field>
<field name="code">URL_ERSATZ_LFD</field>
<field name="sequence" eval="25"/>
<field name="category_id" ref="l10n_at_hr_payroll_private.category_atp_brutto"/>
<field name="struct_id" ref="l10n_at_hr_payroll_private.structure_at_privat"/>
<field name="condition_select">python</field>
<field name="condition_python">result = 'ATP_UEL_TAGE' in inputs and inputs['ATP_UEL_TAGE'].amount</field>
<field name="l10n_at_bemessung">laufend</field>
<field name="l10n_at_sv_verlaengerung">urlaubsersatz</field>
<field name="amount_select">code</field>
<field name="amount_python_compute"><![CDATA[
# § 10 UrlG (lb-url-16): Tageswert = Monatsentgelt / Divisor
# (26 Werktage / 22 Arbeitstage); keine Rundung auf ganze Tage.
result = payslip._at_kv_gehalt_basis() / payslip._at_uel_divisor()
result_qty = inputs['ATP_UEL_TAGE'].amount
]]></field>
</record>
<!-- Urlaubsersatzleistung — Sonderzahlungsanteil (§ 67 Abs 8 lit d). -->
<record id="rule_url_ersatz_sz" model="hr.salary.rule">
<field name="name">Urlaubsersatzleistung — Sonderzahlungsanteil (§ 67 Abs 8 lit d)</field>
<field name="code">URL_ERSATZ_SZ</field>
<field name="sequence" eval="26"/>
<field name="category_id" ref="l10n_at_hr_payroll_private.category_atp_brutto"/>
<field name="struct_id" ref="l10n_at_hr_payroll_private.structure_at_privat"/>
<field name="condition_select">python</field>
<field name="condition_python">result = 'ATP_UEL_TAGE' in inputs and inputs['ATP_UEL_TAGE'].amount</field>
<field name="l10n_at_bemessung">sonstig</field>
<field name="amount_select">code</field>
<field name="amount_python_compute"><![CDATA[
# SZ-Anteil der Urlaubsentgelt-Basis (lb-url-16): 2 Sonderzahlungen
# à Monatsentgelt pro Jahr → je Monat 2 × SZ-Basis/12, als Tageswert
# (auch bei bereits überaliquot bezahlten SZ).
result = payslip._at_uel_sz_tageswert()
result_qty = inputs['ATP_UEL_TAGE'].amount
]]></field>
</record>
<!-- Abgangsentschädigung (§ 67 Abs 8 lit b — Kündigungsentschädigung). -->
<record id="rule_abgang" model="hr.salary.rule">
<field name="name">Abgangsentschädigung (§ 67 Abs 8 lit b)</field>
<field name="code">ABGANG</field>
<field name="sequence" eval="29"/>
<field name="category_id" ref="l10n_at_hr_payroll_private.category_atp_brutto"/>
<field name="struct_id" ref="l10n_at_hr_payroll_private.structure_at_privat"/>
<field name="condition_select">python</field>
<field name="condition_python">result = 'ATP_ABGANG' in inputs and inputs['ATP_ABGANG'].amount</field>
<field name="l10n_at_bemessung">neutral</field>
<field name="l10n_at_sv_art">laufend</field>
<field name="l10n_at_s67_fuenftel" eval="True"/>
<field name="l10n_at_sv_verlaengerung">abgang</field>
<field name="amount_select">code</field>
<field name="amount_python_compute"><![CDATA[
result = inputs['ATP_ABGANG'].amount
]]></field>
</record>
<!-- Zeitguthaben an Normalarbeitszeit (§ 19e Abs 2 AZG: + 50 %). -->
<record id="rule_zeit_guthaben" model="hr.salary.rule">
<field name="name">Zeitguthaben Normalarbeitszeit + 50 % (§ 19e Abs 2 AZG)</field>
<field name="code">ZEIT_GUTHABEN</field>
<field name="sequence" eval="27"/>
<field name="category_id" ref="l10n_at_hr_payroll_private.category_atp_brutto"/>
<field name="struct_id" ref="l10n_at_hr_payroll_private.structure_at_privat"/>
<field name="condition_select">python</field>
<field name="condition_python">result = 'ATP_ZEIT_GUTHABEN' in inputs and inputs['ATP_ZEIT_GUTHABEN'].amount</field>
<field name="l10n_at_bemessung">laufend</field>
<field name="amount_select">code</field>
<field name="amount_python_compute"><![CDATA[
# § 19e Abs 2 AZG: Guthaben an Normalarbeitszeit + 50 % Zuschlag,
# bemessen mit der Normalstunde (lb-end-10); der Zuschlag ist KEIN
# Überstundenzuschlag iSd § 68 EStG (kein Freibetrags-Flag).
result = payslip._at_grundstunde_norm() * 1.5
result_qty = inputs['ATP_ZEIT_GUTHABEN'].amount
]]></field>
</record>
<!-- Zeitschulden (Abrechnung mit der aktuellen Normalstunde). -->
<record id="rule_zeit_schulden" model="hr.salary.rule">
<field name="name">Zeitschulden-Abrechnung (negative Zeile)</field>
<field name="code">ZEIT_SCHULDEN</field>
<field name="sequence" eval="28"/>
<field name="category_id" ref="l10n_at_hr_payroll_private.category_atp_brutto"/>
<field name="struct_id" ref="l10n_at_hr_payroll_private.structure_at_privat"/>
<field name="condition_select">python</field>
<field name="condition_python">result = 'ATP_ZEIT_SCHULDEN' in inputs and inputs['ATP_ZEIT_SCHULDEN'].amount</field>
<field name="l10n_at_bemessung">laufend</field>
<field name="amount_select">code</field>
<field name="amount_python_compute"><![CDATA[
# Abrechnung offener Zeitschulden mit der aktuellen Normalstunde
# (lb-end-08); negative laufende Bezugszeile.
result = -payslip._at_grundstunde_norm()
result_qty = inputs['ATP_ZEIT_SCHULDEN'].amount
]]></field>
</record>
<!-- Aufrechnung offener Vorschüsse: Rückverrechnung vom Netto
NACH SV und Lohnsteuer (§ 293 Abs 3 EO, lb-end-06) — neutral,
ohne SV-/LSt-/LNK-Wirkung. -->
<record id="rule_end_abrechnung_vorschuss" model="hr.salary.rule">
<field name="name">Aufrechnung offener Vorschuss (Netto)</field>
<field name="code">END_ABRECHNUNG_VORSCHUSS</field>
<field name="sequence" eval="80"/>
<field name="category_id" ref="l10n_at_hr_payroll_private.category_atp_abzug"/>
<field name="struct_id" ref="l10n_at_hr_payroll_private.structure_at_privat"/>
<field name="condition_select">python</field>
<field name="condition_python">result = 'ATP_ABRECHNUNG_VORSCHUSS' in inputs and inputs['ATP_ABRECHNUNG_VORSCHUSS'].amount</field>
<field name="l10n_at_bemessung">neutral</field>
<field name="l10n_at_sv_art">keine</field>
<field name="amount_select">code</field>
<field name="amount_python_compute"><![CDATA[
result = inputs['ATP_ABRECHNUNG_VORSCHUSS'].amount
]]></field>
</record>
</odoo>
@@ -0,0 +1,95 @@
# Endabrechnungs-Zyklus — Rechenfälle AP14-A
Stand: 2026-09. AP14-A bildet die Beendigungs-Abrechnung auf der
privaten Structure ab: Urlaubsersatzleistung (UEL), Abgangsentschädigung,
Aufrechnung offener Vorschüsse und Zeitguthaben/-schulden. Grundlagen
sind am Originalwortlaut verifiziert (`.ris/`, Stand 2026-09-14):
§ 11 Abs 2 ASVG, § 67 Abs 8 lit b und lit d EStG, § 19e Abs 2 AZG,
§ 41 Abs 4 FLAG.
## Rechts- und Systemgrenzen
- **UEL (§ 10 UrlG, § 67 Abs 8 lit d EStG):** Zweikomponenten — der
laufende Anteil läuft als laufender Bezug durch den Tarif des
Austrittsmonats und erhöht das Jahressechstel (nativ über
`bemessung='laufend'`); der Sonderzahlungsanteil läuft als sonstiger
Bezug durch das 6 %-Band (Freibetrag 620 €, Freigrenze 2.615 € im
Kern). Nur der laufende Anteil verlängert die Pflichtversicherung
(§ 11 Abs 2 ASVG). Tagewert-Divisoren 26 (Werktage) / 22 (5-AT); die
SV-Tage-Umrechnung (je volle Woche à N AT + 7N KT, nur ganze
Blöcke) ist Verwaltungspraxis (lb-url-15), nicht Gesetzeswortlaut.
- **Abgangsentschädigung (§ 67 Abs 8 lit b):** nach Abzug der §-62-
Beiträge ein Fünftel steuerfrei, gedeckelt mit einem Fünftel des
Neunfachen der monatlichen HBG (2026: 6.930 × 9/5 = **12.474 €**);
der Rest läuft tarifisch im Zuflussmonat — Abs 2 (Sechstel) ist nicht
anzuwenden. SV-pflichtig mit Umlegung auf den Kündigungsfrist-
Zeitraum; voll lohnnebenkostenpflichtig (FLAG § 41 Abs 4 nennt nur
§ 67 Abs 3 und 6).
- **Sequentielle Verlängerungsfenster:** Gebühren sowohl eine
Kündigungsentschädigung als auch eine UEL, ist zur Bestimmung des
Zeitraumes zunächst die Kündigungsentschädigung und im Anschluss die
Ersatzleistung heranzuziehen (§ 11 Abs 2 ASVG) — die SV-Basen werden
fensterweise auf die Monate verteilt, Standardanteile bleiben auf
die volle monatliche HBG gedeckelt, Verlängerungsanteile auf den
Tageswert (HBG × Tage/30).
- **Aufrechnung offener Vorschüsse (§ 293 Abs 3 EO):** Rückverrechnung
vom Netto nach SV und Lohnsteuer — zulässig auch gegen das
Existenzminimum; keine Abgabenberührung (lb-end-06).
- **Zeitguthaben (§ 19e Abs 2 AZG):** Guthaben an Normalarbeitszeit
+ 50 % Zuschlag, bemessen mit der Normalstunde; der Zuschlag ist
KEIN Überstundenzuschlag iSd § 68 EStG (lb-end-10). Entfall bei
unberechtigtem vorzeitigem Austritt (Eingabe weglassen); KV kann
abweichen.
## RF-END-1 — UEL-Zweikomponenten
Ende 15. 3., 11 offene AT à 120 € (2.640 € Gehalt / 22):
- `URL_ERSATZ_LFD` = 1.320 €: tarifisch im März, Sechstel +5.280 €
(annualisiert 60/15);
- `URL_ERSATZ_SZ` = 220 € (2 × 2.640/12 = 440/Monat → 20/Tag):
6 %-Band;
- SV: 1.320 € auf die Verlängerungsmonate (Tage-Schema), 220 € als
sonstige Beitragsgrundlage gegen Monats- und Jahres-HBGl.
## RF-END-2 — SV-Tage-Umrechnung (§ 11 Abs 2)
- 23 AT (5-AT-Woche) → +4×2 KT = **31 SV-Tage**;
- 12 WT (6-WT-Woche) → +2×1 KT = **14**;
- 6 AT (2-Tage-Teilzeitwoche) → +3×5 KT = **21** (lb-url-15-Beispiel).
- Ende 15. 3., 31 SV-Tage → Verlängerung 16. 3.15. 4.; UEL-lfd-Basis
tageproportional (März 16/31, April 15/31), Tageswert-Deckel je
Verlängerungsmonat.
## RF-END-3 — Abgangsentschädigung (Fünftelregelung)
9.000 € Entschädigung, 42 Tage Kündigungsfrist, DN-Satz s:
- Bemessung 9.000 × (1 s/100); davon 1/5 steuerfrei (≤ 12.474 €);
4/5 tarifisch im Zuflussmonat, ohne Sechstelerhöhung;
- SV: Umlegung 16. 3.26. 4. (Kündigungsfrist zuerst);
- LNK: volle 9.000 € in der Basis (§ 41 Abs 3 FLAG).
## RF-END-4 — Aufrechnung
2.000 € offener Vorschuss: `END_ABRECHNUNG_VORSCHUSS` mindert nur das
Netto; LSt-, SV- und LNK-Basen unverändert.
## RF-END-5 — Zeitguthaben/-schulden
10 h Normalarbeitszeit-Guthaben × 1,5 + 5 h Zeitschulden × (1):
beides laufender Bezug, kein §-68-Freibetrag.
## Offene Punkte (dokumentiert, AP14-B/AP15)
- **Vergleich (§ 67 Abs 8 lit a)** und **freiwillige Abfertigung/
Abfindung (§ 67 Abs 6 6 %-Bänder)** → AP15 mit der Abfertigung-Alt-
Günstiger-Maschine (dort auch rollierende 12-Monats-Basis und
Dienstzeit-Nachweise).
- Prämien-Beendigung (§ 16 AngG-Aliquotierung, Rollung) — PRAM/SZ-Regeln
vorhanden; Feinschliff AP14-B.
- Meldewesen-Schnittstellen: „Ende des Entgeltanspruches" in mBGM/
Abmeldung (GP7), gesonderter Lohnzettel bei Zahlung außerhalb des
Beendigungsmonats (LStR 2002 Rz 911a).
- Der §-62-Abzug des Fünftelpfades ist über den effektiven DN-Satz
approximiert (HBG-Caps der Verlängerungsmonate laufen im SV-Pfad).
@@ -20,3 +20,4 @@ from . import pfaendung_report
from . import sachbezuege_private
from . import reisekosten
from . import pendler
from . import endabrechnung
@@ -0,0 +1,315 @@
# Part of the odoo-at-payroll project. License: LGPL-3.
"""AP14-A: Endabrechnungs-Zyklus — Urlaubsersatzleistung (UEL),
Abgangsentschädigung (§ 67 Abs 8 lit b), Aufrechnung offener Vorschüsse
und Zeitguthaben/-schulden (§ 19e AZG).
Am Originalwortlaut verifiziert (.ris/, Stand 2026-09-14):
- § 11 Abs 2 ASVG: die Pflichtversicherung besteht für die Zeit des
Bezuges einer Ersatzleistung für Urlaubsentgelt (und einer
Kündigungsentschädigung) weiter; gebühren beide, ist zur Bestimmung
des Zeitraumes ZUERST die Kündigungsentschädigung und im Anschluss
die Ersatzleistung heranzuziehen (sequentielle Verlängerungsfenster).
Die 6-WT/5-AT-Tage-Umrechnung ist Verwaltungspraxis (lb-url-15),
nicht Gesetzeswortlaut.
- § 67 Abs 8 lit d EStG: UEL „soweit sie laufenden Arbeitslohn
betreffen, als laufender Arbeitslohn, soweit sie sonstige Bezüge
betreffen, als sonstiger Bezug im Kalendermonat der Zahlung" — die
Zweikomponenten-Abbildung läuft nativ über l10n_at_bemessung.
- § 67 Abs 8 lit b EStG (Kündigungs-/Abgangsentschädigung): nach
Abzug der §-62-Beiträge ist ein Fünftel steuerfrei zu belassen,
höchstens ein Fünftel des Neunfachen der monatlichen HBG; der Rest
läuft tarifisch im Zuflussmonat (Abs 10), Abs 2 (Sechstel) ist
NICHT anzuwenden. LNK-pflichtig (FLAG § 41 Abs 4 nennt nur
§ 67 Abs 3 und 6) und SV-pflichtig (keine §-49-Abs-3-Z-7-Befreiung)
mit Umlegung auf den Kündigungsfrist-Zeitraum.
- § 19e Abs 2 AZG: Guthaben an Normalarbeitszeit + 50 % Zuschlag
(Entfall bei unberechtigtem vorzeitigem Austritt; KV kann
abweichend regeln).
"""
from datetime import timedelta
from odoo import fields, models
class HrSalaryRule(models.Model):
_inherit = 'hr.salary.rule'
l10n_at_sv_verlaengerung = fields.Selection(
[('keine', 'Keine SV-Verlängerung (§ 11 Abs 2 ASVG)'),
('urlaubsersatz', 'UEL-Tage-Schema (Urlaubsersatzleistung)'),
('abgang', 'Kündigungsfrist (Abgangsentschädigung)')],
string='AT SV-Verlängerung', default='keine',
help='Verteilt die laufende SV-Basis dieser Regel auf die '
'Pflichtversicherungs-Verlängerungsmonate nach § 11 Abs 2 '
'ASVG statt auf den Abrechnungsmonat: Abgangsentschädigung '
'zuerst (Kündigungsfrist), UEL anschließend (Tage-Schema '
'lb-url-15).')
l10n_at_s67_fuenftel = fields.Boolean(
string='AT § 67 Abs 8 lit a/b (Fünftelregelung)',
help='LSt-Pfad der Vergleichs-/Kündigungsentschädigungen: nach '
'Abzug der §-62-Beiträge ist ein Fünftel steuerfrei '
'(Deckel: ein Fünftel des Neunfachen der monatlichen HBG), '
'der Rest läuft tarifisch im Zuflussmonat — ohne '
'Jahressechstel-Erhöhung (Abs 2 nicht anzuwenden).')
class HrPayslip(models.Model):
_inherit = 'hr.payslip'
# ------------------------------------------------------------------
# SV-Tage-Umrechnung und Verlängerungsmonate (§ 11 Abs 2 ASVG)
# ------------------------------------------------------------------
def _at_end_arbeitsrechtliches_ende(self):
"""Arbeitsrechtliches Ende = letzter Arbeitstag; das SV-Ende
(„Ende des Entgeltanspruches") liegt darüber hinaus, wenn ein
Entgeltanspruch über das Ende hinaus läuft."""
self.ensure_one()
return self.version_id.contract_date_end or self.date_to
def _at_end_arbeitstage_woche(self):
"""Arbeitstage pro Woche des Vertragskalenders (Schema-Grundlage
der SV-Tage-Umrechnung: 5-AT-Woche, 6-WT-Woche, Teilzeitwoche)."""
self.ensure_one()
calendar = self.version_id.resource_calendar_id
days = {int(att.dayofweek) for att in calendar.attendance_ids} \
if calendar else set()
return len(days) or 5
def _at_end_sv_tage(self, tage):
"""SV-Tage aus abzurechnenden Urlaubstagen (Verwaltungspraxis
lb-url-15): je volle Woche à N Arbeitstage werden 7N
Kalendertage hinzugerechnet (5-AT-Woche +2, 6-WT-Woche +1,
2-Tage-Teilzeitwoche +5) — nur ganze Wochenblöcke."""
self.ensure_one()
at_woche = self._at_end_arbeitstage_woche()
blocks = int(tage // at_woche)
return tage + blocks * (7 - at_woche)
def _at_end_verlaengerungs_monate(self, sv_tage, start):
"""[(Monatsanfang, Tage im Monat)] eines Verlängerungsfensters
ab dem übergebenen Starttag (§ 11 Abs 2 ASVG)."""
self.ensure_one()
ergebnis = {}
rest = int(sv_tage)
tag = start
while rest > 0:
monatsende = (tag.replace(day=28) + timedelta(days=4)) \
.replace(day=1) - timedelta(days=1)
tage_dabei = min((monatsende - tag).days + 1, rest)
monat = tag.replace(day=1)
ergebnis[monat] = ergebnis.get(monat, 0) + tage_dabei
tag = monatsende + timedelta(days=1)
rest -= tage_dabei
return sorted(ergebnis.items())
def _at_end_rule_verlaengerung(self, rule, result_rules=None):
"""[(Monatsanfang, Tage, Anteil)] der Regel-Basis nach § 11
Abs 2: die Abgangsentschädigung belegt ZUERST die (fiktive)
Kündigungsfrist, die UEL SCHLIESSLICH die Urlaubstage — beide
Fenster laufen ab dem Tag nach dem arbeitsrechtlichen Ende
hintereinander (Gesetzeswortlaut: „zunächst die
Kündigungsentschädigung und im Anschluss die Ersatzleistung")."""
self.ensure_one()
start = self._at_end_arbeitsrechtliches_ende() + timedelta(days=1)
if rule.l10n_at_sv_verlaengerung == 'urlaubsersatz':
ffrist = self._at_input_amount('ATP_ABGANG_FFRIST_TAGE')
if ffrist:
start += timedelta(days=int(ffrist))
tage = self._at_end_sv_tage(self._at_uel_tage())
else:
ffrist = self._at_input_amount('ATP_ABGANG_FFRIST_TAGE')
tage = int(ffrist) if ffrist else 0
monate = self._at_end_verlaengerungs_monate(tage, start)
if not monate:
return []
gesamt = sum(tage for _monat, tage in monate)
return [(monat, tage, tage / float(gesamt))
for monat, tage in monate]
# ------------------------------------------------------------------
# Eingabe-Helfer
# ------------------------------------------------------------------
def _at_input_amount(self, code):
self.ensure_one()
linie = self.input_line_ids.filtered(
lambda i: i.input_type_id.code == code)
return linie[:1].amount or 0.0
# ------------------------------------------------------------------
# Urlaubsersatzleistung — § 67 Abs 8 lit d, lb-url-15/16
# ------------------------------------------------------------------
def _at_uel_tage(self):
"""Offene Urlaubstage (Arbeitstage; keine Rundung, lb-url-16)."""
self.ensure_one()
return self._at_input_amount('ATP_UEL_TAGE')
def _at_uel_divisor(self):
"""Tagewert-Divisor: /26 bei Werktags-, /22 bei 5-AT-Kalender
(lb-url-16)."""
self.ensure_one()
params = self._l10n_at_param('at_endabrechnung')
if self._at_end_arbeitstage_woche() >= 6:
return float(params['divisor_werktage'])
return float(params['divisor_arbeitstage'])
def _at_uel_lfd(self):
"""UEL-laufender Anteil: Tage × Monatsentgelt / Divisor —
läuft als laufender Bezug durch den Tarif des Austrittsmonats
und erhöht das Jahressechstel (nativ über bemessung='laufend')."""
self.ensure_one()
return self._at_uel_tage() * self._at_kv_gehalt_basis() \
/ self._at_uel_divisor()
def _at_uel_sz_tageswert(self):
"""SZ-Anteil je Urlaubstag: 2 Sonderzahlungen à Monatsentgelt
pro Jahr (KV § 11) → je Monat 2 × SZ-Basis/12, als Tageswert
(auch bei bereits überaliquot bezahlten SZ, lb-url-16)."""
self.ensure_one()
sz_monat = 2.0 * self._at_sz_basis(self.date_to) / 12.0
return sz_monat / self._at_uel_divisor()
def _at_uel_sz(self):
"""UEL-Sonderzahlungsanteil: Tage × SZ-Tageswert."""
self.ensure_one()
return self._at_uel_tage() * self._at_uel_sz_tageswert()
# ------------------------------------------------------------------
# Abgangsentschädigung — § 67 Abs 8 lit b (Fünftelregelung)
# ------------------------------------------------------------------
def _at_fuenftel_regeln(self):
self.ensure_one()
return self.struct_id.rule_ids.filtered(
lambda rule: rule.l10n_at_s67_fuenftel)
def _at_fuenftel_total(self, result_rules=None):
self.ensure_one()
total = 0.0
for rule in self._at_fuenftel_regeln():
if result_rules is not None:
if rule.code in result_rules:
total += result_rules[rule.code]['total']
else:
linie = self.line_ids.filtered(lambda l: l.code == rule.code)
total += sum(linie.mapped('total'))
return total
def _at_fuenftel_tarif_basis(self, result_rules=None):
"""Tarifischer Teil (4/5) der Fünftel-Bezüge: Bemessung nach
Abzug der darauf entfallenden §-62-Beiträge (approximiert über
den effektiven DN-Satz der laufenden Basis; die HBG-Caps der
Verlängerungsmonate werden im SV-Pfad separat geführt); ein
Fünftel ist steuerfrei, gedeckelt mit einem Fünftel des
Neunfachen der monatlichen HBG (lb-end-21: 2026 6.930 × 9/5
= 12.474)."""
self.ensure_one()
total = self._at_fuenftel_total(result_rules)
if total <= 0:
return 0.0
satz = sum(self._l10n_at_sv_satz(
'dn', komponente,
staffel_basis=self._l10n_at_sv_basis_roh(result_rules))
for komponente in ('kv', 'pv', 'av', 'ak', 'wf'))
bemessung = max(total * (1.0 - satz / 100.0), 0.0)
deckel = float(self._l10n_at_param('at_sv_hbg_monatlich')) \
* 9.0 / 5.0
frei = min(bemessung / 5.0, deckel)
return bemessung - frei
# ------------------------------------------------------------------
# Engine-Anbindung: Tarif-Teil ohne Sechstel, LNK-Pflicht, SV-Umlegung
# ------------------------------------------------------------------
def _l10n_at_bemessung_laufen(self, result_rules=None):
basis = super()._l10n_at_bemessung_laufen(result_rules)
# § 67 Abs 8 lit b: 4/5 tarifisch im Zuflussmonat; die Regel
# selbst ist neutral und erhöht brutto('laufend') nicht → kein
# Sechstel-Effekt (Abs 2 nicht anzuwenden).
return basis + self._at_fuenftel_tarif_basis(result_rules)
def _at_lnk_basis(self, steuer, result_rules=None):
"""Upstream-Überschreibung (Muster account_posting_private;
Vorlage payslip_private._at_lnk_basis — bei Änderungen dort
gegen diesen Pin neu abgleichen): Kündigungsentschädigungen
sind Arbeitslöhne iSd § 41 Abs 3 FLAG und unterliegen NICHT
den Abs-4-Ausschlüssen (lit b nennt nur § 67 Abs 3 und 6) —
sie sind VOR der Freibetragsstaffel in die LNK-Basis
aufzunehmen."""
self.ensure_one()
basis = (self._l10n_at_brutto('laufend', result_rules)
+ self._l10n_at_brutto('sonstig', result_rules)
- self._l10n_at_st_frei_freie_laufen(result_rules))
for rule in self.struct_id.rule_ids:
if rule.l10n_at_s67_abs in ('abs3', 'abs6'):
if result_rules is not None and rule.code in result_rules:
basis -= result_rules[rule.code]['total']
elif result_rules is None:
linie = self.line_ids.filtered(
lambda l: l.code == rule.code)
basis -= sum(linie.mapped('total'))
basis += self._at_fuenftel_total(result_rules)
basis = max(basis, 0.0)
if steuer in ('flaf', 'dz'):
version = self.version_id
if (version.l10n_at_lnkp_frei_ab
and self.date_from >= version.l10n_at_lnkp_frei_ab):
return 0.0
freibetrag = self.env['hr.rule.parameter']._get_parameter_from_code(
'at_lnk_freibetrag', self.date_to, raise_if_not_found=False) or {}
grenze = float(freibetrag.get('grenze', 0.0))
if basis <= 0.0:
return 0.0
if basis <= grenze:
basis -= float(freibetrag.get('freibetrag', 0.0))
return max(basis, 0.0)
def _l10n_at_sv_basis_laufen_monate(self, result_rules=None):
"""Upstream-Überschreibung (Muster account_posting_private;
Vorlage l10n_at_hr_payroll.sozialversicherung — bei Odoo- oder
Kern-Upgrades gegen diesen Pin neu abgleichen): identischer
Ablauf wie der Kern, zusätzlich werden Regeln mit AT-SV-
Verlängerung tageproportional auf die Verlängerungsmonate
gelegt. Der Standardanteil jedes Monats bleibt auf die volle
monatliche HBG gedeckelt, der Verlängerungsanteil auf den
Tageswert (monatliche HBG × Verlängerungstage/30, lb-url-15);
die Fenster laufen sequenziell (Abgang zuerst, UEL danach)."""
self.ensure_one()
gruppen = {}
verlaengerung = {}
tage_pro_monat = {}
for rule in self.struct_id.rule_ids:
if self._l10n_at_sv_art(rule) != 'laufend':
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
if rule.l10n_at_sv_verlaengerung != 'keine':
for monat, tage, anteil in \
self._at_end_rule_verlaengerung(rule, result_rules):
verlaengerung[monat] = verlaengerung.get(monat, 0.0) \
+ werte['total'] * anteil
tage_pro_monat[monat] = \
tage_pro_monat.get(monat, 0) + tage
continue
monat = (rule.l10n_at_beitragsmonat or self.date_from) \
.replace(day=1)
gruppen[monat] = gruppen.get(monat, 0.0) + werte['total']
standard = self.date_from.replace(day=1)
if standard in gruppen:
gruppen[standard] -= self._l10n_at_sv49_freie_laufen(
result_rules)
ergebnis = []
for monat in sorted(set(gruppen) | set(verlaengerung)):
hbg = float(self._l10n_at_param('at_sv_hbg_monatlich', monat))
basis = min(max(gruppen.get(monat, 0.0), 0.0), hbg)
if monat in verlaengerung:
tageswert = hbg * tage_pro_monat[monat] / 30.0
basis += min(max(verlaengerung[monat], 0.0), tageswert)
ergebnis.append((monat, basis))
return ergebnis
@@ -23,3 +23,4 @@ access_l10n_at_payroll_pfaendung_user,L10n AT Payroll Pfaendung User,model_l10n_
access_l10n_at_payroll_pfaendung_manager,L10n AT Payroll Pfaendung Manager,model_l10n_at_payroll_pfaendung,hr_payroll.group_hr_payroll_manager,1,1,1,1
access_l10n_at_payroll_pendler_erklaerung_user,L10n AT Payroll Pendlererklaerung User,model_l10n_at_payroll_pendler_erklaerung,hr_payroll.group_hr_payroll_user,1,1,1,0
access_l10n_at_payroll_pendler_erklaerung_manager,L10n AT Payroll Pendlererklaerung Manager,model_l10n_at_payroll_pendler_erklaerung,hr_payroll.group_hr_payroll_manager,1,1,1,1
access_l10n_at_payroll_endabrechnung_wizard,L10n AT Payroll Endabrechnung Wizard,model_l10n_at_payroll_endabrechnung_wizard,hr_payroll.group_hr_payroll_user,1,1,1,1
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
23 access_l10n_at_payroll_pfaendung_manager L10n AT Payroll Pfaendung Manager model_l10n_at_payroll_pfaendung hr_payroll.group_hr_payroll_manager 1 1 1 1
24 access_l10n_at_payroll_pendler_erklaerung_user L10n AT Payroll Pendlererklaerung User model_l10n_at_payroll_pendler_erklaerung hr_payroll.group_hr_payroll_user 1 1 1 0
25 access_l10n_at_payroll_pendler_erklaerung_manager L10n AT Payroll Pendlererklaerung Manager model_l10n_at_payroll_pendler_erklaerung hr_payroll.group_hr_payroll_manager 1 1 1 1
26 access_l10n_at_payroll_endabrechnung_wizard L10n AT Payroll Endabrechnung Wizard model_l10n_at_payroll_endabrechnung_wizard hr_payroll.group_hr_payroll_user 1 1 1 1
@@ -17,3 +17,4 @@ from . import test_sachbezuege_private
from . import test_sachbezuege2_private
from . import test_reisekosten_private
from . import test_pendler_private
from . import test_endabrechnung_private
@@ -0,0 +1,298 @@
# Part of the odoo-at-payroll project. License: LGPL-3.
"""AP14-A: Endabrechnungs-Zyklus — UEL-Zweikomponenten (§ 67 Abs 8
lit d), SV-Verlängerung (§ 11 Abs 2 ASVG, sequentielle Fenster),
Abgangsentschädigung (§ 67 Abs 8 lit b Fünftel), Aufrechnung offener
Vorschüsse (§ 293 Abs 3 EO) und Zeitguthaben (§ 19e Abs 2 AZG)."""
from datetime import date, timedelta
from odoo.tests import TransactionCase, tagged
@tagged('post_install', '-at_install')
class EndabrechnungPrivateTest(TransactionCase):
def setUp(self):
super().setUp()
self.structure = self.env.ref(
'l10n_at_hr_payroll_private.structure_at_privat')
self.kv2203 = self.env.ref('l10n_at_hr_payroll_private.kv_si_2203')
self.gruppe_ii = self.env.ref(
'l10n_at_hr_payroll_private.kv2203_grp_ii')
self.calendar = self.env['resource.calendar'].create({
'name': 'AT Test End 40h Mo-Fr',
'tz': 'Europe/Vienna',
'hours_per_day': 8,
'attendance_ids': [
(0, 0, {'name': 'Tag %d' % weekday,
'dayofweek': str(weekday),
'hour_from': 8.0, 'hour_to': 16.0})
for weekday in range(5)
],
})
self.env.company.resource_calendar_id = self.calendar
def _calendar(self, weekdays):
return self.env['resource.calendar'].create({
'name': 'AT Test End %dTage' % len(weekdays),
'tz': 'Europe/Vienna',
'hours_per_day': 8,
'attendance_ids': [
(0, 0, {'name': 'Tag %d' % weekday,
'dayofweek': str(weekday),
'hour_from': 8.0, 'hour_to': 16.0})
for weekday in weekdays
],
})
def _employee(self, ueberzahlung=2640.0,
ende=date(2026, 3, 15), calendar=None):
employee = self.env['hr.employee'].create(
{'name': 'Test End privat'})
version = self.env['hr.version'].search(
[('employee_id', '=', employee.id)], limit=1)
if not version:
version = self.env['hr.version'].create({
'employee_id': employee.id, 'date_start': date(2020, 1, 1)})
version.write({
'contract_date_start': date(2020, 1, 1),
'contract_date_end': ende,
'ueberzahlung': ueberzahlung,
'kv_id': self.kv2203.id,
'kv_gruppe_id': self.gruppe_ii.id,
'erfahrungsstufe': 1,
'resource_calendar_id': (calendar or self.calendar).id,
})
return employee
def _slip(self, employee, monat=3, jahr=2026):
erster = date(jahr, monat, 1)
naechster = (erster + timedelta(days=32)).replace(day=1)
slip = self.env['hr.payslip'].create({
'name': 'Test-End %d/%d' % (monat, jahr),
'employee_id': employee.id,
'struct_id': self.structure.id,
'date_from': erster,
'date_to': naechster - timedelta(days=1),
})
slip.compute_sheet()
return slip
def _input(self, slip, code, amount):
typ = self.env['hr.payslip.input.type'].search(
[('code', '=', code)], limit=1)
self.env['hr.payslip.input'].create({
'payslip_id': slip.id,
'input_type_id': typ.id,
'amount': amount,
})
slip.compute_sheet()
def _total(self, slip, code):
return sum(slip.line_ids.filtered(
lambda line: line.code == code).mapped('total'))
def _basis_monate(self, slip):
return dict(slip._l10n_at_sv_basis_laufen_monate())
# ------------------------------------------------------------------
# RF-END-1: UEL-Zweikomponenten (§ 67 Abs 8 lit d)
# ------------------------------------------------------------------
def test_01_uel_zwei_komponenten(self):
"""11 offene AT à 120 € (2.640/22): UEL-lfd 1.320 € läuft
tarifisch und sechstelerhöhend; UEL-SZ 220 € (2×2.640/12/22)
als sonstiger Bezug; nur der laufende Anteil verlängert die
Pflichtversicherung."""
emp_mit = self._employee()
emp_ohne = self._employee()
slip_mit = self._slip(emp_mit)
slip_ohne = self._slip(emp_ohne)
self._input(slip_mit, 'ATP_UEL_TAGE', 11)
self.assertAlmostEqual(
self._total(slip_mit, 'URL_ERSATZ_LFD'), 1320.0, places=2)
self.assertAlmostEqual(
self._total(slip_mit, 'URL_ERSATZ_SZ'), 220.0, places=2)
svdn_diff = (slip_mit._l10n_at_svdn_gesamt()
- slip_ohne._l10n_at_svdn_gesamt())
self.assertAlmostEqual(
slip_mit._l10n_at_bemessung_laufen()
- slip_ohne._l10n_at_bemessung_laufen() + svdn_diff,
1320.0, places=2)
# Sechstel wächst um den annualisierten laufenden Anteil
# (60/Lohnsteuertage × 1.320; Teilmonat März: 15 Tage).
self.assertAlmostEqual(
slip_mit._l10n_at_sechstel() - slip_ohne._l10n_at_sechstel(),
1320.0 * 60.0 / 15.0, places=2)
# SZ-Bemessung enthält den UEL-SZ-Anteil (SV-DN nach § 67
# Abs 12 abziehen, um den Brutto-Anteil zu isolieren)
svdn_sz_diff = (slip_mit._l10n_at_svdn_sz()
- slip_ohne._l10n_at_svdn_sz())
self.assertAlmostEqual(
slip_mit._l10n_at_sz_bemessung()
- slip_ohne._l10n_at_sz_bemessung() + svdn_sz_diff,
220.0, places=2)
# ------------------------------------------------------------------
# RF-END-2: SV-Tage-Umrechnung und Verlängerungsmonate (§ 11 Abs 2)
# ------------------------------------------------------------------
def test_02_sv_tage_und_verlaengerung(self):
"""Schema je voller Woche à N AT + (7N) KT: 23 AT (5-Woche)
→ 31 SV-Tage; 12 WT (6-Woche) → 14; 6 AT (2-Tage-Woche) → 21
(lb-url-15). Ende 15. 3.: Verlängerung 16. 3.15. 4. — die
UEL-lfd-Basis liegt tageproportional auf den Monaten."""
emp = self._employee()
slip = self._slip(emp)
self.assertEqual(slip._at_end_sv_tage(23), 31)
self.assertEqual(slip._at_end_sv_tage(10), 14)
emp_6 = self._employee(calendar=self._calendar(range(6)))
self.assertEqual(self._slip(emp_6)._at_end_sv_tage(12), 14)
emp_2 = self._employee(calendar=self._calendar([0, 3]))
self.assertEqual(self._slip(emp_2)._at_end_sv_tage(6), 21)
self.assertEqual(
slip._at_end_verlaengerungs_monate(
31, date(2026, 3, 16)),
[(date(2026, 3, 1), 16), (date(2026, 4, 1), 15)])
self._input(slip, 'ATP_UEL_TAGE', 23)
basis = self._basis_monate(slip)
self.assertAlmostEqual(
basis[date(2026, 4, 1)], 2760.0 * 15.0 / 31.0, places=2)
# März: KVGEO-Standardanteil + UEL-März-Anteil
self.assertAlmostEqual(
basis[date(2026, 3, 1)] - 2760.0 * 16.0 / 31.0,
2640.0 * 10.0 / 22.0, places=2)
# ------------------------------------------------------------------
# RF-END-3: Abgangsentschädigung (§ 67 Abs 8 lit b, Fünftel)
# ------------------------------------------------------------------
def test_03_abgang_fuenftel(self):
"""9.000 € Abgangsentschädigung: Fünftel-LSt (1/5 steuerfrei
nach §-62-Abzug, Deckel 12.474 €), 4/5 tarifisch OHNE
Sechstelerhöhung; SV-Umlegung auf die 42 Tage Kündigungsfrist;
voll LNK-pflichtig."""
emp_mit = self._employee(ueberzahlung=3000.0)
emp_ohne = self._employee(ueberzahlung=3000.0)
slip_mit = self._slip(emp_mit)
slip_ohne = self._slip(emp_ohne)
self._input(slip_mit, 'ATP_ABGANG', 9000.0)
self._input(slip_mit, 'ATP_ABGANG_FFRIST_TAGE', 42)
self.assertAlmostEqual(self._total(slip_mit, 'ABGANG'), 9000.0,
places=2)
# Sechstel unverändert (Abs 2 nicht anzuwenden)
self.assertAlmostEqual(
slip_mit._l10n_at_sechstel() - slip_ohne._l10n_at_sechstel(),
0.0, places=2)
# Tarifischer Teil: 9.000 minus §-62-Abzug, 1/5 frei
satz = sum(slip_mit._l10n_at_sv_satz(
'dn', k, staffel_basis=slip_mit._l10n_at_sv_basis_roh())
for k in ('kv', 'pv', 'av', 'ak', 'wf'))
bemessung = 9000.0 * (1.0 - satz / 100.0)
deckel = float(slip_mit._l10n_at_param('at_sv_hbg_monatlich')) \
* 9.0 / 5.0
erwartet = bemessung - min(bemessung / 5.0, deckel)
self.assertAlmostEqual(
slip_mit._at_fuenftel_tarif_basis(), erwartet, places=2)
svdn_diff = (slip_mit._l10n_at_svdn_gesamt()
- slip_ohne._l10n_at_svdn_gesamt())
self.assertAlmostEqual(
slip_mit._l10n_at_bemessung_laufen()
- slip_ohne._l10n_at_bemessung_laufen() + svdn_diff,
erwartet, places=2)
# LNK-Basis um die volle Entschädigung erhöht
self.assertAlmostEqual(
slip_mit._at_lnk_basis('flaf')
- slip_ohne._at_lnk_basis('flaf'), 9000.0, places=2)
# SV-Umlegung: 16. 3.26. 4. (Kündigungsfrist zuerst); im März
# liegt zusätzlich der aliquote KVGEO-Standardanteil
basis = self._basis_monate(slip_mit)
self.assertAlmostEqual(
basis[date(2026, 4, 1)], 9000.0 * 26.0 / 42.0, places=2)
self.assertAlmostEqual(
basis[date(2026, 3, 1)] - 3000.0 * 10.0 / 22.0,
9000.0 * 16.0 / 42.0, places=2)
# ------------------------------------------------------------------
# RF-END-4: Aufrechnung offener Vorschüsse (§ 293 Abs 3 EO)
# ------------------------------------------------------------------
def test_04_aufrechnung_vorschuss(self):
"""2.000 € offener Vorschuss wird vom Netto abgezogen — ohne
jede Wirkung auf LSt-, SV- oder LNK-Basen (lb-end-06)."""
emp_mit = self._employee()
emp_ohne = self._employee()
slip_mit = self._slip(emp_mit)
slip_ohne = self._slip(emp_ohne)
self._input(slip_mit, 'ATP_ABRECHNUNG_VORSCHUSS', 2000.0)
self.assertAlmostEqual(self._total(slip_mit, 'END_ABRECHNUNG_VORSCHUSS'),
2000.0, places=2)
self.assertAlmostEqual(
slip_mit._l10n_at_bemessung_laufen()
- slip_ohne._l10n_at_bemessung_laufen(), 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(
self._total(slip_mit, 'NET') - self._total(slip_ohne, 'NET'),
-2000.0, places=2)
# ------------------------------------------------------------------
# RF-END-5: Zeitguthaben (§ 19e Abs 2 AZG) und Zeitschulden
# ------------------------------------------------------------------
def test_05_zeitguthaben_und_schulden(self):
"""10 h Normalarbeitszeit-Guthaben + 50 % Zuschlag, 5 h
Zeitschulden negativ — beides laufender Bezug, KEIN §-68-
Freibetrag (lb-end-10)."""
emp = self._employee(ueberzahlung=3000.0)
slip = self._slip(emp)
self._input(slip, 'ATP_ZEIT_GUTHABEN', 10.0)
self._input(slip, 'ATP_ZEIT_SCHULDEN', 5.0)
stunde = slip._at_grundstunde_norm()
self.assertAlmostEqual(
self._total(slip, 'ZEIT_GUTHABEN'), 10.0 * stunde * 1.5,
places=2)
self.assertAlmostEqual(
self._total(slip, 'ZEIT_SCHULDEN'), -5.0 * stunde, places=2)
regel = slip.struct_id.rule_ids.filtered(
lambda rule: rule.code == 'ZEIT_GUTHABEN')
self.assertEqual(regel.l10n_at_steuerbefreiung_68, 'keine')
# ------------------------------------------------------------------
# RF-END-6: Wizard legt den Endabrechnungs-Lauf vorbefüllt an
# ------------------------------------------------------------------
def test_06_wizard(self):
"""Der Wizard sammelt UEL, Abgang, Zeitguthaben und Vorschuss
und erzeugt den Payslip mit vorbefüllten Eingaben."""
emp = self._employee()
# Version auf die private Structure-Type binden, damit der
# Wizard über version.struct_id die richtige Struktur findet.
emp.version_id.write({'structure_type_id': self.env.ref(
'l10n_at_hr_payroll_private.structure_type_at_privat').id})
wizard = self.env['l10n.at.payroll.endabrechnung.wizard'].create({
'employee_id': emp.id,
'austrittsdatum': date(2026, 3, 15),
'beendigungsart': 'einvernehmlich',
'uel_tage': 11.0,
'abgang_betrag': 9000.0,
'abgang_ffrist_tage': 42.0,
'zeit_guthaben_stunden': 10.0,
'vorschuss_saldo': 500.0,
})
ergebnis = wizard.action_payslip()
slip = self.env['hr.payslip'].browse(ergebnis['res_id'])
codes = set(slip.input_line_ids.mapped(
lambda line: line.input_type_id.code))
self.assertIn('ATP_UEL_TAGE', codes)
self.assertIn('ATP_ABGANG', codes)
self.assertIn('ATP_ABRECHNUNG_VORSCHUSS', codes)
self.assertAlmostEqual(
self._total(slip, 'URL_ERSATZ_LFD'), 1320.0, places=2)
self.assertAlmostEqual(self._total(slip, 'ABGANG'), 9000.0,
places=2)
@@ -0,0 +1,58 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- AP14-A: Endabrechnungs-Wizard (Beendigung) — sammelt UEL, -->
<!-- Abgangsentschädigung, Zeitguthaben/-schulden und offene -->
<!-- Vorschüsse und legt den Endabrechnungs-Lauf vorbefüllt an. -->
<record id="endabrechnung_wizard_form_view" model="ir.ui.view">
<field name="name">l10n.at.payroll.endabrechnung.wizard.form</field>
<field name="model">l10n.at.payroll.endabrechnung.wizard</field>
<field name="arch" type="xml">
<form string="Endabrechnung (Beendigung)">
<group>
<group string="Beendigung">
<field name="employee_id"/>
<field name="austrittsdatum"/>
<field name="beendigungsart"/>
</group>
<group string="Offene Posten">
<field name="uel_tage"/>
<field name="zeit_guthaben_stunden"/>
<field name="zeit_schulden_stunden"/>
<field name="abgang_betrag"/>
<field name="abgang_ffrist_tage"
invisible="not abgang_betrag"/>
<field name="vorschuss_saldo"/>
</group>
</group>
<group string="Vorschau (§ 10 UrlG, § 11 Abs 2 ASVG)">
<group>
<field name="uel_lfd_vorschau"/>
<field name="uel_sz_vorschau"/>
</group>
<group>
<field name="sv_tage_vorschau"/>
</group>
</group>
<footer>
<button name="action_payslip" type="object"
string="Endabrechnungs-Lauf anlegen"
class="btn-primary"/>
<button string="Abbrechen" special="cancel"
class="btn-secondary"/>
</footer>
</form>
</field>
</record>
<record id="action_endabrechnung_wizard" model="ir.actions.act_window">
<field name="name">Endabrechnung (Beendigung)</field>
<field name="res_model">l10n.at.payroll.endabrechnung.wizard</field>
<field name="view_mode">form</field>
<field name="target">new</field>
</record>
<menuitem id="menu_l10n_at_payroll_endabrechnung"
name="Endabrechnung (Beendigung)"
parent="hr_work_entry_enterprise.menu_hr_payroll_root"
action="action_endabrechnung_wizard" sequence="42"/>
</odoo>
@@ -1,3 +1,4 @@
# Part of the odoo-at-payroll project. License: LGPL-3.
from . import kv_katalog_import
from . import bv_endabrechnung
from . import bv_endabrechnung
from . import endabrechnung
@@ -0,0 +1,176 @@
# Part of the odoo-at-payroll project. License: LGPL-3.
"""AP14-A: Endabrechnungs-Wizard — sammelt die offenen Posten der
Beendigungs-Abrechnung (Urlaubsersatzleistung, Abgangsentschädigung,
Zeitguthaben/-schulden, offene Vorschüsse) und legt den
Endabrechnungs-Lauf mit vorbefüllten Eingaben an. Reiner
Sammel-/Anlageprozess; die BV-Anwartschaft läuft über den eigenen
BV-Wizard (GP5), die Pfändung über die AP11-Maschine."""
from datetime import timedelta
from odoo import _, api, fields, models
class L10nAtEndabrechnungWizard(models.TransientModel):
_name = 'l10n.at.payroll.endabrechnung.wizard'
_description = 'Endabrechnung (Beendigung des Dienstverhältnisses)'
employee_id = fields.Many2one(
'hr.employee', string='Dienstnehmer:in', required=True)
austrittsdatum = fields.Date(
string='Austritt (Ende des Arbeitsverhältnisses)', required=True)
beendigungsart = fields.Selection(
[('ag_kuendigung', 'Kündigung durch den Arbeitgeber'),
('an_kuendigung', 'Kündigung durch den Dienstnehmer'),
('einvernehmlich', 'Einvernehmliche Auflösung'),
('berechtigter_austritt', 'Berechtigter vorzeitiger Austritt'),
('unberechtigter_austritt',
'Unberechtigter vorzeitiger Austritt '
'(keine UEL für die 5./6. Woche, § 10 Abs 2 UrlG; '
'kein Zeitguthaben-Zuschlag, § 19e Abs 2 AZG)'),
('verschuldete_entlassung', 'Verschuldete Entlassung'),
('unverschuldete_entlassung', 'Unverschuldete Entlassung'),
('befristung', 'Zeitablauf (befristetes Dienstverhältnis)'),
('tod', 'Tod (UEL an die Erben, § 10 Abs 5 UrlG)')],
string='Beendigungsart', required=True)
uel_tage = fields.Float(
string='Offene Urlaubstage (Arbeitstage)',
help='Resturlaub früherer Urlaubsjahre zur Gänze plus aliquoter '
'Anspruch des laufenden Urlaubsjahres abzüglich verbrauchter '
'Tage, ohne Rundung (§ 10 UrlG, lb-url-16). Für '
'unberechtigten vorzeitigen Austritt sind die Tage der 5. '
'und 6. Urlaubswoche des laufenden Jahres herauszurechnen.')
abgang_betrag = fields.Monetary(
string='Abgangsentschädigung (Brutto, § 67 Abs 8 lit b EStG)',
currency_field='currency_id',
help='Entschädigung für den Verzicht auf die Arbeitsleistung '
'während der (fiktiven) Kündigungsfrist — LSt: Fünftelregelung; '
'SV: Umlegung auf den Kündigungsfrist-Zeitraum (§ 11 Abs 2 '
'ASVG); voll lohnnebenkostenpflichtig.')
abgang_ffrist_tage = fields.Float(
string='Kündigungsfrist (Kalendertage)',
help='Kalendertage der (fiktiven) Kündigungsfrist, auf die die '
'pauschalierte Abgangsentschädigung sv-rechtlich umzulegen '
'ist (§ 11 Abs 2 ASVG).')
zeit_guthaben_stunden = fields.Float(
string='Zeitguthaben an Normalarbeitszeit (Stunden)',
help='Offenes Guthaben an Normalarbeitszeit aus durchrechenbarem '
'Arbeitszeitmodell — Abgeltung mit 50 % Zuschlag (§ 19e '
'Abs 2 AZG); bei unberechtigtem vorzeitigem Austritt kein '
'Zuschlag.')
zeit_schulden_stunden = fields.Float(
string='Zeitschulden (Stunden)',
help='Offene Zeitschulden (negative Zeitguthaben), abgerechnet '
'mit der aktuellen Normalstunde (lb-end-08).')
vorschuss_saldo = fields.Monetary(
string='Offener Vorschuss-/Darlehenssaldo',
currency_field='currency_id',
help='Bei Beendigung zur Gänze fälliger, noch offener '
'Vorschusssaldo (lb-end-06) — Rückverrechnung vom Netto '
'nach SV und Lohnsteuer, ohne Abgabenberührung '
'(§ 293 Abs 3 EO).')
currency_id = fields.Many2one(
'res.currency', default=lambda self: self.env.company.currency_id)
uel_lfd_vorschau = fields.Monetary(
string='UEL laufender Anteil (Vorschau)',
currency_field='currency_id', compute='_compute_vorschau')
uel_sz_vorschau = fields.Monetary(
string='UEL Sonderzahlungsanteil (Vorschau)',
currency_field='currency_id', compute='_compute_vorschau')
sv_tage_vorschau = fields.Integer(
string='SV-Verlängerungstage (§ 11 Abs 2)',
compute='_compute_vorschau')
@api.onchange('employee_id')
def _onchange_employee(self):
for wizard in self:
if wizard.employee_id:
wizard.austrittsdatum = (
wizard.employee_id.version_id.contract_date_end
or fields.Date.today())
@api.depends('employee_id', 'austrittsdatum', 'uel_tage')
def _compute_vorschau(self):
for wizard in self:
wizard.uel_lfd_vorschau = 0.0
wizard.uel_sz_vorschau = 0.0
wizard.sv_tage_vorschau = 0
if not wizard.employee_id or not wizard.austrittsdatum:
continue
slip = wizard._vorschau_payslip()
wizard.uel_lfd_vorschau = slip._at_uel_lfd()
wizard.uel_sz_vorschau = slip._at_uel_sz()
wizard.sv_tage_vorschau = slip._at_end_sv_tage(
wizard.uel_tage)
def _vorschau_payslip(self):
"""Flüchtiger Payslip-Kontext für die Vorschau-Berechnung (kein
Speichern): trägt Austrittsmonat, Struktur und die Eingaben."""
self.ensure_one()
erster = self.austrittsdatum.replace(day=1)
letzter = (erster + timedelta(days=32)).replace(day=1) \
- timedelta(days=1)
struct = self.employee_id.version_id.structure_id
if not struct:
struct = self.env.ref(
'l10n_at_hr_payroll_private.structure_at_privat')
slip = self.env['hr.payslip'].new({
'employee_id': self.employee_id.id,
'struct_id': struct.id,
'date_from': erster,
'date_to': letzter,
})
eingaben = [('ATP_UEL_TAGE', self.uel_tage)]
slip.input_line_ids = [
(0, 0, {
'input_type_id': self.env['hr.payslip.input.type']
.search([('code', '=', code)], limit=1).id,
'amount': betrag,
})
for code, betrag in eingaben if betrag
]
return slip
def _eingaben(self):
self.ensure_one()
return [
('ATP_UEL_TAGE', self.uel_tage),
('ATP_ABGANG', self.abgang_betrag),
('ATP_ABGANG_FFRIST_TAGE', self.abgang_ffrist_tage),
('ATP_ZEIT_GUTHABEN', self.zeit_guthaben_stunden),
('ATP_ZEIT_SCHULDEN', self.zeit_schulden_stunden),
('ATP_ABRECHNUNG_VORSCHUSS', self.vorschuss_saldo),
]
def action_payslip(self):
"""Endabrechnungs-Lauf im Austrittsmonat anlegen und die
gesammelten offenen Posten als Eingaben vorbefüllen."""
self.ensure_one()
erster = self.austrittsdatum.replace(day=1)
letzter = (erster + timedelta(days=32)).replace(day=1) \
- timedelta(days=1)
struct = self.employee_id.version_id.structure_id \
or self.env.ref('l10n_at_hr_payroll_private.structure_at_privat')
slip = self.env['hr.payslip'].create({
'name': _('Endabrechnung %s') % self.employee_id.name,
'employee_id': self.employee_id.id,
'struct_id': struct.id,
'date_from': erster,
'date_to': letzter,
})
typen = {code: self.env['hr.payslip.input.type'].search(
[('code', '=', code)], limit=1)
for code, _betrag in self._eingaben()}
self.env['hr.payslip.input'].create([{
'payslip_id': slip.id,
'input_type_id': typen[code].id,
'amount': betrag,
} for code, betrag in self._eingaben() if betrag])
slip.compute_sheet()
return {
'type': 'ir.actions.act_window',
'name': _('Endabrechnung'),
'res_model': 'hr.payslip',
'view_mode': 'form',
'res_id': slip.id,
}