bimetricsEntwickler
Rezepte

Delta-Sync

Geänderte Belege, Kontakte, Bankkonten und Umsätze regelmäßig abgleichen, ohne jedes Mal alles neu zu laden.

Als Markdown ansehen

Webhooks gibt es nicht. Deine Anwendung fragt deshalb in festen Abständen ab, was sich seit dem letzten Abgleich geändert hat, zum Beispiel alle 15 Minuten. Dafür hat jede Filterroute das Attribut updatedAt, den Zeitpunkt der letzten Änderung, und kann danach sortieren.

Ablauf

  1. Stand merken: Deine Anwendung speichert je Ressource den updatedAt-Wert des zuletzt gelesenen Eintrags. Beim ersten Lauf gibt es noch keinen.
  2. Mit Überlappung ansetzen: Der nächste Lauf beginnt 5 Minuten vor diesem Stand (siehe Warum die Überlappung).
  3. Änderungen holen: Eine Filteranfrage mit range auf updatedAt ab diesem Zeitpunkt, sortiert nach updatedAt aufsteigend, mit dem höchsten limit von 1.000.
  4. Speichern: Jeden Eintrag unter seiner id anlegen oder überschreiben.
  5. Weiterblättern: Ist die Seite voll, setzt die nächste Anfrage beim updatedAt des letzten Eintrags an. Ist sie nicht voll, ist der Lauf fertig, und der updatedAt-Wert des letzten Eintrags wird der neue Stand.

Innerhalb eines Laufs setzt die nächste Seite beim letzten Zeitpunkt an, statt mit offset weiterzuzählen. So verschieben Änderungen während des Laufs die Seiten nicht. offset braucht das Beispiel nur im seltenen Fall, dass eine ganze Seite denselben Zeitpunkt hat.

lower schließt den Wert selbst ein, und die Überlappung liest die letzten Minuten erneut. Viele Einträge bekommst du deshalb zweimal. Weil du nach id speicherst, schadet das nicht.

Warum die Überlappung

bimetrics setzt updatedAt, wenn ein Eintrag geschrieben wird. Sichtbar wird die Änderung aber erst, wenn bimetrics den ganzen Vorgang gespeichert hat, also etwas später. Ein Eintrag kann deshalb erst nach deinem Lauf in den Treffern auftauchen, obwohl sein updatedAt vor dem gespeicherten Stand liegt. Ohne Überlappung würde ihn kein späterer Lauf mehr lesen.

Die 5 Minuten fangen solche Nachzügler ab. Ganz ausschließen lassen sie sich damit nicht. Muss deine Anwendung jeden Eintrag sicher haben, gleiche zusätzlich in größeren Abständen den vollständigen Bestand ab, wie unter Grenzen beschrieben.

Code

Das Beispiel gleicht Belege ab (POST /api/document/filter). Für Kontakte, Bankkonten und Umsätze tauschst du nur den Pfad aus. Du brauchst einen Schlüssel mit der Berechtigung read in der Umgebungsvariable BIMETRICS_API_KEY. 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 delta-sync.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"
STATE="stand-belege.txt"  # updatedAt des zuletzt gelesenen Belegs
LIMIT=1000
mkdir -p belege

# Zeitpunkt nach RFC 3339 minus 5 Minuten, als UTC
minus_overlap() {
  jq -rn --arg t "$1" '$t
    | capture("^(?<utc>.{19})(\\.[0-9]+)?(?<tz>Z|(?<sign>[+-])(?<h>[0-9]{2}):(?<m>[0-9]{2}))$")
    | (.utc + "Z" | fromdateiso8601)
      - (if .tz == "Z" then 0 else (if .sign == "-" then -1 else 1 end) * ((.h | tonumber) * 3600 + (.m | tonumber) * 60) end)
      - 300
    | todateiso8601'
}

