Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
49 commits
Select commit Hold shift + click to select a range
18dd981
refactor: Replace icon font with SVG icons
MoritzWeber0 Mar 29, 2026
0729ca8
fix: Sizing of icons
MoritzWeber0 Mar 29, 2026
0475d95
fix: Make search icons work again
MoritzWeber0 Mar 29, 2026
879c125
feat: Add more space for startpage teaser
MoritzWeber0 Mar 29, 2026
6e4f24e
feat: initial
lenderom Apr 6, 2026
fdea39d
Merge remote-tracking branch 'origin/main' into feature/taxation
lenderom Apr 6, 2026
ca2f2ca
fix: some improvements
lenderom Apr 6, 2026
079c7e5
fix: cleanup
lenderom Apr 8, 2026
ca05b54
remove taxation features
lenderom Jul 26, 2026
a640b53
fix: add infos
lenderom Aug 3, 2026
290ae23
fix: update implementation
lenderom Aug 3, 2026
4024d55
fix: implementation
lenderom Aug 4, 2026
b9eb194
Merge remote-tracking branch 'origin/main' into feature/taxation
lenderom Sep 19, 2026
4955531
fix: only DB Users
lenderom Sep 19, 2026
0d94028
fix: finalize content changes
lenderom Sep 19, 2026
c2f2246
fix: test permissions api
lenderom Sep 19, 2026
4667614
fix: update permissions api
lenderom Sep 19, 2026
fcadea3
fix: improve permissions api
lenderom Sep 19, 2026
d6e9a15
fix: add delay
lenderom Sep 19, 2026
16c4bd2
fix: intranet implementation
lenderom Sep 19, 2026
45d4807
fix: improve formatting
lenderom Sep 20, 2026
ec71bd5
fix: update ui
lenderom Sep 20, 2026
ad39791
fix: update ui 2
lenderom Sep 20, 2026
19d98c9
fix: wording
lenderom Sep 20, 2026
f57da27
fix: add ticket count and price per ticket to taxation localization
lenderom Sep 20, 2026
81814e1
fix: update placeholder text for taxation input fields in localizatio…
lenderom Sep 20, 2026
5943857
fix: add distribution field to taxation localization in German, Engli…
lenderom Sep 20, 2026
7ff0f84
fix: add headline
lenderom Sep 20, 2026
2f8dc15
fix: min
lenderom Sep 20, 2026
40de4b0
fix: split css
lenderom Sep 20, 2026
769da61
fix: update css
lenderom Sep 20, 2026
4f7e224
fix: add news
lenderom Sep 20, 2026
c427a50
fix: update links to the tax calculator in multiple languages and files
lenderom Sep 22, 2026
66825fb
fix: test new loading
lenderom Sep 22, 2026
a40ee7e
Revert "fix: test new loading"
lenderom Sep 22, 2026
b72c612
fix: improve loading
lenderom Sep 22, 2026
8b5f9ad
fix: test fetch
lenderom Sep 22, 2026
bc351a5
fix: next try
lenderom Sep 22, 2026
e0a4502
fix: next test
lenderom Sep 22, 2026
8a1b69e
fix: next try
lenderom Sep 22, 2026
cd0abf8
fix: final done
lenderom Sep 22, 2026
2d7d389
fix: download icon
lenderom Sep 22, 2026
7ad5abf
fix: update references to meineDB and Personalportal in multiple lang…
lenderom Sep 23, 2026
73ea48a
fix: relocate taxationembed script
lenderom Sep 23, 2026
74e772e
fix: update taxation warnings for selected operators in multiple lang…
lenderom Sep 23, 2026
a0e5577
fix: replace Personalportal with meineDB
lenderom Sep 23, 2026
5f8a333
Merge branch 'main' into feature/taxation
therobrob Sep 24, 2026
fb28da1
fix: update news date
lenderom Sep 24, 2026
c558ff0
fix: loading template
lenderom Sep 24, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
266 changes: 266 additions & 0 deletions assets/js/taxationEmbed.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,266 @@
(function () {
var root = document.querySelector("[data-taxation-remote-root]");
if (!root) return;
var warningTemplate = root.querySelector(
"[data-taxation-remote-warning-template]",
);
var warningHtml = warningTemplate ? warningTemplate.innerHTML : "";
var loadingTemplate = root.querySelector(
"[data-taxation-remote-loading-template]",
);
var loadingHtml = loadingTemplate ? loadingTemplate.innerHTML : "";

function normalizeBaseUrl(rawUrl) {
var trimmed = String(rawUrl || "").trim();
if (!trimmed) return "";
return trimmed.endsWith("/") ? trimmed : trimmed + "/";
}

function renderWarning() {
root.innerHTML = "";
if (warningHtml) {
root.insertAdjacentHTML("beforeend", warningHtml);
}
}

function renderLoading() {
root.innerHTML = "";
root.insertAdjacentHTML("beforeend", loadingHtml);
}

function loadModule(src) {
return new Promise(function (resolve, reject) {
var existing = document.getElementById("taxation-remote-module");
if (existing) {
resolve();
return;
}
var script = document.createElement("script");
script.id = "taxation-remote-module";
script.type = "module";
script.src = src;
script.crossOrigin = "anonymous";
script.onload = function () {
resolve();
};
script.onerror = function () {
reject(new Error("module-load-failed"));
};
document.head.appendChild(script);
});
}

async function loadCss(baseUrl) {
var cssUrl = baseUrl + "css/taxation.min.css";
var existing = document.getElementById("taxation-remote-style-link");
if (existing) {
return;
}
await new Promise(function (resolve, reject) {
var link = document.createElement("link");
link.id = "taxation-remote-style-link";
link.rel = "stylesheet";
link.href = cssUrl;
link.crossOrigin = "anonymous";
link.onload = function () {
resolve();
};
link.onerror = function () {
reject(new Error("style-load-failed"));
};
document.head.appendChild(link);
});
}

function renderShell() {
root.innerHTML =
'<div class="o-taxation-embed__calculator" data-taxation-calculator-host></div>';
}

function fetchWithTimeout(url, options, timeoutMs) {
if (typeof AbortController === "undefined") {
return fetch(url, options);
}
var controller = new AbortController();
var timer = setTimeout(function () {
controller.abort();
}, timeoutMs);
var opts = Object.assign({}, options, { signal: controller.signal });
return fetch(url, opts).finally(function () {
clearTimeout(timer);
});
}

async function attemptLoad() {
if (mounted) return true;
var baseUrl = normalizeBaseUrl(
root.getAttribute("data-taxation-remote-base-url"),
);
if (!baseUrl) {
renderWarning();
return true;
}

var lang = root.getAttribute("data-taxation-remote-lang") || "en";
var issuer = "db";
var i18nPayload = root.getAttribute("data-taxation-remote-i18n") || "{}";
var i18n = JSON.parse(i18nPayload);

var checkResponse = await fetchWithTimeout(
baseUrl + "data/index.json",
{ method: "GET", cache: "no-store", credentials: "omit" },
8000,
);
if (!checkResponse.ok) {
throw new Error("remote-unreachable");
}

await loadCss(baseUrl);
await loadModule(baseUrl + "js/calculatorEmbed.js");

renderShell();
var host = root.querySelector("[data-taxation-calculator-host]");
if (!host || !window.mountTaxationCalculator) {
throw new Error("calculator-mount-unavailable");
}

await window.mountTaxationCalculator({
host: host,
remoteBaseUrl: baseUrl,
iconBaseUrl: "/icons/material-symbols-rounded/",
lang: lang,
issuer: issuer,
i18n: i18n,
});
mounted = true;
return true;
}

var loading = false;
var mounted = false;

// Query the Local Network Access permission state.
// Returns "granted" | "prompt" | "denied", or null if the Permissions
// API (or this specific permission) is not available in the browser.
async function queryLocalNetworkPermission() {
var permissions = typeof navigator !== "undefined" && navigator.permissions;
if (!permissions || typeof permissions.query !== "function") {
return null;
}
try {
var status = await permissions.query({
name: "local-network-access",
});
return status;
} catch (_error) {
return null;
}
}

async function initOnce() {
if (loading) return;
loading = true;

// Track which notice is currently shown so we never re-render (and thus
// never "reset" or hide) a message that is already visible. Re-rendering
// the same notice would disrupt assistive technology.
var currentNotice = null; // "loading" | "notice" | null

function showNotice(kind, renderFn) {
if (currentNotice === kind) return;
currentNotice = kind;
renderFn();
}

// Poll the Local Network Access permission until it becomes "granted",
// then load. This "retry" is what recovers automatically when the user
// switches from block to allow later. No timeout ever hides or changes
// the notice, which keeps the flow accessible.
var POLL_INTERVAL = 1000;
var pollTimer = null;

function stopPolling() {
if (pollTimer !== null) {
clearInterval(pollTimer);
pollTimer = null;
}
}

async function loadCalculator() {
stopPolling();
showNotice("loading", renderLoading);
try {
await attemptLoad();
} catch (_error) {
// Access is granted but the remote is still unreachable. Show the
// combined notice; the poll keeps running in case it recovers.
showNotice("notice", renderWarning);
loading = false;
}
}

var status = await queryLocalNetworkPermission();

// No Permissions API: plain load attempt. The request still triggers the
// browser prompt; on failure show the combined notice.
if (!status) {
showNotice("loading", renderLoading);
try {
await attemptLoad();
} catch (_error) {
showNotice("notice", renderWarning);
} finally {
loading = false;
}
return;
}

if (status.state === "granted") {
await loadCalculator();
return;
}

// Not granted (prompt or denied). We do NOT distinguish "not on the
// intranet" from "blocked in the browser": in both cases we show the
// same combined notice (intranet connection + browser confirmation) and
// keep it visible. The fetch below only triggers the browser prompt; the
// notice never changes based on its outcome. A repeated permission query
// (onchange + polling) reloads automatically once access is granted.
function loadWhenGranted(state) {
if (state === "granted" && !mounted) {
loadCalculator();
}
}

showNotice("notice", renderWarning);

status.onchange = function () {
loadWhenGranted(status.state);
};

pollTimer = setInterval(async function () {
if (mounted) {
stopPolling();
return;
}
var latest = await queryLocalNetworkPermission();
if (latest) {
loadWhenGranted(latest.state);
}
}, POLL_INTERVAL);

// Trigger the request to make the browser show the prompt (when
// applicable). Its outcome is intentionally ignored: the notice stays
// put and the permission poll decides when to load.
attemptLoad().catch(function () {});
loading = false;
}

if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", initOnce, {
once: true,
});
} else {
initOnce();
}
})();
2 changes: 2 additions & 0 deletions assets/sass/_variables.scss
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ $fip-validity-colors-dark: (
);

