mirror of
http://100.103.83.12:3003/fegger/odoo-at-payroll.git
synced 2026-09-17 08:52:45 +00:00
[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:
@@ -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)
|
||||
Reference in New Issue
Block a user