@fin.cx/expenses
German travel and expense reimbursement as pure functions: the per diems, mileage and lodging flats of § 9 EStG, the BMF's foreign rates by year, the tax-free and taxable share of every claim line, whether a receipt's input tax may be deducted, and the business-meal split. A claim is computed from what the person entered: the trip's days and places, the meals the company provided, the kilometres, the nights and the receipts. Every amount is a whole number of cents.
The package is pure: it reads no clock, keeps no state, has no dependencies and fetches nothing. Each year's rates ship with it.
Issue Reporting and Security
For reporting bugs, issues, or security vulnerabilities, please visit community.foss.global/. This is the central community hub for all issue reporting. Developers who sign and comply with our contribution agreement and go through identification can also get a code.foss.global/ account to submit Pull Requests directly.
Install
pnpm install @fin.cx/expenses
Usage
A three-day trip to Paris. The hotel's invoice to the company includes breakfast, and on the second evening the person attended a business dinner the company paid:
import { getTravelRates, perDiemsOf, lodgingFlatOf, mileageOf, taxTreatmentOf, type ITrip } from '@fin.cx/expenses';
const rates = getTravelRates(2026); // a year without rates throws ExpensesError 'travel_year_unknown'
const paris = { country: 'FR', city: 'Paris' };
const trip: ITrip = {
startsAt: '2026-03-09T06:30', // leaving home or the first place of work, local time
endsAt: '2026-03-11T21:15',
days: [
{ date: '2026-03-09', placeAtMidnight: paris, placesOfWork: [paris], overnight: true },
{
date: '2026-03-10',
placeAtMidnight: paris,
placesOfWork: [paris],
overnight: true,
mealsProvided: [{ meal: 'breakfast' }, { meal: 'dinner' }],
},
{ date: '2026-03-11', placesOfWork: [paris], mealsProvided: [{ meal: 'breakfast' }] },
],
beyondThreeMonths: false, // the person's answer to "had you worked at this place for more than three months?"
};
const perDiems = perDiemsOf(trip, rates);
perDiems.days.map((day) => [day.date, day.kind, day.amountCents]);
// [['2026-03-09', 'arrival', 3900], ['2026-03-10', 'intermediate', 2320], ['2026-03-11', 'departure', 2740]]
// Paris: 58 € for 24 hours, 39 € for arrival and departure; breakfast −11.60 €, dinner −23.20 € (20 % and 40 % of 58 €)
perDiems.totalCents; // 8960
lodgingFlatOf({ nights: 2, place: paris }, rates).amountCents; // 31800: 159 € a night, for nights without a receipt
mileageOf({ km: 123.4, vehicle: 'car' }, rates).amountCents; // 3702: 0.30 € per km
taxTreatmentOf({ kind: 'per_diem', trip, paidCents: 12_000 }, rates);
// { allowedCents: 8960, paidCents: 12000, taxFreeCents: 8960, taxableCents: 3040, flatTaxableCents: 3040, basis: '§ 3 Nr. 16' }
Each day of the result says which place's rates it took and why (day.rate.rule), its rate before the meals (baseCents), every meal's reduction and the amount. A day that two trips share appears once, with the higher per diem: pass the trips together, perDiemsOf([firstTrip, secondTrip], rates).
Receipts:
import { inputVatOf, businessMealOf, EXPENSE_CATEGORIES } from '@fin.cx/expenses';
inputVatOf({ document: 'invoice', grossCents: 41_650, vatCents: 2_725, vatCountry: 'DE', recipient: 'person', supplyTo: 'company' });
// { deductible: false, inputVatCents: 0, reason: 'invoice_to_person' } — above 250 €: ask the hotel for an invoice to the company
inputVatOf({ document: 'ticket', grossCents: 8_990, vatCents: 588, vatCountry: 'DE', recipient: 'person', supplyTo: 'person' });
// { deductible: false, inputVatCents: 0, reason: 'supply_to_person' } — a candidate's own journey to the interview
const vat = inputVatOf({ document: 'invoice', grossCents: 18_445, vatCents: 2_945, vatCountry: 'DE', recipient: 'company', supplyTo: 'company' });
businessMealOf({
grossCents: 18_445,
inputVatCents: vat.inputVatCents,
tipCents: 1_500,
inRestaurant: true,
participants: ['Mara Beispiel (Acme GmbH)', 'Dr. Jan Kunde (Kunde AG)'],
occasion: 'Rahmenvertrag 2027',
});
// { expenseCents: 17000, deductibleCents: 11900, nondeductibleCents: 5100, inputVatCents: 2945, missing: [] }
EXPENSE_CATEGORIES.find((category) => category.category === 'lodging');
// { category: 'lodging', accountRole: 'travel_employee_lodging', travel: true, taxFreeBasis: '§ 3 Nr. 16', requirements: ['receipt', 'trip', 'breakfast_stated'], … }
What it computes
Per diems (perDiemsOf)
| Day | Rule | Source |
|---|---|---|
| A day of 24 hours away | 28 € in Germany; abroad the place's full-day rate | § 9 Abs. 4a Satz 3 Nr. 1, Satz 5 EStG |
| The arrival and departure day of a trip with a night away | 14 €, or the place's partial rate, without a minimum of hours | § 9 Abs. 4a Satz 3 Nr. 2 EStG; BMF 25.11.2020, Rz. 49, 51 |
| A day of more than 8 hours without a night away | 14 €, or the partial rate; 8 hours or less give nothing | § 9 Abs. 4a Satz 3 Nr. 3 EStG |
| An absence over midnight without a night away | Its hours added, the 14 € on the day of the greater part (on equal parts, the first day) | § 9 Abs. 4a Satz 3 Nr. 3 second half-sentence; Rz. 47 |
| Meals provided | −20 % for breakfast, −40 % each for lunch and dinner of the day's full-day rate, less what the person paid for that meal, the day never below 0 | § 9 Abs. 4a Satz 8 to 10 EStG; Rz. 73 to 78 |
| Two trips meeting on a day | Only the higher per diem; the day's meals reduce it by its own full-day rate | BMF 5.12.2025, p. 2; Rz. 49 |
| More than three months at the place | Nothing, with the warning three_month_rule |
§ 9 Abs. 4a Satz 6 and 7 EStG |
The place whose rate a day takes abroad:
- the arrival day and each day between: the place reached last before 24:00 local time; where that place is in Germany, the last place of work abroad that day (§ 9 Abs. 4a Satz 5 EStG; R 9.6 Abs. 3 Satz 3 LStR);
- the departure day: the trip's last place of work (BMF 5.12.2025, p. 1; BMF 25.11.2020, Rz. 52);
- a one-day trip: its last place of work abroad (BMF 5.12.2025, p. 1).
The person gives, per day, the place at 24:00 (placeAtMidnight), where they worked (placesOfWork, in order), whether they spent the night away (overnight) and the meals provided. A meal counts as provided where the employer, or a third at its instigation, gave it: a breakfast on a hotel invoice to the company, a lunch at a seminar the company booked, a business meal, a meal the company reimbursed (Rz. 64, 75, 82). It reduces the day whether it was eaten or not.
The rate of a place (rateOfPlace)
A place is a country by its ISO 3166-1 alpha-2 code and, where it matters, a city. DE is Germany.
- A city the letter lists for its country takes the city's rate; any other city the country's rate "im Übrigen". Paris includes the départements 77, 78 and 91 to 95: give
Parisfor a place there. Cities are compared without regard to case, as the letter writes them (New York City,Washington, D. C.,Kanarische Inseln,Hongkong). - Micronesia takes the Philippines' rates, and Antigua and Barbuda, Dominica, Grenada, Guyana, St. Kitts and Nevis, St. Lucia, St. Vincent and the Grenadines and Suriname take Trinidad and Tobago's (BMF 5.12.2025, p. 2).
- An overseas or outlying territory the letter does not list takes its mother country's rate: Réunion France's, Puerto Rico that of the United States, Hong Kong (
HK) China's city rate for Hongkong (p. 2; R 9.6 Abs. 3 Satz 2 LStR). - Any other country the letter does not list takes Luxembourg's rate.
Mileage and lodging (mileageOf, lodgingFlatOf)
- Mileage in the person's own vehicle: 0.30 € per km by car, 0.20 € per km by another motor vehicle, the highest Wegstreckenentschädigung of the BRKG (§ 9 Abs. 1 Satz 3 Nr. 4a Satz 2 EStG; § 5 Abs. 1 and 2 BRKG; Rz. 37). Kilometres take one decimal; the amount is rounded to the cent. A bicycle gives no flat: § 5 BRKG sets none for it. A company car gives none either (R 9.5 Abs. 2 Satz 3 LStR).
- Lodging without a receipt: 20 € a night in Germany, the BMF's flat of the place abroad. Only the employer may pay these flats, and only for a night the person paid for. They do not apply to lodging provided free, a night in a vehicle, or a sleeping car or ship's cabin unless the night began or ended elsewhere (R 9.7 Abs. 3 LStR; Rz. 128).
Tax-free and taxable (taxTreatmentOf)
| Line | Tax-free up to | Basis |
|---|---|---|
per_diem |
the per diems computed | § 3 Nr. 16 EStG |
mileage, lodging_flat |
the flat | § 3 Nr. 16 EStG |
receipt, replacement of a travel category (tickets, taxi, rental car, parking, lodging) |
the receipt's gross | § 3 Nr. 16 EStG |
receipt, replacement of any other category |
the receipt's gross (Auslagenersatz) | § 3 Nr. 50 EStG |
Whatever the company pays above that is wages (taxableCents). Per diems paid above the tax-free amount may be taxed at a flat 25 % up to 100 % of the per diems before meal reductions (flatTaxableCents), but not after the three months (§ 40 Abs. 2 Satz 1 Nr. 4 EStG; Rz. 58, 59, Beispiele 42, 50, 51).
Input tax (inputVatOf)
recipient is whom the document names; supplyTo is whom the supply was made to. An employee's business travel and an outlay for the company are supplies to the company (company). A guest's own travel, such as a candidate's journey to the interview, is a supply to the guest (person), which the company only reimburses: its input tax is deducted only where the invoice names the company.
| Receipt | Input tax | Reason |
|---|---|---|
| An invoice naming the company | deductible | invoice_to_company (§ 15 Abs. 1 Satz 1 Nr. 1, § 14 Abs. 4 UStG) |
| Up to 250 € gross, naming anyone or no one | deductible | small_amount_invoice (§ 33 UStDV) |
| A ticket | deductible | ticket (§ 34 Abs. 1 UStDV) |
| A cross-border ticket without the carrier's statement of the domestic share | not deductible | cross_border_ticket (§ 34 Abs. 2 UStDV) |
| Above 250 € naming the person | not deductible | invoice_to_person |
| Above 250 € naming no one | not deductible | recipient_missing |
| A supply to the person, the invoice not naming the company, even a ticket or up to 250 € | not deductible | supply_to_person (§ 15 Abs. 1 Satz 1 Nr. 1 UStG) |
| Another country's VAT | not deductible here | foreign_vat |
| No VAT shown | none | no_vat_shown |
| An Ersatzbeleg; a per diem, mileage or lodging flat | none | replacement, flat_amount |
Whether an invoice carries every detail of § 14 Abs. 4 UStG is not checked.
Business meals (businessMealOf)
70 % of the expense is deductible and 30 % is not; the tip is part of the expense, and so are the host's and the employees' shares (§ 4 Abs. 5 Satz 1 Nr. 2 EStG; R 4.10 Abs. 5 Satz 4, Abs. 6 Satz 7 EStR). The expense is the gross less the deductible input tax, plus the tip. The input tax is deducted in full (§ 15 Abs. 1a Satz 2 UStG). The deductible part is rounded down to the cent, so it never exceeds 70 %; the rest is the non-deductible part. missing names the details the Bewirtungsbeleg still lacks:
- always the participants and the occasion;
- outside a restaurant also the place and the day;
- for a restaurant invoice above 250 €, the host named on the invoice (§ 4 Abs. 5 Satz 1 Nr. 2 Satz 2 and 3 EStG; R 4.10 Abs. 8 and 9 EStR).
The restaurant's invoice must be machine-made, registered and itemised (R 4.10 Abs. 8 Satz 8 and 9 EStR). This is not checked.
A business meal on a trip day is a meal provided, which reduces that day's per diem (Rz. 82).
Categories (EXPENSE_CATEGORIES)
Tickets, taxi, rental car, fuel (of a company car), parking and tolls, lodging, business meal, office supplies, postage, literature, training, software, other. Each comes with:
- the
@fin.cx/chartdataaccount role it books on (TExpenseAccountRole), and the role of a business meal's non-deductible part; - whether it is a travel cost;
- its tax-free basis;
- what a line must carry (receipt, trip, breakfast stated, vehicle, participants, occasion, place and day).
The allowances book on travel_employee_meals, mileage_employee and travel_employee_lodging (ALLOWANCE_ACCOUNT_ROLES).
The roles are those of @fin.cx/chartdata from 3.2.0 (TExpenseRole), which gives a role's account in a chart:
import { getExpenseAccount } from '@fin.cx/chartdata';
getExpenseAccount('SKR03', 2026, 'travel_employee_meals')?.accountNumber; // '4664'
getExpenseAccount('SKR04', 2026, 'travel_employee_meals')?.accountNumber; // '6664'
This package does not depend on chartdata at runtime; a test holds every role against both standard charts.
What is refused
Every input is checked. A refusal throws an ExpensesError with a code and the field it is about (days[2].placeAtMidnight, [1].days[0].date for the second of several trips):
| Code | Refused |
|---|---|
travel_year_unknown |
A year without rates; a trip day in a year whose rates were not passed |
invalid_place |
A country that is no ISO 3166-1 alpha-2 code in capitals (or XK); an empty city |
invalid_trip |
Times that are not YYYY-MM-DDTHH:MM; days that are not the trip's calendar days in order; no place at 24:00 on an arrival or intermediate day; no place of work for the departure day; a night away after the last day; three or more days without a night away; a meal listed twice on a day |
trip_end_not_after_start |
A trip's end at or before its start (endsAt) |
invalid_amount |
Cents that are not whole numbers from 0; kilometres with more than one decimal; nights that are not whole; a tax amount above its gross |
invalid_line |
A line kind, category or vehicle this package does not know |
invalid_receipt |
A document kind, recipient, supply or VAT country this package does not know |
The years
| Year | Rates | Foreign table |
|---|---|---|
| 2026 | 28 € / 14 €, 20 € lodging in Germany, 0.30 € / 0.20 € per km | BMF letter of 5 December 2025, GZ IV C 5 - S 2353/00094/007/012, DOK COO.7005.100.3.13672232: 166 countries, 48 cities |
TRAVEL_RATE_YEARS lists them. A trip over the turn of a year takes both years' rates: perDiemsOf(trip, [getTravelRates(2026), getTravelRates(2027)]), once 2027 exists. Each year's letter comes as a minor release.
How the foreign table is made
ts/data/travelrates.<year>.ts is generated from the letter's PDF, as published on bundesfinanzministerium.de:
tools/extract-pdf-rows.mjslays out the PDF's text with pdf.js (not a dependency: pass the path of anypdfjs-dist).tools/generate-travelrates.tsreads the table's rows. The text layer splits some cells, such as Chile's "29" into "2" and "9", and some names run over two lines ("Paris sowie die / Departments 77, 78, 91 bis 95", "Vereinigte Staaten von Amerika / (USA)"). The generator joins them and reports each one.tools/check-travelrates.tsreads the same PDF a second time through Ghostscript and compares every row's name and amounts.
For 2026, both readings agree on all 214 rows. Every row was also compared by hand with the rendered pages 1 to 7 of the letter's Anlage. The changes against the previous year, printed bold in the letter, are not recorded.
ts/data/countries.ts (the ISO codes and the mother country of each territory) is generated by tools/generate-countries.ts from Wikipedia's "List of ISO 3166 country codes".
Not covered
- Double household (doppelte Haushaltsführung), the professional drivers' allowance for nights in a vehicle (§ 9 Abs. 1 Satz 3 Nr. 5b EStG), meal benefits outside trips, and the flat 25 % tax itself (
flatTaxableCentssays how much it may cover). These belong to payroll. - The three-month period itself: it is the person's answer, not computed from earlier trips.
- Several separate absences on one day (BMF 25.11.2020, Rz. 47, Beispiel 29): give the day's longest trip, or add the times yourself.
- Days in the air over more than two calendar days (Austria's rate) and sea voyages (Luxembourg's rate), R 9.6 Abs. 3 Satz 4 LStR: give the place yourself.
- Meals above 60 € taxed as wages, which reduce nothing (Rz. 86): leave them out of
mealsProvided. - The limit on actual lodging costs after 48 months at one place of work (§ 9 Abs. 1 Satz 3 Nr. 5a Satz 4 EStG).
- Whether an expense is reasonable, whether a meal of the company's own employees is a business meal (it is not, R 4.10 Abs. 7 EStR), and whether an invoice meets § 14 Abs. 4 UStG.
- Changes of daylight saving time: hours away are wall-clock hours.
Sources
- § 3 EStG Nr. 16 and Nr. 50; § 4 EStG Abs. 5 Satz 1 Nr. 2; § 9 EStG Abs. 1 Satz 3 Nr. 4a, Abs. 4a; § 40 EStG Abs. 2 Satz 1 Nr. 4; § 5 BRKG; § 15 UStG Abs. 1 and 1a; § 33 and § 34 UStDV (read 2026-10-08).
- BMF, "Steuerliche Behandlung von Reisekosten und Reisekostenvergütungen bei betrieblich und beruflich veranlassten Auslandsreisen ab 1. Januar 2026", 5 December 2025, GZ IV C 5 - S 2353/00094/007/012, DOK COO.7005.100.3.13672232, with its Anlage "Übersicht": bundesfinanzministerium.de, PDF SHA-256
0eacf14b6eedcc17d9147d374bd1bb76036d9c4b424b3f966040109984f9d760. - BMF, "Steuerliche Behandlung der Reisekosten von Arbeitnehmern", 25 November 2020, BStBl I S. 1228 ("Rz." above), as printed in the BMF's Lohnsteuer-Handbuch 2026, Anhang 25 III: lsth.bundesfinanzministerium.de.
- R 9.5, R 9.6 and R 9.7 LStR, as printed in the Lohnsteuer-Handbuch 2026: lsth.bundesfinanzministerium.de.
- R 4.10 Abs. 5 to 9 EStR, as printed in the Einkommensteuer-Handbuch 2024: esth.bundesfinanzministerium.de.
- Wikipedia, "List of ISO 3166 country codes", columns "Sovereignty" and "A-2" (read 2026-10-08).
Unconfirmed:
- That a crown dependency (Guernsey, Jersey, Isle of Man) or another non-sovereign ISO territory is an "Übersee- oder Außengebiet" in the letter's sense, taking its mother country's rate, is this package's reading. The letter does not list them.
- The worked example of BMF 25.11.2020, Beispiel 50 Abwandlung 1, states a payment of 5 € for breakfast but computes with 6 €; it is not used.
- The Einkommensteuer-Handbuch was read in its 2024 edition, the newest found; R 4.10 EStR's text there gives the host's name above 150 €, with a note raising the limit to 250 € from 2017.
- That a guest's own travel, such as a candidate's journey to the interview, is a supply to the guest and not to the company is this package's reading of § 15 Abs. 1 Satz 1 Nr. 1 UStG (a supply "für sein Unternehmen"); confirm it with a tax advisor.
- The UStAE's section on input tax from employees' travel receipts was not read. Not deducting the input tax of an invoice above 250 € that names the person follows § 14 Abs. 4 Satz 1 Nr. 1 UStG (the recipient's name and address).
License and Legal Information
This repository contains open-source code licensed under the MIT License. A copy of the license can be found in the license.md file.
Please note: The MIT License does not grant permission to use the trade names, trademarks, service marks, or product names of the project, except as required for reasonable and customary use in describing the origin of the work and reproducing the content of the NOTICE file.
Trademarks
This project is owned and maintained by Task Venture Capital GmbH. The names and logos associated with Task Venture Capital GmbH and any related products or services are trademarks of Task Venture Capital GmbH or third parties, and are not included within the scope of the MIT license granted herein.
Use of these trademarks must comply with Task Venture Capital GmbH's Trademark Guidelines or the guidelines of the respective third-party owners, and any usage must be approved in writing. Third-party trademarks used herein are the property of their respective owners and used only in a descriptive manner, e.g. for an implementation of an API or similar.
Company Information
Task Venture Capital GmbH Registered at District Court Bremen HRB 35230 HB, Germany
For any legal inquiries or further information, please contact us via email at hello@task.vc.
By using this repository, you acknowledge that you have read this section, agree to comply with its terms, and understand that the licensing of the code does not imply endorsement by Task Venture Capital GmbH of any derivative works.