# Delta-Sync (https://developer.bimetrics.de/recipes/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

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](#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](#grenzen) beschrieben.

## Code

Das Beispiel gleicht Belege ab ([`POST /api/document/filter`](https://developer.bimetrics.de/reference/documents/filter-documents.md)). 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"`.

**cURL:**

```bash
#!/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
```

**JavaScript:**

```js
import { mkdir, readFile, writeFile } from "node:fs/promises";
import { setTimeout as sleep } from "node:timers/promises";

const api = "https://app.bimetrics.de/api";
const auth = { Authorization: `Bearer ${process.env.BIMETRICS_API_KEY}` };
const limit = 1000;
const overlapMs = 5 * 60 * 1000; // siehe „Warum die Überlappung“

// Sendet eine Anfrage und wiederholt sie bei 429 (nach Retry-After) und 5xx.
async function call(path, body, attempts = 5) {
  for (let attempt = 1; ; attempt++) {
    const response = await fetch(api + path, {
      method: "POST",
      headers: { ...auth, "Content-Type": "application/json" },
      body: JSON.stringify(body),
    });
    if ((response.status === 429 || response.status >= 500) && attempt < attempts) {
      await sleep((Number(response.headers.get("Retry-After")) || 2 ** attempt) * 1000);
      continue;
    }
    if (!response.ok) throw new Error(`${response.status}: ${await response.text()}`);
    return response.json();
  }
}

// Holt alle Einträge, die sich seit `stored` geändert haben, und gibt den neuen Stand zurück.
async function sync(path, stored, save) {
  let cursor = stored && new Date(Date.parse(stored) - overlapMs).toISOString();
  let newest = stored;
  let offset = 0;
  for (;;) {
    const filter = cursor
      ? { kind: "range", attribute: "updatedAt", lower: cursor }
      : { kind: "and", children: [] };
    const page = await call(path, { ...filter, sort: ["updatedAt"], direction: ["asc"], limit, offset });
    for (const item of page.items) await save(item);

    if (page.items.length === 0) return newest;
    const last = page.items.at(-1).updatedAt;
    newest = last;
    if (page.items.length < limit) return newest;
    if (last === cursor) {
      offset += limit; // ganze Seite mit demselben Zeitpunkt
    } else {
      cursor = last;
      offset = 0;
    }
  }
}

const stateFile = "stand.json";
const state = JSON.parse(await readFile(stateFile, "utf8").catch(() => "{}"));
await mkdir("belege", { recursive: true });

state.documents = await sync("/document/filter", state.documents, (document) =>
  writeFile(`belege/${document.id}.json`, JSON.stringify(document)),
);
await writeFile(stateFile, JSON.stringify(state));
```

**Python:**

```python
import json
import os
import pathlib
import time
from datetime import datetime, timedelta

import requests

API = "https://app.bimetrics.de/api"
LIMIT = 1000
OVERLAP = timedelta(minutes=5)  # siehe „Warum die Überlappung“
session = requests.Session()
session.headers["Authorization"] = f"Bearer {os.environ['BIMETRICS_API_KEY']}"

def call(path, body, attempts=5):
    """Sendet eine Anfrage und wiederholt sie bei 429 (nach Retry-After) und 5xx."""
    for attempt in range(1, attempts + 1):
        response = session.post(API + path, json=body, timeout=60)
        if (response.status_code == 429 or response.status_code >= 500) and attempt < attempts:
            time.sleep(int(response.headers.get("Retry-After", 2**attempt)))
            continue
        response.raise_for_status()
        return response.json()

def sync(path, stored, save):
    """Holt alle Einträge, die sich seit `stored` geändert haben, und gibt den neuen Stand zurück."""
    # fromisoformat liest den Zusatz "Z" ab Python 3.11
    cursor = (datetime.fromisoformat(stored) - OVERLAP).isoformat() if stored else None
    newest = stored
    offset = 0
    while True:
        if cursor:
            body = {"kind": "range", "attribute": "updatedAt", "lower": cursor}
        else:
            body = {"kind": "and", "children": []}
        body |= {"sort": ["updatedAt"], "direction": ["asc"], "limit": LIMIT, "offset": offset}
        items = call(path, body)["items"]
        for item in items:
            save(item)

        if not items:
            return newest
        last = items[-1]["updatedAt"]
        newest = last
        if len(items) < LIMIT:
            return newest
        if last == cursor:
            offset += LIMIT  # ganze Seite mit demselben Zeitpunkt
        else:
            cursor, offset = last, 0

state_file = pathlib.Path("stand.json")
state = json.loads(state_file.read_text()) if state_file.exists() else {}
store = pathlib.Path("belege")
store.mkdir(exist_ok=True)

def save_document(document):
    (store / f"{document['id']}.json").write_text(json.dumps(document))

state["documents"] = sync("/document/filter", state.get("documents"), save_document)
state_file.write_text(json.dumps(state))
```

## Welche Ressourcen

| Ressource  | Filterroute | Hinweis                                          |
| ---------- | ----------- | ------------------------------------------------ |
| Belege     | [`POST /api/document/filter`](https://developer.bimetrics.de/reference/documents/filter-documents.md)        | Auch der Stand der Erkennung ändert `updatedAt`. |
| Kontakte   | [`POST /api/contact/filter`](https://developer.bimetrics.de/reference/contacts/filter-contacts.md)        |                                                  |
| Bankkonten | [`POST /api/bankaccount/filter`](https://developer.bimetrics.de/reference/bank-accounts/filter-bank-accounts.md)        |                                                  |
| Umsätze    | [`POST /api/banktransaction/filter`](https://developer.bimetrics.de/reference/bank-transactions/filter-bank-transactions.md)        |                                                  |
| Buchungen  | [`POST /api/accounting/booking/filter`](https://developer.bimetrics.de/reference/accounting/filter-bookings.md)        | Nicht per Delta-Sync, siehe unten.               |

Speichere den Stand je Ressource getrennt. `updatedAt` gilt nur für den Datensatz selbst. Die [Zuordnung](/glossary#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)).

## 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}`](https://developer.bimetrics.de/reference/documents/get-document.md) oder [`GET /api/banktransaction/{id}`](https://developer.bimetrics.de/reference/bank-transactions/get-bank-transaction.md), 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](/glossary#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](/glossary#buchung)). Ein Delta-Sync würde die alten behalten. Hole Buchungen deshalb je Zeitraum vollständig neu, wie im [Monatsexport](/recipes/monthly-export).
* **Zeitzone:** Übernimm `updatedAt` unverändert aus der Antwort. Der Wert enthält die Zeitzone (siehe [Datentypen](/data-types#zeitpunkte)).
* **Limits:** Ein Lauf braucht eine Anfrage je 1.000 geänderte Einträge. Verteile die Abgleiche mehrerer Ressourcen, damit du unter den [Limits](/limits) bleibst.
