# Part of the odoo-at-payroll project. License: LGPL-3. """AP17: L17 (A1) und Finanz-Mitteilungen § 109a (W1)/§ 109b (B1) — Positionsexakte Satz-Assertions (ODL-Extraktion 2026-09-16, 1-basierte DM-ORG-Positionen → 0-basierte String-Indizes), Prüfkatalog-Logik (350-Formel, 357/347-Caps, 354-Cap aliquotiert) und Schwellen der Mitteilungsläufe.""" import base64 import datetime import types from odoo.tests import TransactionCase, tagged from odoo.addons.l10n_at_hr_payroll.models.elda import ( L10N_AT_ELDA_IDTEIL, L10nAtEldaSatz, ) from odoo.addons.l10n_at_hr_payroll.models.lohnzettel_l17 import L17_SATZ @tagged('post_install', '-at_install') class L17SatzTest(TransactionCase): def test_01_a1_satz_laenge_positionen(self): """A1: Länge 1100; FSART 'A' an Position 21; BELZ 120; JALZ 128; B350 an 281 (10 n, Cent); HOPA an 618; Satzlänge exakt.""" satz = L10nAtEldaSatz( L10N_AT_ELDA_IDTEIL + L17_SATZ, 1100, bezeichnung='A1 Test') satz.setze('SART', 'A1') satz.setze('VSTR', '94') satz.setze('FSART', 'A') satz.setze('PENS', 'N') satz.setze('AVSN', '1234150590') satz.setze('BELZ', '0101') satz.setze('ENLZ', '0131') satz.setze('JALZ', 2026) satz.setze('V350', '+') satz.setze('B350', 1234.56, cent=True) satz.setze('HOTA', 21) satz.setze('VHOPA', '+') satz.setze('HOPA', 63.0, cent=True) roh = satz.render() self.assertEqual(len(roh), 1100) self.assertEqual(roh[20], 'A') # FSART pos 21 self.assertEqual(roh[119:123], '0101') # BELZ pos 120 self.assertEqual(roh[127:131], '2026') # JALZ pos 128 self.assertEqual(roh[131], 'N') # PENS pos 132 self.assertEqual(roh[132:142], '1234150590') # AVSN pos 133 self.assertEqual(roh[279], '+') # V350 pos 280 self.assertEqual(roh[280:290], '0000123456') # B350 pos 281 self.assertEqual(roh[616], '+') # VHOPA pos 617 self.assertEqual(roh[617:627], '0000006300') # HOPA pos 618 def test_02_kz_salden_mapping(self): """GP3-Flags → L17-KZs: laufend → 350; sonstig ohne Abs-3/6 → 350+351; abs3/abs6 → 352; SVDN_* → 357; SVDN_SZ → 347; § 68- Freibetrag ÜStd → 354; Homeoffice-Regel → HOPA.""" deklaration = self.env['l10n.at.payroll.l17'].create({ 'company_id': self.env.company.id, 'jahr': 2026}) laufend = types.SimpleNamespace( l10n_at_bemessung='laufend', l10n_at_s67_abs='keine') sz = types.SimpleNamespace( l10n_at_bemessung='sonstig', l10n_at_s67_abs='keine') abf = types.SimpleNamespace( l10n_at_bemessung='sonstig', l10n_at_s67_abs='abs3') neutral = types.SimpleNamespace( l10n_at_bemessung='neutral', l10n_at_s67_abs='keine') slip = types.SimpleNamespace( line_ids=[ types.SimpleNamespace(salary_rule_id=laufend, code='GEHALT', total=3000.0), types.SimpleNamespace(salary_rule_id=sz, code='SZ13', total=3000.0), types.SimpleNamespace(salary_rule_id=abf, code='ABF_ALT', total=6000.0), types.SimpleNamespace(salary_rule_id=neutral, code='SVDN_KV', total=100.0), types.SimpleNamespace(salary_rule_id=neutral, code='SVDN_SZ', total=50.0), types.SimpleNamespace(salary_rule_id=laufend, code='SB_HOMEOFFICE', total=63.0), ], input_line_ids=[], _l10n_at_freibetraege_68=lambda: {'szfn': 0.0, 'ue_std': 60.0}, ) salden, hota = deklaration._l10n_at_kz_salden(slip) self.assertEqual(salden['kz350'], 12063.0) self.assertEqual(salden['kz351'], 3000.0) self.assertEqual(salden['kz352'], 6000.0) self.assertEqual(salden['kz354'], 60.0) self.assertEqual(salden['kz357'], 100.0) self.assertEqual(salden['kz347'], 50.0) self.assertEqual(salden['hopa'], 63.0) def test_03_l17_pruefungen_pruefkatalog(self): deklaration = self.env['l10n.at.payroll.l17'].create({ 'company_id': self.env.company.id, 'jahr': 2026}) employee = self.env['hr.employee'].create({ 'name': 'L17 Prüfung', 'birthday': '1990-01-01'}) line = self.env['l10n.at.payroll.l17.line'].create({ 'l17_id': deklaration.id, 'employee_id': employee.id, 'date_from': datetime.date(2026, 1, 1), 'date_to': datetime.date(2026, 1, 31), }) fehler, warnungen = [], [] line.write({'kz350': 5000.0, 'kz351': 3000.0, 'kz352': 2500.0}) line._l10n_at_pruefungen(fehler, warnungen) self.assertTrue(any('KZ 350' in f for f in fehler)) fehler.clear() line.write({'kz350': 12000.0, 'kz351': 3000.0, 'kz352': 0.0, 'kz357': 4900.0}) line._l10n_at_pruefungen(fehler, warnungen) self.assertTrue(any('KZ 357' in f for f in fehler)) fehler.clear() line.write({'kz357': 0.0, 'kz354': 2500.0}) line._l10n_at_pruefungen(fehler, warnungen) self.assertTrue(any('KZ 354' in w for w in warnungen)) @tagged('post_install', '-at_install') class Mitteilung109Test(TransactionCase): def setUp(self): super().setUp() self.company = self.env.company self.company.write({'l10n_at_lohnsteuernummer': '123456789'}) def _deklaration(self, art, entgelt): deklaration = self.env['l10n.at.payroll.mitteilung109'].create({ 'company_id': self.company.id, 'jahr': 2026, 'art': art}) werte = { 'name_partner': 'Testpartner', 'name': 'Testwerk', 'steuernummer': '8765432', 'entgelt': entgelt, } if art == 'w1': werte.update({'art_leistung': '04', 'adresse': 'Werkstraße 1', 'plz': '1010', 'ort': 'Wien'}) else: werte.update({'al31': True, 'ziellaender': 'DE,CH', 'adresse': 'Werkstraße 1', 'plz': '1010', 'ort': 'Wien'}) deklaration.line_ids = [(0, 0, werte)] return deklaration def test_04_w1_satz_und_positionen(self): deklaration = self._deklaration('w1', 1200.0) deklaration.action_erzeugen() line = deklaration.line_ids[0] self.assertTrue(line.gemeldet) daten = base64.b64decode(deklaration.datei).decode('ISO-8859-15') w1_satz = daten.splitlines()[3] self.assertEqual(len(w1_satz), 1100) self.assertEqual(w1_satz[:2], 'W1') self.assertEqual(w1_satz[20], 'W') # FSART pos 21 self.assertEqual(w1_satz[281], '+') # V341 pos 282 self.assertEqual(w1_satz[282:292], '0000120000') # B341 pos 283 self.assertEqual(w1_satz[303:305], '12') # FANRA pos 304 self.assertEqual(w1_satz[305:312], '3456789') # STNRA pos 306 def test_05_w1_schwelle_untermeldung(self): deklaration = self._deklaration('w1', 400.0) deklaration.action_erzeugen() line = deklaration.line_ids[0] self.assertFalse(line.gemeldet) self.assertIn('Schwellen', deklaration.protokoll) self.assertFalse(deklaration.datei) def test_05b_w1_900_eur_kumulativ_grenze(self): """450 € je Leistung, aber 1.000 € kumulativ je LE → meldepflichtig trotz je-Leistung-Unterschreitung.""" deklaration = self._deklaration('w1', 450.0) deklaration.line_ids = [ (0, 0, {'name_partner': 'Testpartner', 'name': 'Testwerk', 'steuernummer': '8765432', 'art_leistung': '04', 'entgelt': 550.0}), ] deklaration.action_erzeugen() self.assertTrue(deklaration.line_ids[0].gemeldet) def test_06_b1_satz_schwellen_ausnahme(self): deklaration = self._deklaration('b1', 120000.0) deklaration.action_erzeugen() line = deklaration.line_ids[0] self.assertTrue(line.gemeldet) daten = base64.b64decode(deklaration.datei).decode('ISO-8859-15') b1_satz = daten.splitlines()[3] self.assertEqual(len(b1_satz), 1100) self.assertEqual(b1_satz[:2], 'B1') self.assertEqual(b1_satz[20], 'B') # FSART pos 21 self.assertEqual(b1_satz[281], '+') # V499 pos 282 self.assertEqual(b1_satz[282:292], '0012000000') # B499 pos 283 self.assertEqual(b1_satz[360], 'J') # AL31 pos 361 self.assertEqual(b1_satz[361], 'N') # AL32 pos 362 self.assertEqual(b1_satz[363:366], 'DE ') # LKZ1 pos 364 (3 a) self.assertEqual(b1_satz[366:369], 'CH ') # LKZ2 pos 367 (3 a) # Unter der 100.000-€-Schwelle: keine Meldung (§ 109b Abs 4). klein = self._deklaration('b1', 50000.0) klein.action_erzeugen() self.assertFalse(klein.line_ids[0].gemeldet) # Ausnahme Steuerabzug § 99: keine Meldung trotz Betrags. abzug = self._deklaration('b1', 150000.0) abzug.line_ids[0].ausnahme_grund = 'steuerabzug' abzug.action_erzeugen() self.assertFalse(abzug.line_ids[0].gemeldet) def test_07_fristenkalender_ap17(self): from odoo.addons.l10n_at_hr_payroll.models.fristenkalender \ import FRISTART_DEFINITIONEN for art in ('l17', 'mitteilung_109b'): self.assertIn(art, FRISTART_DEFINITIONEN) self.assertEqual( FRISTART_DEFINITIONEN[art]['monat'], 2)