stored=$(cat "$STATE" 2>/dev/null || true)
newest=$stored
cursor=""
if [ -n "$stored" ]; then cursor=$(minus_overlap "$stored"); fi
offset=0
while :; do
  if [ -n "$cursor" ]; then
    filter=$(jq -n --arg lower "$cursor" '{kind: "range", attribute: "updatedAt", lower: $lower}')
  else
    filter='{"kind": "and", "children": []}'
  fi
  body=$(jq -c --argjson limit "$LIMIT" --argjson offset "$offset" \
    '. + {sort: ["updatedAt"], direction: ["asc"], limit: $limit, offset: $offset}' <<<"$filter")
  page=$(curl --fail --silent --show-error --retry 5 "$API/document/filter" \
    -H "$AUTH" -H "Content-Type: application/json" -d "$body")

  # Jeden Beleg unter seiner ID speichern, hier als Datei
  jq -c '.items[]' <<<"$page" | while read -r item; do
    jq . <<<"$item" >"belege/$(jq -r .id <<<"$item").json"
  done

  count=$(jq '.items | length' <<<"$page")
  if [ "$count" -eq 0 ]; then break; fi
  last=$(jq -r '.items[-1].updatedAt' <<<"$page")
  newest=$last
  if [ "$count" -lt "$LIMIT" ]; then break; fi
  if [ "$last" = "$cursor" ]; then
    offset=$((offset + LIMIT))  # ganze Seite mit demselben Zeitpunkt
  else
    cursor=$last
    offset=0
  fi
done
if [ -n "$newest" ]; then echo "$newest" >"$STATE"; fi

Welche Ressourcen

RessourceFilterrouteHinweis
BelegePOST /api/document/filterAuch der Stand der Erkennung ändert updatedAt.
KontaktePOST /api/contact/filter
BankkontenPOST /api/bankaccount/filter
UmsätzePOST /api/banktransaction/filter
BuchungenPOST /api/accounting/booking/filterNicht per Delta-Sync, siehe unten.

Speichere den Stand je Ressource getrennt. updatedAt gilt nur für den Datensatz selbst. Die Zuordnung in match und die Buchungen in bookings eines Belegs oder Umsatzes können sich ändern, ohne dass sich sein updatedAt ändert (siehe Grenzen).

Grenzen

Zuordnungen und Buchungen ändern updatedAt nicht

Ordnet jemand einen Beleg einem Umsatz zu oder hebt eine Zuordnung auf, bleibt updatedAt von Beleg und Umsatz gleich. Auch die Buchungen ändern sich ohne neues updatedAt, etwa wenn bimetrics sie nach einer neuen Zuordnung neu berechnet oder beim DATEV-Export festschreibt. Ein Delta-Sync hält match, also etwa den offenen Betrag in match.amountOpen und den Skonto in match.cashDiscount, und bookings deshalb nicht aktuell. Brauchst du den Zahlungsstand, lies den Eintrag direkt vor der Verwendung neu, etwa mit GET /api/document/{id} oder GET /api/banktransaction/{id}, oder gleiche den Bestand regelmäßig vollständig ab wie bei Löschungen.

Löschungen sind nicht erkennbar

Die API meldet nicht, dass oder wann etwas gelöscht wurde. Gelöschte Kontakte, Bankkonten und Umsätze erscheinen nicht mehr in den Treffern, ebenso gelöschte Belege ohne festgeschriebene Buchungen. Ein Delta-Sync bemerkt das nicht. Muss deine Anwendung Löschungen nachziehen, gleiche in größeren Abständen den vollständigen Bestand ab, zum Beispiel einmal pro Nacht, und entferne Einträge, deren id dabei nicht mehr vorkommt.

Einen Beleg mit festgeschriebenen Buchungen entfernt bimetrics beim Löschen nicht. Er bleibt in den Treffern, seine invoicePositions sind danach null, und seine Buchungen hebt bimetrics per Storno auf (siehe Festschreibung). Über die dokumentierten Felder erkennst du einen solchen Beleg derzeit nicht sicher, auch nicht beim vollständigen Abgleich.

  • Buchungen: Solange eine Buchung nicht festgeschrieben ist, berechnet bimetrics sie bei Änderungen neu. Die neuen Buchungen bekommen neue IDs, die bisherigen verschwinden aus den Treffern (siehe Glossar). Ein Delta-Sync würde die alten behalten. Hole Buchungen deshalb je Zeitraum vollständig neu, wie im Monatsexport.
  • Zeitzone: Übernimm updatedAt unverändert aus der Antwort. Der Wert enthält die Zeitzone (siehe Datentypen).
  • Limits: Ein Lauf braucht eine Anfrage je 1.000 geänderte Einträge. Verteile die Abgleiche mehrerer Ressourcen, damit du unter den Limits bleibst.

Auf dieser Seite