# Monatsexport (https://developer.bimetrics.de/recipes/monthly-export)

Die Belege eines Monats mit ihren Originaldateien und die Buchungen des Monats für ein eigenes Reporting abrufen.

Das Rezept holt für einen Monat drei Dinge: die Belege, ihre Originaldateien und die Buchungen. Ein zweiter Lauf lädt nur Dateien, die sich geändert haben.

## Ablauf

1. **Belege des Monats:** [`POST /api/document/filter`](https://developer.bimetrics.de/reference/documents/filter-documents.md) mit `range` auf `invoiceDate`, sortiert nach `invoiceDate`. Kalenderdaten schreibst du als `JJJJ-MM-TT` (siehe [Datentypen](/data-types#kalenderdaten)).
2. **Originaldateien:** Für jeden Beleg mit Datei [`GET /api/document/{id}/content`](https://developer.bimetrics.de/reference/documents/get-document-content.md). Den `ETag` der Antwort speicherst du und schickst ihn beim nächsten Lauf in `If-None-Match` mit. Ist die Datei unverändert, antwortet die API mit `304` ohne Inhalt.
3. **Buchungen des Monats:** [`POST /api/accounting/booking/filter`](https://developer.bimetrics.de/reference/accounting/filter-bookings.md) mit `range` auf `bookingDate`. Daraus bildest du etwa den Saldo je Konto.

## Code

Du brauchst einen Schlüssel mit der Berechtigung `read` in der Umgebungsvariable `BIMETRICS_API_KEY`. Das Beispiel exportiert September 2026 in den Ordner `export-2026-09`. Das JavaScript-Beispiel braucht Node.js ab Version 20 und läuft nur als ES-Modul, weil es `import` und `await` außerhalb von Funktionen nutzt: Speichere es als `monatsexport.mjs` oder setze in deiner `package.json` `"type": "module"`.

**cURL:**

```bash
#!/usr/bin/env bash
set -euo pipefail

API="https://app.bimetrics.de/api"
AUTH="Authorization: Bearer $BIMETRICS_API_KEY"
FROM="2026-09-01"
TO="2026-09-30"
OUT="export-2026-09"
mkdir -p "$OUT/dateien" "$OUT/etags"

# Alle Seiten einer Filterroute als ein JSON-Array
fetch_all() { # $1 = Pfad, $2 = Filter
  local offset=0 page count pages
  pages=$(mktemp)
  while :; do
    page=$(curl --fail --silent --show-error --retry 5 "$API$1" \
      -H "$AUTH" -H "Content-Type: application/json" \
      -d "$(jq -c --argjson offset "$offset" '. + {limit: 1000, offset: $offset}' <<<"$2")")
    echo "$page" >>"$pages"
    count=$(jq '.items | length' <<<"$page")
    offset=$((offset + count))
    if [ "$count" -eq 0 ] || [ "$offset" -ge "$(jq .total <<<"$page")" ]; then break; fi
  done
  jq -s '[.[].items[]]' "$pages"
  rm -f "$pages"
}

# 1. Belege mit Rechnungsdatum im Monat
documents=$(fetch_all /document/filter "$(jq -n --arg from "$FROM" --arg to "$TO" \
  '{kind: "range", attribute: "invoiceDate", lower: $from, upper: $to, sort: ["invoiceDate"]}')")
echo "$documents" >"$OUT/belege.json"

# 2. Originaldateien, nur geänderte laden
for id in $(jq -r '.[] | select(.uploadDocumentId != null) | .id' <<<"$documents"); do
  etag_file="$OUT/etags/$id"
  etag=$(cat "$etag_file" 2>/dev/null || true)
  status=$(curl --silent --retry 5 --output "$OUT/dateien/$id.tmp" --write-out '%{http_code}' \
    --dump-header "$OUT/dateien/$id.headers" \
    -H "$AUTH" ${etag:+-H "If-None-Match: $etag"} "$API/document/$id/content")
  case "$status" in
    200)
      mv "$OUT/dateien/$id.tmp" "$OUT/dateien/$id"
      grep -i '^etag:' "$OUT/dateien/$id.headers" | cut -d' ' -f2 | tr -d '\r' >"$etag_file"
      ;;
    304) rm -f "$OUT/dateien/$id.tmp" ;;  # unverändert
    *) echo "Beleg $id: HTTP $status" >&2; exit 1 ;;
  esac
  rm -f "$OUT/dateien/$id.headers"
done

# 3. Buchungen des Monats und Saldo je Konto: amount auf account, -amount auf accountContra
bookings=$(fetch_all /accounting/booking/filter "$(jq -n --arg from "$FROM" --arg to "$TO" \
  '{kind: "range", attribute: "bookingDate", lower: $from, upper: $to, sort: ["bookingDate"]}')")
echo "$bookings" >"$OUT/buchungen.json"
jq '[.[] | (.amount | tonumber * 100 | round) as $cents
      | {model: .accountingModel, account: .account, cents: $cents},
        {model: .accountingModel, account: .accountContra, cents: -$cents}]
    | group_by([.model, .account])
    | map({accountingModel: .[0].model, account: .[0].account, saldo: ((map(.cents) | add) / 100)})' \
  <<<"$bookings" >"$OUT/salden.json"
```

**JavaScript:**

```js
import { mkdir, readFile, writeFile } from "node:fs/promises";
import { setTimeout as sleep } from "node:timers/promises";

const api = "https://app.bimetrics.de/api";
const auth = { Authorization: `Bearer ${process.env.BIMETRICS_API_KEY}` };
const [from, to, out] = ["2026-09-01", "2026-09-30", "export-2026-09"];

// Sendet eine Anfrage und wiederholt sie bei 429 (nach Retry-After) und 5xx.
async function request(path, init = {}, attempts = 5) {
  for (let attempt = 1; ; attempt++) {
    const response = await fetch(api + path, { ...init, headers: { ...auth, ...init.headers } });
    if ((response.status === 429 || response.status >= 500) && attempt < attempts) {
      await sleep((Number(response.headers.get("Retry-After")) || 2 ** attempt) * 1000);
      continue;
    }
    if (!response.ok && response.status !== 304) throw new Error(`${response.status}: ${await response.text()}`);
    return response;
  }
}

// Alle Seiten einer Filterroute
async function fetchAll(path, filter) {
  const items = [];
  for (;;) {
    const response = await request(path, {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ ...filter, limit: 1000, offset: items.length }),
    });
    const page = await response.json();
    items.push(...page.items);
    if (page.items.length === 0 || items.length >= page.total) return items;
  }
}

await mkdir(`${out}/dateien`, { recursive: true });
const etags = JSON.parse(await readFile(`${out}/etags.json`, "utf8").catch(() => "{}"));

// 1. Belege mit Rechnungsdatum im Monat
const documents = await fetchAll("/document/filter", {
  kind: "range", attribute: "invoiceDate", lower: from, upper: to, sort: ["invoiceDate"],
});
await writeFile(`${out}/belege.json`, JSON.stringify(documents, null, 2));

// 2. Originaldateien, nur geänderte laden
for (const document of documents) {
  if (!document.uploadDocumentId) continue; // Belege aus Verträgen oder Serien haben keine Datei
  const headers = etags[document.id] ? { "If-None-Match": etags[document.id] } : {};
  const response = await request(`/document/${document.id}/content`, { headers });
  if (response.status === 304) continue; // unverändert
  await writeFile(`${out}/dateien/${document.id}`, Buffer.from(await response.arrayBuffer()));
  etags[document.id] = response.headers.get("ETag");
}
await writeFile(`${out}/etags.json`, JSON.stringify(etags));

// 3. Buchungen des Monats und Saldo je Konto: amount auf account, -amount auf accountContra
const bookings = await fetchAll("/accounting/booking/filter", {
  kind: "range", attribute: "bookingDate", lower: from, upper: to, sort: ["bookingDate"],
});
await writeFile(`${out}/buchungen.json`, JSON.stringify(bookings, null, 2));

const saldo = new Map(); // "accountingModel konto" -> Cent
for (const booking of bookings) {
  const cents = Math.round(Number(booking.amount) * 100);
  for (const [account, value] of [[booking.account, cents], [booking.accountContra, -cents]]) {
    const key = `${booking.accountingModel} ${account}`;
    saldo.set(key, (saldo.get(key) ?? 0) + value);
  }
}
for (const [key, cents] of [...saldo].sort()) console.log(key, (cents / 100).toFixed(2));
```

**Python:**

```python
import json
import os
import pathlib
import time
from collections import defaultdict
from decimal import Decimal

import requests

API = "https://app.bimetrics.de/api"
FROM, TO = "2026-09-01", "2026-09-30"
OUT = pathlib.Path("export-2026-09")
session = requests.Session()
session.headers["Authorization"] = f"Bearer {os.environ['BIMETRICS_API_KEY']}"

def request(method, path, attempts=5, **kwargs):
    """Sendet eine Anfrage und wiederholt sie bei 429 (nach Retry-After) und 5xx."""
    for attempt in range(1, attempts + 1):
        response = session.request(method, API + path, timeout=60, **kwargs)
        if (response.status_code == 429 or response.status_code >= 500) and attempt < attempts:
            time.sleep(int(response.headers.get("Retry-After", 2**attempt)))
            continue
        if response.status_code != 304:
            response.raise_for_status()
        return response

def fetch_all(path, body):
    """Alle Seiten einer Filterroute."""
    items = []
    while True:
        page = request("POST", path, json={**body, "limit": 1000, "offset": len(items)}).json()
        items += page["items"]
        if not page["items"] or len(items) >= page["total"]:
            return items

(OUT / "dateien").mkdir(parents=True, exist_ok=True)
etag_file = OUT / "etags.json"
etags = json.loads(etag_file.read_text()) if etag_file.exists() else {}

# 1. Belege mit Rechnungsdatum im Monat
documents = fetch_all("/document/filter", {
    "kind": "range", "attribute": "invoiceDate", "lower": FROM, "upper": TO, "sort": ["invoiceDate"],
})
(OUT / "belege.json").write_text(json.dumps(documents, indent=2))

# 2. Originaldateien, nur geänderte laden
for document in documents:
    if not document["uploadDocumentId"]:
        continue  # Belege aus Verträgen oder Serien haben keine Datei
    headers = {"If-None-Match": etags[document["id"]]} if document["id"] in etags else {}
    response = request("GET", f"/document/{document['id']}/content", headers=headers)
    if response.status_code == 304:
        continue  # unverändert
    (OUT / "dateien" / document["id"]).write_bytes(response.content)
    etags[document["id"]] = response.headers["ETag"]
etag_file.write_text(json.dumps(etags))

# 3. Buchungen des Monats und Saldo je Konto: amount auf account, -amount auf accountContra
bookings = fetch_all("/accounting/booking/filter", {
    "kind": "range", "attribute": "bookingDate", "lower": FROM, "upper": TO, "sort": ["bookingDate"],
})
(OUT / "buchungen.json").write_text(json.dumps(bookings, indent=2))

saldo = defaultdict(Decimal)
for booking in bookings:
    amount = Decimal(booking["amount"])
    saldo[(booking["accountingModel"], booking["account"])] += amount
    saldo[(booking["accountingModel"], booking["accountContra"])] -= amount
for (model, account), value in sorted(saldo.items()):
    print(model, account, value)
```

## Belege

* Belege ohne Rechnungsdatum, etwa während der Erkennung, fehlen in dieser Suche. Sie kommen beim nächsten Lauf dazu, sobald das Datum erkannt ist.
* Ausgangsrechnungen und Eingangsbelege kommen gemeinsam. Mit einem zweiten Ausdruck `{ "kind": "eq", "attribute": "kind", "value": "receipt" }` in einem `and` beschränkst du den Export auf eine Art.
* Belege aus Verträgen oder Serien haben keine Originaldatei. Ihr `uploadDocumentId` ist `null`, und `/content` antwortet mit `404`.
* Ein gelöschter Beleg mit festgeschriebenen Buchungen bleibt in den Treffern, mit Rechnungsdatum und Originaldatei. Seine `invoicePositions` sind `null`, und seine Buchungen sind per [Storno](/glossary#storno) aufgehoben. Über die dokumentierten Felder erkennst du solche Belege derzeit nicht sicher, der Export enthält sie deshalb mit (siehe [Festschreibung](/glossary#festschreibung)).

## Originaldateien

* Der `ETag` ist der SHA-256 der Datei als Hex-Wert, ohne Anführungszeichen. Schicke ihn genau so in `If-None-Match` zurück. Ein Wert in Anführungszeichen oder eine Liste mehrerer Werte trifft nicht.
* Derselbe Wert steht auch im Beleg in `uploadDocument.blob.sha256`. Stimmt er mit deinem gespeicherten Wert überein, kannst du die Anfrage ganz sparen.
* Der Dateityp steht in `uploadDocument.blob.mime`, der ursprüngliche Dateiname in `uploadDocument.blob.originalFilename`.
* Jeder Abruf zählt zu den [Limits](/limits). Die Beispiele warten bei `429` die Zeit aus `Retry-After` ab.

## Buchungen

* Das Vorzeichen von `amount` bezieht sich auf `account`: größer als 0 ist Soll, kleiner als 0 ist Haben. Das Gegenkonto hat die umgekehrte Seite. Deshalb zählt das Beispiel `amount` auf `account` und `-amount` auf `accountContra`.
* [Stornobuchungen](/glossary#storno) zählst du einfach mit. Sie heben die stornierte Buchung auf.
* Die Suche liefert Buchungen aller [Kontenrahmen](/glossary#kontenrahmen), in denen die Firma gebucht hat. Das Beispiel rechnet deshalb getrennt nach `accountingModel`.
* Nicht festgeschriebene Buchungen ändern sich, solange sich Belege, Umsätze oder Zuordnungen ändern. Hole die Buchungen für jeden Bericht deshalb neu, statt einen alten Stand fortzuschreiben. Ob eine Buchung festgeschrieben ist, zeigt `finalized` (siehe [Glossar](/glossary#festschreibung)).
* Filtere und sortiere Buchungen nicht nach `amount`; die API vergleicht dieses Attribut als Text (siehe [Datentypen](/data-types#beträge-von-buchungen)).
