bimetricsEntwickler
Rezepte

Delta-Sync

Geänderte und gelöschte Belege, Kontakte, Bankkonten, Umsätze und Buchungen 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 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

  1. Stand merken: Deine Anwendung speichert je Ressource zwei Werte: den updatedAt-Wert des zuletzt gelesenen Eintrags und den deletedAt-Wert des zuletzt gelesenen gelöschten Eintrags. Beim ersten Lauf gibt es noch keinen.
  2. Mit Überlappung ansetzen: Jeder Durchgang beginnt 5 Minuten vor seinem 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 Durchgang fertig, und der updatedAt-Wert des letzten Eintrags wird der neue Stand.
  6. Löschungen holen: Der zweite Durchgang läuft genauso, mit "includeDeleted": true, range und Sortierung auf deletedAt. Jeden Eintrag, den er liefert, entfernst du unter seiner id (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 neuem updatedAt im 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_document

Welche Ressourcen

RessourceFilterrouteHinweis
BelegePOST /api/document/filterAuch der Stand der Erkennung und Zuordnungen ändern updatedAt.
KontaktePOST /api/contact/filter
BankkontenPOST /api/bankaccount/filter
UmsätzePOST /api/banktransaction/filterZuordnungen ändern updatedAt. Umsätze deaktivierter Konten fehlen, siehe unten.
BuchungenPOST /api/accounting/booking/filterNeu 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: Sein disabled ist dann true, und sein updatedAt ändert sich.
  • Zeitzone: Übernimm updatedAt und deletedAt unverä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.

Auf dieser Seite