[IMP] l10n_at_hr_payroll_private: add domestic travel expenses (AP13-A)

Use Odoo's payroll expense reconciliation flow so documented employee-paid travel expenses remain linked to their payslip and payable settlement. Classify only the excess over the verified domestic tax-free limits as running remuneration, avoiding both unpaid expense reimbursements and double payment.
This commit is contained in:
2026-09-14 13:42:06 +02:00
parent 91ac9e5fda
commit 3f307a0688
11 changed files with 607 additions and 2 deletions
@@ -0,0 +1,146 @@
# Part of the odoo-at-payroll project. License: LGPL-3.
"""AP13-A: domestic travel expenses and taxable-overhang payroll split."""
from datetime import date, timedelta
from odoo.tests import TransactionCase, tagged
@tagged('post_install', '-at_install')
class ReisekostenPrivateTest(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 Reise 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 _employee(self):
employee = self.env['hr.employee'].create({'name': 'Test Reise 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),
'kv_id': self.kv2203.id,
'kv_gruppe_id': self.gruppe_ii.id,
'erfahrungsstufe': 1,
'resource_calendar_id': self.calendar.id,
})
return employee
def _slip(self, employee, month, year=2026):
first = date(year, month, 1)
next_month = (first + timedelta(days=32)).replace(day=1)
slip = self.env['hr.payslip'].create({
'name': 'Test-Reise-Privat %d/%d' % (month, year),
'employee_id': employee.id,
'struct_id': self.structure.id,
'date_from': first,
'date_to': next_month - timedelta(days=1),
})
slip.compute_sheet()
return slip
def _expense(self, values):
"""An in-memory expense is sufficient for the legal split helper."""
return self.env['hr.expense'].new({
'name': 'Testreise',
'employee_id': self._employee().id,
'total_amount_currency': 0.0,
'unit_amount': 0.0,
**values,
})
def _input(self, slip, xmlid, amount):
input_type = self.env.ref(xmlid)
self.env['hr.payslip.input'].create({
'payslip_id': slip.id,
'input_type_id': input_type.id,
'amount': amount,
})
def _total(self, slip, code):
return sum(slip.line_ids.filtered(lambda line: line.code == code).mapped('total'))
def test_01_inland_taggeld_cap(self):
"""10 h domestic travel: 10 × 30/12 = 25 €; the 15 € excess is
ordinary running remuneration."""
expense = self._expense({
'total_amount': 40.0,
'l10n_at_reise_art': 'taggeld',
'l10n_at_reise_datum': date(2026, 1, 15),
'l10n_at_reise_stunden': 10.0,
'l10n_at_reise_nachweis': True,
})
frei, taxable, km_ytd, niedriger_satz_ytd = expense._l10n_at_reise_split()
self.assertAlmostEqual(frei, 25.0, places=2)
self.assertAlmostEqual(taxable, 15.0, places=2)
self.assertEqual(km_ytd, 0.0)
self.assertEqual(niedriger_satz_ytd, 0.0)
def test_02_kilometergeld_annual_cap(self):
"""After 29,800 confirmed annual kilometres, 500 private-car
kilometres yield only 100 € tax-free; 150 € are taxable."""
expense = self._expense({
'total_amount': 250.0,
'l10n_at_reise_art': 'kilometergeld',
'l10n_at_reise_datum': date(2026, 11, 15),
'l10n_at_reise_kilometer': 500.0,
'l10n_at_reise_fahrzeug': 'pkw',
'l10n_at_reise_nachweis': True,
})
frei, taxable, km_ytd, niedriger_satz_ytd = expense._l10n_at_reise_split(29800.0)
self.assertAlmostEqual(frei, 100.0, places=2)
self.assertAlmostEqual(taxable, 150.0, places=2)
self.assertAlmostEqual(km_ytd, 30300.0, places=2)
self.assertEqual(niedriger_satz_ytd, 0.0)
def test_03_taxable_overhang_is_not_reimbursed_twice(self):
"""EXPENSES refunds the actual 40 €. The 15 € classification line
is offset in the net while its SV and LSt remain deductible."""
employee_with = self._employee()
employee_without = self._employee()
with_expense = self._slip(employee_with, 1)
without_expense = self._slip(employee_without, 1)
self._input(with_expense, 'hr_payroll_expense.expense_other_input', 40.0)
self._input(with_expense,
'l10n_at_hr_payroll_private.input_type_atp_reise_steuerpflichtig',
15.0)
self._input(with_expense,
'l10n_at_hr_payroll_private.input_type_atp_reise_nettoausgleich',
15.0)
with_expense.compute_sheet()
self.assertAlmostEqual(self._total(with_expense, 'EXPENSES'), 40.0, places=2)
self.assertAlmostEqual(
self._total(with_expense, 'REISE_STEUERPFLICHTIG'), 15.0, places=2)
self.assertAlmostEqual(
self._total(with_expense, 'REISE_NETTOAUSGLEICH'), 15.0, places=2)
svdn_difference = (with_expense._l10n_at_svdn_gesamt()
- without_expense._l10n_at_svdn_gesamt())
self.assertAlmostEqual(
with_expense._l10n_at_bemessung_laufen()
- without_expense._l10n_at_bemessung_laufen()
+ svdn_difference, 15.0, places=2)
net_difference = (self._total(with_expense, 'NET')
- self._total(without_expense, 'NET'))
tax_difference = self._total(with_expense, 'LST_LFD') \
- self._total(without_expense, 'LST_LFD')
self.assertAlmostEqual(net_difference, 40.0 - svdn_difference - tax_difference,
places=2)