$highlight-colors: (
success: map-get($tag-colors, success),
tip: #116278,
inofficial: #42454a,
important: map-get($tag-colors, warning),
Expand All @@ -49,6 +50,7 @@ $highlight-colors: (
);

$highlight-colors-dark: (
success: map-get($tag-colors-dark, success),
tip: #61bdd6,
inofficial: #acb3c1,
important: map-get($tag-colors-dark, warning),
Expand Down
1 change: 1 addition & 0 deletions assets/sass/main.scss
Original file line number Diff line number Diff line change
Expand Up @@ -26,4 +26,5 @@
@import "teamMember.scss";
@import "dialog.scss";
@import "fip-validity.scss";
@import "taxationEmbed.scss";
@import "404.scss";
22 changes: 22 additions & 0 deletions assets/sass/taxationEmbed.scss
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
.o-taxation-embed {
margin-top: 1rem;

&__loading {
margin-bottom: 1.2rem;
}

&__loading-content {
display: inline-flex;
align-items: center;
gap: 0.5rem;
font-weight: 500;
}

&__loading-content .a-icon {
font-size: 2rem;
}

.m-text-highlight {
margin-top: 0;
}
}
48 changes: 43 additions & 5 deletions content/application/db/index.de.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,25 +8,63 @@ Der FIP Ausweis ist für eine feste Periode von drei Jahren gültig. Die aktuell

## Versteuerung

FIP Freifahrtscheine von Mitarbeitenden in Deutschland gelten als _Geldwerter Vorteil_ und unterliegen § 8 des Einkommensteuergesetzes (EStG). Die FIP Freifahrtscheine sind somit steuer- und sozialversicherungspflichtig.
FIP Freifahrtscheine von Mitarbeitenden in Deutschland gelten als _Geldwerter Vorteil_ und unterliegen § 8 Abs. 2 des Einkommensteuergesetzes (EStG). Die FIP Freifahrtscheine sind somit steuer- und sozialversicherungspflichtig.

Der angerechnete Wert unterscheidet sich je nach Streckennetzlänge die mit dem jeweiligen Freifahrtschein gefahren werden kann und wird jährlich aktualisiert. Eine Liste der genauen aktuellen Beträge ist im DB Reisemarkt oder DB Personalportal verfügbar.
Der angerechnete Wert unterscheidet sich je nach Streckennetzlänge die mit dem jeweiligen Freifahrtschein gefahren werden kann und wird jährlich aktualisiert. Eine Liste der genauen aktuellen Beträge ist im [Steuerrechner](/general/taxation/), im DB Reisemarkt oder meineDB verfügbar.

Monatlich werden alle Sachbezugswerte zusammengerechnet (auch andere geldwerte Vorteile wie nationale Freifahrten). Wenn die Summe einen gewissen Grenzwert übersteigt (50 € in 2026) wird der gesamte Betrag (nicht nur, was über dem Grenzwert liegt, sondern alles) auf das zu versteuernde Einkommen (üblicherweise das Bruttogehalt) aufgeschlagen. Dementsprechend höher fallen in diesem Monat dann Steuer- und Sozialabgaben aus.
Monatlich werden alle Sachbezugswerte zusammengerechnet (auch andere geldwerte Vorteile wie nationale Freifahrten). Wenn die Summe den monatlichen Grenzwert übersteigt (50 € in 2026) wird der gesamte Betrag (nicht nur, was über dem Grenzwert liegt, sondern alles) auf das zu versteuernde Einkommen (üblicherweise das Bruttogehalt) aufgeschlagen. Dementsprechend höher fallen in diesem Monat dann Steuer- und Sozialabgaben aus.

Diese Regelung stellt eine Freigrenze dar. Das bedeutet, dass bereits bei geringfügigem Überschreiten die vollständige Besteuerung erfolgt.

{{% highlight tip %}}
Mit dem [DB Steuerrechner](/general/taxation/) kannst du den Sachbezugswert deiner Fahrvergünstigungen berechnen und deine Steuerlast über das Jahr optimieren.
{{% /highlight %}}

FIP Freifahrtscheine und nationale Fahrvergünstigungen von Angehörigen werden dem Mitarbeitenden angerechnet.

Für FIP 50 Tickets wird kein Geldwerter Vorteil berechnet.

### Besonderheiten bei einzelnen Konzernunternehmen

{{% highlight important %}}
Für Mitarbeitende der DB Fernverkehr AG, der DB InfraGO AG, der DB Bahnbau Gruppe GmbH und der DB Energie GmbH wird das TagesTicket M Fern als sogenannter Rabattfreibetrag nach § 8 Abs. 3 EStG versteuert.
{{% /highlight %}}

Diese Mitarbeitenden erhalten einen zusätzlichen jährlichen Freibetrag (1.080 € in 2026), bis zu dem kein Sachbezugswert für Tagestickets M Fern anfällt. Im Unterschied zur monatlichen Freigrenze handelt es sich um einen Freibetrag. Das bedeutet, dass bei Überschreiten nicht der Gesamtbetrag, sondern nur der übersteigende Anteil steuerpflichtig ist. Bei Überschreitung dieses Freibetrags, wird nur der übersteigende Anteil in diesem Monat individual versteuert (also direkt ohne Berücksichtigung der monatlichen 50 € Geldwerten Vorteil Freigrenze). Ab dem Folgemonat gilt für folgende Sachbezugswerte die monatliche Freigrenze von 50 €, in die auch internationale Fahrvergünstigungen eingerechnet werden.

Auch die persönliche Netzcard fällt in diesen Rabattfreibetrag. FIP Freifahrtscheine fallen jedoch nicht in diesen Rabattfreibetrag und werden weiterhin nach den allgemeinen Regeln für geldwerte Vorteile besteuert.

Weiterführende Informationen sind in meineDB zu finden.

{{% expander "Beispiel für einen ausgeschöpften Rabattfreibetrag" info %}}

Eine Person der DB Fernverkehr AG löst im Laufe des Jahres wiederholt das TagesTicket M Fern F der 2. Klasse ein (Sachbezugswert je 49,96 €). Die folgende Tabelle zeigt, welcher Anteil in den jährlichen Rabattfreibetrag (1.080 €) fällt, welcher Anteil individuell versteuert wird und ab wann die monatliche Freigrenze von 50 € greift:

| Ticket | Sachbezugswert | Jährlicher Rabattfreibetrag – 1.080 € | Individualversteuerung | Monatliche Freigrenze (Geldwerter Vorteil) – 50 € |
| ------------------------------------------------------------------ | -------------- | ------------------------------------- | ---------------------- | ------------------------------------------------- |
| 1.–21. TagesTicket M Fern F (2. Klasse) | 21 × 49,96 € | 1.049,16 € | – | – |
| 22. TagesTicket M Fern F (2. Klasse) | 49,96 € | 30,84 € | 19,12 € | – |
| **gleicher Monat:**<br>Weitere TagesTicket M Fern F (2. Klasse) | je 49,96 € | – | je 49,96 € | – |
| **ab dem Folgemonat:**<br>Weitere TagesTicket M Fern F (2. Klasse) | je 49,96 € | – | – | je 49,96 € |

**Erläuterung:**

- **Tickets 1–21:** Der Sachbezugswert von 21 × 49,96 € = 1.049,16 € liegt vollständig innerhalb des jährlichen Rabattfreibetrags von 1.080 €. Es fällt keine Versteuerung an; der Rabattfreibetrag ist danach bis auf 30,84 € ausgeschöpft.
- **22. Ticket:** Der Sachbezugswert von 49,96 € wird aufgeteilt. 30,84 € werden auf den verbleibenden Rabattfreibetrag angerechnet (Rabattfreibetrag danach 0 €), die übersteigenden 19,12 € werden individuell versteuert.
- **Weitere Tickets im selben Monat:** Der Rabattfreibetrag ist ausgeschöpft. Alle weiteren TagesTickets M Fern desselben Monats werden vollständig individuell versteuert (je 49,96 €). Die monatliche Freigrenze greift in diesem Monat nicht mehr, da der Rabattfreibetrag genau in diesem Monat überschritten wurde.
- **Ab dem Folgemonat:** Für die Varianten des TagesTicket M Fern F gilt nun die monatliche Freigrenze von 50 €, in die auch internationale Fahrvergünstigungen (z. B. FIP Freifahrtscheine) einfließen. Mit 49,96 € bleibt ein einzelnes Ticket knapp darunter; wird die Grenze durch weitere Vergünstigungen überschritten, ist der gesamte Monatsbetrag steuerpflichtig.

{{% /expander %}}

## Nationale Vergünstigungen (kein FIP)

Bei Fahrten ins Ausland, können Mitarbeitende der Deutschen Bahn für die Fahrt bis zum Grenztarifpunkt nationale Vergünstigungen nutzen. Dafür kann ein Tagesticket M Fern oder eine NetzCard genutzt werden. Diese gelten im gesamten Netz und somit bis zum Grenztarifpunkt zu den Nachbarländern.

Teilweise sind andere Tickets, die auf dem gesamten Abschnitt gelten, günstigere Alternativen zu FIP. Dazu gehören (Super) Sparpreise Europa DB-PEP (Nur in Verbindung mit einem Reisenden mit FIP, siehe Nutzungsbestimmungen) oder auch normale (Super) Sparpreise. Weitere Informationen dazu sind im DB Reisemarkt und DB Personalportal zu finden.
Teilweise sind andere Tickets, die auf dem gesamten Abschnitt gelten, günstigere Alternativen zu FIP. Dazu gehören (Super) Sparpreise Europa DB-PEP (Nur in Verbindung mit einem Reisenden mit FIP, siehe Nutzungsbestimmungen) oder auch normale (Super) Sparpreise. Weitere Informationen dazu sind im DB Reisemarkt und meineDB zu finden.

## FIP Freifahrtschein

Die Beantragung von FIP Freifahrtscheinen für DB Mitarbeitende muss mind. vier Wochen vor Gültigkeitsbeginn über das DB Personalportal erfolgen.
Die Beantragung von FIP Freifahrtscheinen für DB Mitarbeitende muss mind. vier Wochen vor Gültigkeitsbeginn über das meineDB erfolgen.

Freifahrtscheine können mit 1 bis 4 Feldern beantragt werden. Wenn bereits ein Freifahrtschein mit weniger als vier Feldern im gleichen Jahr beantragt wurde, verfallen die weiteren Felder und können nicht erneut beantragt werden. Jedes Feld wird individuell versteuert. Mehr Informationen dazu unter [Versteuerung](/general/fip-validity#versteuerung).

Expand Down
Loading
Loading