Delta-Sync
Geänderte und gelöschte Belege, Kontakte, Bankkonten, Umsätze und Buchungen 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 haben die Filterrouten für Belege, Kontakte, Bankkonten, Umsätze und Buchungen das Attribut updatedAt, den Zeitpunkt der letzten Änderung, und das Attribut deletedAt, den Zeitpunkt, seit dem ein Eintrag als gelöscht gilt. Nach beiden lässt sich sortieren. Welche Attribute die übrigen Filterrouten haben, steht unter Filtern.
Ein Lauf hat zwei Durchgänge: Der erste holt neue und geänderte Einträge über updatedAt, der zweite gelöschte über deletedAt. Löschen ändert updatedAt nicht, der erste Durchgang allein bemerkt Löschungen deshalb nicht.
Ablauf
- Stand merken: Deine Anwendung speichert je Ressource zwei Werte: den
updatedAt-Wert des zuletzt gelesenen Eintrags und dendeletedAt-Wert des zuletzt gelesenen gelöschten Eintrags. Beim ersten Lauf gibt es noch keinen. - Mit Überlappung ansetzen: Jeder Durchgang beginnt 5 Minuten vor seinem 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 Durchgang fertig, und derupdatedAt-Wert des letzten Eintrags wird der neue Stand. - Löschungen holen: Der zweite Durchgang läuft genauso, mit
"includeDeleted": true,rangeund Sortierung aufdeletedAt. Jeden Eintrag, den er liefert, entfernst du unter seinerid(siehe Löschungen).
Innerhalb eines Durchgangs 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 und entfernst, schadet das nicht.
Warum die Überlappung
bimetrics setzt updatedAt und deletedAt, 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 Zeitpunkt 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: alle Einträge ohne includeDeleted holen und lokal entfernen, was dabei nicht mehr vorkommt.
Löschungen
Gelöschte Einträge liefert die Suche nur mit "includeDeleted": true. Sie tragen dann in deletedAt den Zeitpunkt, seit dem sie als gelöscht gelten; bei allen anderen ist deletedAt null. Einzeln abrufen lassen sie sich nicht mehr: Die Routen mit ID antworten mit 404.
- Beim ersten Lauf holt der erste Durchgang den ganzen Bestand ohne gelöschte Einträge. Den Stand des zweiten Durchgangs setzt du dabei auf den Zeitpunkt, bevor der erste begann. Ältere Löschungen brauchst du nicht.
- Bestehender Abgleich: Hat deine Anwendung bisher nur den ersten Durchgang, gleiche einmal den vollständigen Bestand ab, bevor du den zweiten ergänzt. Löschungen von vorher holt der zweite Durchgang nicht nach.
- Belege, die bimetrics behält: Manche Belege entfernt bimetrics beim Löschen nicht, etwa solche mit festgeschriebenen Buchungen; diese Buchungen hebt es per Storno auf. In der API gilt der Beleg ab dann als gelöscht, mit dem Zeitpunkt der Löschung in
deletedAt. Gilt er später wieder als nicht gelöscht, kommt er mit neuemupdatedAtim ersten Durchgang zurück. - Längere Pausen: Hat dein Abgleich länger nicht laufen können, gleiche einmal den vollständigen Bestand ab, wie unter Warum die Überlappung beschrieben.
Code
Das Beispiel gleicht Belege ab (POST /api/document/filter). Für Kontakte, Bankkonten, Umsätze und Buchungen 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"
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'
}
# Ein Durchgang: alle Einträge, deren Attribut $1 seit dem Stand in der Datei $2
# gesetzt wurde. $3 sind weitere Felder der Anfrage, $4 der Befehl je Eintrag.
sync_pass() {
local attribute=$1 state=$2 extra=$3 handle=$4
local stored newest cursor="" offset=0 filter body page count last
stored=$(cat "$state" 2>/dev/null || true)
newest=$stored
if [ -n "$stored" ]; then cursor=$(minus_overlap "$stored"); fi
while :; do
if [ -n "$cursor" ]; then
filter=$(jq -n --arg a "$attribute" --arg lower "$cursor" '{kind: "range", attribute: $a, lower: $lower}')
else
filter='{"kind": "and", "children": []}'
fi
body=$(jq -c --arg a "$attribute" --argjson extra "$extra" --argjson limit "$LIMIT" --argjson offset "$offset" \
'. + $extra + {sort: [$a], 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")
jq -c '.items[]' <<<"$page" | while read -r item; do "$handle" "$item"; done
count=$(jq '.items | length' <<<"$page")
if [ "$count" -eq 0 ]; then break; fi
last=$(jq -r --arg a "$attribute" '.items[-1][$a]' <<<"$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
}
# Jeden Beleg unter seiner ID speichern bzw. entfernen, hier als Datei
save_document() { jq . <<<"$1" >"belege/$(jq -r .id <<<"$1").json"; }
remove_document() { rm -f "belege/$(jq -r .id <<<"$1").json"; }
started=$(date -u +%Y-%m-%dT%H:%M:%SZ)
sync_pass updatedAt stand-belege.txt '{}' save_document
# Erster Lauf: Löschungen ab dem Beginn dieses Laufs
if [ ! -s stand-belege-geloescht.txt ]; then echo "$started" >stand-belege-geloescht.txt; fi
sync_pass deletedAt stand-belege-geloescht.txt '{"includeDeleted": true}' remove_documentWelche Ressourcen
| Ressource | Filterroute | Hinweis |
|---|---|---|
| Belege | POST /api/document/filter | Auch der Stand der Erkennung und Zuordnungen ändern updatedAt. |
| Kontakte | POST /api/contact/filter | |
| Bankkonten | POST /api/bankaccount/filter | |
| Umsätze | POST /api/banktransaction/filter | Zuordnungen ändern updatedAt. Umsätze deaktivierter Konten fehlen, siehe unten. |
| Buchungen | POST /api/accounting/booking/filter | Neu berechnete Buchungen bekommen neue IDs, die bisherigen gelten als gelöscht. |
Speichere den Stand je Ressource und Durchgang getrennt. updatedAt gilt für den Eintrag selbst und für seine Zuordnung in match: Zuordnen, das Aufheben einer Zuordnung und ein neuer offener Betrag (match.amountOpen) ändern updatedAt der betroffenen Belege und Umsätze. Die Buchungen in bookings gehören nicht dazu (siehe Grenzen).
Grenzen
Buchungen in Belegen und Umsätzen ändern updatedAt nicht
Die Buchungen in bookings eines Belegs oder Umsatzes können sich ändern, ohne dass sich sein updatedAt ändert, etwa wenn bimetrics sie beim DATEV-Export festschreibt. Auch Änderungen an der Zuordnungsregel in match.matchingRule ändern es nicht. Gleiche Buchungen deshalb über ihre eigene Filterroute ab, wie jede andere Ressource, und ordne sie über refDocumentId und refBankTransactionId zu.
- Buchungen: Solange eine Buchung nicht festgeschrieben ist, berechnet bimetrics sie bei Änderungen neu. Die neuen Buchungen bekommen neue IDs, die bisherigen gelten als gelöscht (siehe Glossar). Der zweite Durchgang entfernt sie.
- Deaktivierte Bankkonten: Wird ein Bankkonto deaktiviert, liefert die Suche seine Umsätze nicht mehr, auch nicht mit
includeDeleted. Sie gelten nicht als gelöscht. Erkennbar ist das am Bankkonto: Seindisabledist danntrue, und seinupdatedAtändert sich. - Zeitzone: Übernimm
updatedAtunddeletedAtunverändert aus der Antwort. Die Werte enthalten die Zeitzone (siehe Datentypen). - Limits: Ein Lauf braucht eine Anfrage je 1.000 geänderte oder gelöschte Einträge und je Durchgang mindestens eine. Verteile die Abgleiche mehrerer Ressourcen, damit du unter den Limits bleibst.