Tesla Ladekosten-Rechnungen abrufen

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)

  1. Auf developer.tesla.com eine App anlegen.
  2. Client ID und Client Secret notieren.
  3. Redirect-URL hinterlegen: http://localhost:5173/callback.
  4. Billing aktivieren und Limits setzen. Deshalb keine Sorge, man kann hier die Berechtigungen für diesen Zugang sehr genau einstellen.
  5. Scopes: openid offline_access vehicle_charging_cmds.

Hinweis: Region für Deutschland = EMEA. Basis-URL siehe .env unten.

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