bimetricsEntwickler
Rezepte

Monatsexport

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

Als Markdown ansehen

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 mit range auf invoiceDate, sortiert nach invoiceDate. Kalenderdaten schreibst du als JJJJ-MM-TT (siehe Datentypen).
  2. Originaldateien: Für jeden Beleg mit Datei GET /api/document/{id}/content. 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 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".

#!/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"

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 aufgehoben. Über die dokumentierten Felder erkennst du solche Belege derzeit nicht sicher, der Export enthält sie deshalb mit (siehe 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. 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 zählst du einfach mit. Sie heben die stornierte Buchung auf.
  • Die Suche liefert Buchungen aller 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).
  • Filtere und sortiere Buchungen nicht nach amount; die API vergleicht dieses Attribut als Text (siehe Datentypen).

Auf dieser Seite