Delta-Sync
Geänderte Belege, Kontakte, Bankkonten und Umsätze regelmäßig abgleichen, ohne jedes Mal alles neu zu laden.
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
- Stand merken: Deine Anwendung speichert je Ressource den
updatedAt-Wert des zuletzt gelesenen Eintrags. Beim ersten Lauf gibt es noch keinen. - Mit Überlappung ansetzen: Der nächste Lauf beginnt 5 Minuten vor diesem Stand (siehe Warum die Überlappung).
- Änderungen holen: Eine Filteranfrage mit
rangeaufupdatedAtab diesem Zeitpunkt, sortiert nachupdatedAtaufsteigend, mit dem höchstenlimitvon 1.000. - Speichern: Jeden Eintrag unter seiner
idanlegen oder überschreiben. - Weiterblättern: Ist die Seite voll, setzt die nächste Anfrage beim
updatedAtdes letzten Eintrags an. Ist sie nicht voll, ist der Lauf fertig, und derupdatedAt-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"; fiWelche Ressourcen
| Ressource | Filterroute | Hinweis |
|---|---|---|
| Belege | POST /api/document/filter | Auch der Stand der Erkennung ändert updatedAt. |
| Kontakte | POST /api/contact/filter | |
| Bankkonten | POST /api/bankaccount/filter | |
| Umsätze | POST /api/banktransaction/filter | |
| Buchungen | POST /api/accounting/booking/filter | Nicht 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
updatedAtunverä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.