Für alle noch (trotz Elon Musk) Teslafahrer …
… die jeden Monat genervt sind, die Ladekosten-Rechnungen aus der Tesla-App herunterzuladen, um sie der Buchhaltung zu übermitteln: Hier ist eine kleine Node.js-Lösung, die eure Tesla-Supercharger-Rechnungen automatisch per API zieht und als PDF ablegt.
Was die Lösung macht
- OAuth-Login mit eurem Tesla-Konto
- Ladevorgänge (History) abrufen
- Zu jedem Eintrag die Rechnung als PDF herunterladen
- PDFs lokal im Ordner
./invoices/speichern
Voraussetzungen
- Node.js (LTS empfohlen)
- Ein Tesla Developer Account mit eigener Client ID/Secret
- Billing im Tesla Dev-Portal aktiv (Tesla Fleet API ist pay-per-use)
Wie geht das ganz konkret. Hier die Anleitung:
1) Node.js installieren
- Windows: [nodejs.org] herunterladen und installieren (LTS).
- Prüfen in PowerShell/Terminal:
node -v
npm -v
2) Tesla Developer einrichten (einmalig)
- Auf developer.tesla.com eine App anlegen.
- Client ID und Client Secret notieren.
- Redirect-URL hinterlegen:
http://localhost:5173/callback. - Billing aktivieren und Limits setzen. Deshalb keine Sorge, man kann hier die Berechtigungen für diesen Zugang sehr genau einstellen.
- Scopes:
openid offline_access vehicle_charging_cmds.
Hinweis: Region für Deutschland = EMEA. Basis-URL siehe
.envunten.
3) Projekt anlegen
In einem leeren Ordner (z. B. tesla-app) ausführen, im Fenster von nodes.js.
Dadurch werden verschiedenen Dateien erzeugt und Ordner angelegt.
npm init -y
npm i express axios open dotenv qs
4) .env anlegen
Datei .env im Projektordner erstellen und anpassen:
(Hier kann man auch den Zeitraum für den Abruf der Rechnungen angeben)
# ---- Tesla OAuth / API ----
TESLA_CLIENT_ID=deine-client-id
TESLA_CLIENT_SECRET=dein-client-secret
TESLA_REDIRECT_URI=http://localhost:5173/callback
# EMEA-Basis-URL (DE/EU)
TESLA_FLEET_API_BASE=https://fleet-api.prd.eu.vn.cloud.tesla.com
# Auth-Server für Token-Exchange
TESLA_TOKEN_URL=https://fleet-auth.prd.vn.cloud.tesla.com/oauth2/v3/token
# Browser-Login
TESLA_AUTHORIZE_URL=https://auth.tesla.com/oauth2/v3/authorize
# Scopes: Ladehistorie & Rechnungen
TESLA_SCOPES=openid offline_access vehicle_charging_cmds
# Paginierungsschutz
MAX_PAGES=50
# Optionaler Datumsfilter (ISO 8601, UTC 'Z'):
# START_TIME=2025-01-01T00:00:00Z
# END_TIME=2025-12-31T23:59:59Z
5) index.js einfügen
Eine Datei index.js erstellen (komplett so übernehmen):
// index.js
require('dotenv').config();
const express = require('express');
const axios = require('axios');
const qs = require('qs');
const fs = require('fs');
const path = require('path');
// ---------------------------
// Basis-Konfiguration
// ---------------------------
const PORT = Number(process.env.PORT || 5173);
const CLIENT_ID = process.env.TESLA_CLIENT_ID;
const CLIENT_SECRET = process.env.TESLA_CLIENT_SECRET;
const REDIRECT_URI = process.env.TESLA_REDIRECT_URI || `http://localhost:${PORT}/callback`;
const AUTHORIZE_URL =
process.env.TESLA_AUTHORIZE_URL || 'https://auth.tesla.com/oauth2/v3/authorize';
const TOKEN_URL =
process.env.TESLA_TOKEN_URL || 'https://fleet-auth.prd.vn.cloud.tesla.com/oauth2/v3/token';
const FLEET_API_BASE =
process.env.TESLA_FLEET_API_BASE || 'https://fleet-api.prd.eu.vn.cloud.tesla.com';
// Scopes: Zugriff auf Ladehistorie & Rechnungen
const SCOPES = (process.env.TESLA_SCOPES || 'openid offline_access vehicle_charging_cmds').trim();
// Optional: Zeitraum eingrenzen (ISO 8601)
const START_TIME = process.env.START_TIME || ''; // z.B. 2025-01-01T00:00:00Z
const END_TIME = process.env.END_TIME || ''; // z.B. 2025-12-31T23:59:59Z
// Sicherheitslimit für Paginierung
const MAX_PAGES = Number(process.env.MAX_PAGES || 50);
// ---------------------------
// Pfade / Storage
// ---------------------------
const TOKENS_FILE = path.join(__dirname, 'token.json');
const INVOICES_DIR = path.join(__dirname, 'invoices');
if (!fs.existsSync(INVOICES_DIR)) fs.mkdirSync(INVOICES_DIR, { recursive: true });
// ---------------------------
// ESM-only 'open' -> dynamischer Import
// ---------------------------
async function openBrowser(url) {
try {
const { default: open } = await import('open');
await open(url);
} catch (e) {
console.log('Öffne bitte manuell:', url);
}
}
// ---------------------------
// Token-Handling
// ---------------------------
let tokens = null;
function saveTokens(obj) {
tokens = {
access_token: obj.access_token,
refresh_token: obj.refresh_token || tokens?.refresh_token,
expires_in: obj.expires_in,
obtained_at: Date.now()
};
fs.writeFileSync(TOKENS_FILE, JSON.stringify(tokens, null, 2));
}
function loadTokens() {
if (fs.existsSync(TOKENS_FILE)) {
try {
tokens = JSON.parse(fs.readFileSync(TOKENS_FILE, 'utf-8'));
} catch {
tokens = null;
}
}
}
async function refreshIfNeeded() {
if (!tokens?.access_token) return;
const expiresAt = (tokens.obtained_at || 0) + (tokens.expires_in || 0) * 1000;
const safety = 60 * 1000; // 60s Puffer
if (Date.now() < expiresAt - safety) return;
console.log('🔄 Erneuere Access Token …');
const body = qs.stringify({
grant_type: 'refresh_token',
client_id: CLIENT_ID,
refresh_token: tokens.refresh_token
});
const { data } = await axios.post(TOKEN_URL, body, {
headers: { 'Content-Type': 'application/x-www-form-urlencoded' }
});
saveTokens(data);
}
function buildAuthorizeUrl() {
const params = {
response_type: 'code',
client_id: CLIENT_ID,
redirect_uri: REDIRECT_URI,
scope: SCOPES,
state: Math.random().toString(36).slice(2) + Date.now(),
prompt_missing_scopes: 'true'
};
return `${AUTHORIZE_URL}?${qs.stringify(params)}`;
}
async function exchangeCodeForTokens(code) {
const body = qs.stringify({
grant_type: 'authorization_code',
client_id: CLIENT_ID,
client_secret: CLIENT_SECRET,
code,
audience: FLEET_API_BASE, // wichtig: Audience = regionale Base-URL
redirect_uri: REDIRECT_URI
});
const { data } = await axios.post(TOKEN_URL, body, {
headers: { 'Content-Type': 'application/x-www-form-urlencoded' }
});
saveTokens(data);
}
// ---------------------------
// HTTP Helper
// ---------------------------
async function apiGet(url, config = {}) {
await refreshIfNeeded();
const res = await axios.get(url, {
...config,
headers: {
...(config.headers || {}),
Authorization: `Bearer ${tokens.access_token}`
},
validateStatus: () => true
});
if (res.status === 401) throw new Error('401 Unauthorized – Token abgelaufen oder Login erforderlich.');
if (res.status === 402) throw new Error('402 Payment Required – bitte Billing im Tesla Dev-Portal aktivieren.');
if (res.status === 403) throw new Error('403 Forbidden – fehlende Berechtigung/Scopes (vehicle_charging_cmds?).');
const data = res.data;
if (data?.error) throw new Error(`${data.error}: ${data.error_description || ''}`);
return data;
}
// ---------------------------
// History/Invoice Hilfsfunktionen
// ---------------------------
function pickEventsFromHistoryPayload(payload) {
// Robust gegen verschiedene Formen
const r = payload?.response ?? payload;
let events = [];
if (Array.isArray(r)) events = r;
else if (Array.isArray(r?.records)) events = r.records;
else if (Array.isArray(r?.charging_history)) events = r.charging_history;
else if (Array.isArray(r?.data)) events = r.data;
else if (Array.isArray(r?.items)) events = r.items;
else if (Array.isArray(payload?.data)) events = payload.data;
const pagination =
r?.pagination || payload?.pagination || r?.links || payload?.links || {};
const next = pagination?.next || r?.next || payload?.next || null;
return { events, next };
}
function makeAbsoluteNext(next) {
if (!next) return null;
if (/^https?:\/\//i.test(next)) return next;
if (typeof next === 'string' && next.startsWith('/')) return `${FLEET_API_BASE}${next}`;
return null; // Cursor-only: nicht unterstützt
}
function sanitize(name) {
return (name || '')
.toString()
.replace(/[^\p{L}\p{N}\-_\. ]/gu, '_')
.slice(0, 160);
}
function guessFileName(ev, id) {
const ts =
ev?.chargeStartDateTime ||
ev?.start_time ||
ev?.startTime ||
ev?.timestamp ||
'';
const date = ts ? new Date(ts).toISOString().slice(0, 10) : '';
const loc = sanitize(
ev?.siteLocationName ||
ev?.site_name ||
ev?.location ||
ev?.address ||
''
);
const base = [date, loc, id].filter(Boolean).join('_');
return base ? `${base}.pdf` : `invoice_${id}.pdf`;
}
function extractInvoiceIds(ev) {
const out = new Set();
// Üblicher Fall: invoices[].contentId
if (Array.isArray(ev?.invoices)) {
for (const inv of ev.invoices) {
if (inv?.contentId) out.add(inv.contentId);
if (inv?.id) out.add(inv.id); // manche liefern 'id'
}
} else if (ev?.invoices?.contentId) {
out.add(ev.invoices.contentId);
}
// Fallbacks (selten)
if (Array.isArray(ev?.fees)) {
for (const fee of ev.fees) {
if (fee?.invoice?.contentId) out.add(fee.invoice.contentId);
if (fee?.invoiceId) out.add(fee.invoiceId);
}
}
if (ev?.invoiceId) out.add(ev.invoiceId);
if (ev?.invoice_id) out.add(ev.invoice_id);
return [...out];
}
async function downloadInvoice(id, ev) {
const url = `${FLEET_API_BASE}/api/1/dx/charging/invoice/${id}`;
const res = await axios.get(url, {
responseType: 'arraybuffer',
headers: {
Authorization: `Bearer ${tokens.access_token}`,
Accept: 'application/pdf'
},
validateStatus: s => s < 500
});
if (res.status === 404) throw new Error('404 – Rechnung nicht gefunden (ID ungültig oder noch nicht erzeugt).');
if (res.status === 403) throw new Error('403 – Scope/Berechtigung fehlt (vehicle_charging_cmds?).');
if (res.status === 401) throw new Error('401 – Token abgelaufen, bitte neu anmelden.');
if (res.status >= 400) throw new Error(`${res.status} – Unerwarteter Fehler beim Download.`);
const fileName = guessFileName(ev, id);
const full = path.join(INVOICES_DIR, fileName);
fs.writeFileSync(full, res.data);
console.log(`💾 gespeichert: ${full}`);
}
// ---------------------------
// Hauptablauf: History -> PDFs
// ---------------------------
async function fetchAllHistoryAndInvoices() {
let url = `${FLEET_API_BASE}/api/1/dx/charging/history`;
const params = {};
if (START_TIME) params.startTime = START_TIME;
if (END_TIME) params.endTime = END_TIME;
const query = qs.stringify(params);
if (query) url = `${url}?${query}`;
let debugPrinted = false;
for (let page = 1; page <= MAX_PAGES && url; page++) {
console.log(`➡️ Lade History (Seite ${page}) …`);
const payload = await apiGet(url);
const { events, next } = pickEventsFromHistoryPayload(payload);
if (!Array.isArray(events) || events.length === 0) {
console.log('Keine Einträge auf dieser Seite.');
break;
}
for (const ev of events) {
const ids = extractInvoiceIds(ev);
if (!ids.length) {
console.log('⚠️ Keine Rechnungs-ID im Event gefunden. Session:', ev?.sessionId ?? ev?.id ?? 'unbekannt');
if (!debugPrinted) {
console.log('🔎 Debug – verfügbare Schlüssel:', Object.keys(ev));
console.log('🔎 Debug – ev.invoices:', ev?.invoices);
debugPrinted = true;
}
continue;
}
for (const id of ids) {
try {
await downloadInvoice(id, ev);
} catch (e) {
console.log(`❌ Konnte Rechnung ${id} nicht laden: ${e.message}`);
}
}
}
url = makeAbsoluteNext(next);
}
console.log('✅ Fertig.');
}
// ---------------------------
// Express-Server & Routen
// ---------------------------
const APP = express();
APP.get('/', async (_req, res) => {
if (tokens?.access_token) {
res.send('<h1>Tesla Invoice Downloader</h1><p>Token vorhanden. Starte Download … bitte Konsole beobachten.</p>');
fetchAllHistoryAndInvoices().catch(err => console.error(err));
} else {
res.redirect('/login');
}
});
APP.get('/login', (_req, res) => {
const url = buildAuthorizeUrl();
res.send(`<a href="${url}">Bei Tesla einloggen</a>`);
openBrowser(url);
});
APP.get('/callback', async (req, res) => {
const code = req.query.code;
if (!code) return res.status(400).send('Kein Code übergeben.');
try {
await exchangeCodeForTokens(code);
res.send('Login erfolgreich! Du kannst dieses Fenster schließen. Der Download startet in der Konsole …');
setTimeout(() => fetchAllHistoryAndInvoices().catch(console.error), 500);
} catch (e) {
console.error(e);
res.status(500).send('Token-Tausch fehlgeschlagen: ' + e.message);
}
});
// ---------------------------
// Start
// ---------------------------
loadTokens();
APP.listen(PORT, () => {
console.log(`🚀 Läuft auf http://localhost:${PORT}`);
if (!tokens?.access_token) {
console.log('Kein Token gefunden – öffne Login im Browser…');
}
});
6) Starten
Im Projektordner innerhalb des node.js Fensters:
node index.js
- Browser öffnet sich → mit Tesla-Konto einloggen, Zugriff erlauben.
- Danach erscheinen die PDFs im Ordner
invoices/. - Tokens liegen in
token.json(Refresh wird automatisch gehandhabt).
7) Nützliche Befehle (Cheat-Sheet)
# Projekt initialisieren
npm init -y
# Abhängigkeiten installieren
npm i express axios open dotenv qs
# App starten
node index.js
Optional (nur wenn du den Port ändern willst):
$env:PORT=8080
node index.js
