jkunz 9d6badb6e8
Default (tags) / test (push) Successful in 59s
v3.0.0
2026-10-10 02:48:46 +00:00
…
2026-10-10 02:48:46 +00:00
2026-10-10 02:48:46 +00:00
2026-10-10 02:48:46 +00:00
…

@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 Paris for 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/chartdata account 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:

  1. tools/extract-pdf-rows.mjs lays out the PDF's text with pdf.js (not a dependency: pass the path of any pdfjs-dist).
  2. tools/generate-travelrates.ts reads 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.
  3. tools/check-travelrates.ts reads 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 (flatTaxableCents says 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

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).

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.

S
Description
No description provided
Readme
457 KiB
Languages
TypeScript 98.9%
JavaScript 1.1%