« Zurück zum Blog
GoBD-konforme Bestellnummern mit Firestore: Atomare Counter ohne Race Conditions
Jede Bestellannahme-Engine läuft früher oder später in dieselbe Falle: Bestellnummern müssen eindeutig, fortlaufend und revisionssicher sein — aber bei paralleler Bestellannahme tauchen Race Conditions auf, doppelte Nummern führen zu Chaos in der Buchhaltung, und ein offener auto_increment-Counter verrät dem Wettbewerb das Monatsvolumen. Im voiceOne-Commerce-Stack haben wir uns für eine Kombination aus atomaren Firestore-Transaktionen, einem obfuskierten Format mit Prüfziffer und einem Jahres-Scoped-Counter entschieden. Dieser Artikel zeigt, wie das funktioniert, warum es GoBD-konform ist und was dabei schiefgehen kann.
BE-2026-XXXX-C — BE (Bestellung), 2026 (Jahres-Scope), XXXX (4 alphanumerische Zeichen aus einer base-32-Codierung des Counters), C (Prüfziffer, Luhn-mod-32-Verfahren). Revisionssicher monoton, aber nicht öffentlich als Volumen ablesbar.
Was GoBD überhaupt fordert
Die Grundsätze zur ordnungsmäßigen Führung und Aufbewahrung von Büchern, Aufzeichnungen und Unterlagen in elektronischer Form (GoBD) sind in Deutschland der Referenzrahmen für digitale Buchhaltung. Für Bestell- und Rechnungsnummern ergeben sich daraus vier Anforderungen:
- Eindeutigkeit. Keine Nummer darf doppelt vergeben werden.
- Lückenlosigkeit. Innerhalb eines Nummernkreises darf keine Nummer fehlen, oder das Fehlen muss dokumentiert sein.
- Unveränderbarkeit. Einmal vergebene Nummern dürfen nicht nachträglich geändert werden.
- Nachvollziehbarkeit. Es muss nachvollziehbar sein, wann und in welcher Reihenfolge Nummern vergeben wurden.
Wichtig: GoBD verlangt nicht, dass Bestellnummern kontinuierlich aufsteigend oder öffentlich lesbar sind. Wir können sie also obfuskieren — das ist sogar ratsam, weil ein öffentlich sichtbarer Monotonie-Counter Wettbewerbern das Bestellvolumen verrät. Dieselben Anforderungen decken sich im UK-Kontext weitgehend mit den HMRC Making-Tax-Digital-Richtlinien.
Warum auto_increment nicht reicht
Ein klassischer SQL-auto-increment-Counter liefert Eindeutigkeit und Monotonie — aber er hat drei Probleme:
- Informations-Leakage. Wenn eine Bestellung Nummer 4.321 und am nächsten Tag Nummer 4.389 hat, weiß der Wettbewerb, dass in 24 Stunden 68 Bestellungen reingekommen sind.
- Jahres-Rollover. GoBD erlaubt, den Nummernkreis pro Jahr zu resetten — das macht das Monitoring für Steuerprüfungen einfacher. Ein auto-increment tut das nicht von selbst.
- Keine Prüfziffer. Wenn ein Kunde die Nummer am Telefon diktiert, kann er sich verhören. Ohne Prüfziffer merkt das System den Fehler nicht.
Firestore (die NoSQL-Datenbank aus dem Google-Cloud-Ökosystem) bietet keine native auto-increment-Funktion — das klingt wie ein Nachteil, ist aber eine Chance: Wir können von Grund auf das Format bauen, das wir wirklich wollen.
Das Format im Detail
Eine Bestellnummer bei voiceOne Commerce sieht so aus: BE-2026-4F3A-C. Aufgeschlüsselt:
BE— Präfix für „Bestellung". Rechnungen bekommenRE, GutschriftenGU. Das macht sie auf den ersten Blick unterscheidbar.2026— Jahr der Vergabe. Der Counter wird pro Jahr auf 1 zurückgesetzt. Dokumente aus 2025 und 2026 können identischeXXXX-Werte haben, sind aber durch das Jahr eindeutig.4F3A— vier Zeichen aus einer Crockford-Base32-Kodierung des internen Counters. Das gibt 324 = 1.048.576 Kombinationen pro Jahr — mehr als ausreichend für mittelständische Shops. Crockford-Base32 lässt 0/O und 1/I/L weg und ist am Telefon hervorragend diktierbar.C— ein Prüfzeichen aus einer Luhn-mod-32-Variante. Falls der Kunde ein Zeichen verhört, merkt das System den Fehler sofort.
Warum Crockford-Base32 und nicht Base64?
Base64 enthält Case-Sensitivity, das Zeichen + und /. Base32 in der Standard-Variante ist case-insensitive, aber enthält leicht verwechselbare Zeichen. Crockford-Base32 ist speziell für menschen-diktable Codes entwickelt: Es verwendet nur 0-9 und A-Z, ohne 0/O, 1/I, L und U-Zeichen, die zu Verwechslungen führen. Genau richtig für einen Telefon-Dialog mit Mia, die den Kunden nach der Bestellnummer fragt.
Die atomare Firestore-Transaktion
Das Herzstück ist die Transaktion, die den Counter hochzählt und die formatierte Nummer zurückgibt. Firestore-Transaktionen sind optimistisch: Mehrere Writes auf dasselbe Dokument führen dazu, dass einer davon automatisch erneut ausgeführt wird. Das ist genau das, was wir brauchen.
// Python, google-cloud-firestore
from google.cloud import firestore
from datetime import datetime
CROCKFORD = "0123456789ABCDEFGHJKMNPQRSTVWXYZ"
def generate_order_number(db: firestore.Client, tenant_id: str) -> str:
year = datetime.utcnow().year
counter_ref = db.collection("tenants").document(tenant_id) \
.collection("counters").document(f"orders_{year}")
@firestore.transactional
def _txn(transaction):
snap = counter_ref.get(transaction=transaction)
current = snap.get("value") if snap.exists else 0
new_value = current + 1
transaction.set(counter_ref, {"value": new_value, "updated_at":
firestore.SERVER_TIMESTAMP}, merge=True)
return new_value
counter = _txn(db.transaction())
code = to_crockford_base32(counter, length=4)
check = luhn_mod32(code)
return f"BE-{year}-{code}-{check}"
def to_crockford_base32(n: int, length: int = 4) -> str:
if n < 0: raise ValueError("negative counter")
out = []
while n > 0:
n, r = divmod(n, 32)
out.append(CROCKFORD[r])
padded = "".join(reversed(out)).rjust(length, "0")
if len(padded) > length:
raise ValueError(f"counter overflow: {n} does not fit in {length} chars")
return padded
def luhn_mod32(code: str) -> str:
total = 0
for i, ch in enumerate(reversed(code)):
v = CROCKFORD.index(ch)
if i % 2 == 0:
v *= 2
if v >= 32: v = (v // 32) + (v % 32)
total += v
check_val = (32 - (total % 32)) % 32
return CROCKFORD[check_val]
Was hier passiert, ist genau die GoBD-Anforderung: In der Transaktion lesen wir den aktuellen Counter, erhöhen ihn um eins, schreiben zurück. Firestore garantiert, dass keine zwei parallelen Transaktionen denselben Wert lesen und denselben neuen Wert schreiben können. Bei einem Conflict rollbackt Firestore automatisch und startet die Transaktion neu — transparent für den Aufrufer.
Der Tenant-Scope: Multi-Shop-Isolation
Der Counter ist nicht global, sondern pro Tenant und pro Jahr. Der Dokumentpfad ist tenants/{tenant_id}/counters/orders_{year}. Das hat zwei Vorteile:
- Isolation. Sleepring und ein anderer voiceOne-Shop teilen sich nicht denselben Counter. Jeder Shop hat seinen eigenen fortlaufenden Nummernkreis.
- Schreib-Durchsatz. Firestore limitiert einzelne Dokumente auf ~1 Write/Sekunde nachhaltigen Durchsatz. Pro Tenant und Jahr ist das für mittelständische Shops mehr als ausreichend.
Für High-Volume-Shops, die in Peak-Stunden mehr als 1 Bestellung pro Sekunde erwarten, würde man den Counter in 10 Shards aufteilen (z.B. orders_2026_shard0 bis orders_2026_shard9) und dann einen Aggregator rechnen. Das haben wir im aktuellen Stack nicht gebraucht — Sleepring und die anderen D2C-Tenants liegen deutlich darunter.
Prüfziffer: Luhn-mod-32 in der Praxis
Die Prüfziffer schützt vor genau drei Fehler-Typen:
- Einzelfehler: Ein Zeichen wird falsch übermittelt (
4F3Astatt4E3A). - Transpositionsfehler: Zwei benachbarte Zeichen werden vertauscht (
4F3AstattF43A). - Tippfehler bei manueller Eingabe.
Der klassische Luhn-Algorithmus ist für Dezimalzahlen (Kreditkarten) definiert. Für unsere Base32-Variante haben wir ihn auf Modulo 32 angepasst. Die Implementierung oben zeigt, wie das konkret aussieht. Wenn Mia am Telefon die Nummer entgegennimmt und der Kunde einen Buchstaben falsch nennt, fällt der Fehler sofort auf — die Verifikation schlägt an der Prüfziffer fehl, und Mia kann höflich nachfragen.
Rechnungsnummern: dasselbe Prinzip, anderer Präfix
Für GoBD-Konformität reicht die Bestellnummer nicht — Rechnungen brauchen ihren eigenen fortlaufenden Nummernkreis. Wir nutzen RE-2026-XXXX-C mit demselben Transaktions-Mechanismus auf einem anderen Counter-Dokument: tenants/{tenant_id}/counters/invoices_{year}.
Die Rechnung entsteht typischerweise nach der Bestellannahme, sobald die Zahlung bestätigt ist (Stripe-Webhook payment_intent.succeeded). Damit ist die Rechnungs-Nummernfolge nicht identisch mit der Bestell-Nummernfolge — was auch richtig ist, weil nicht jede Bestellung zu einer bezahlten Rechnung führt (Stornierungen, Refunds).
Was sich in der Praxis bewährt hat
1. Counter-Dokument getrennt vom Bestellungs-Dokument halten
Das Counter-Dokument ist ein Hotspot. Firestore erlaubt ~1 Write/Sekunde. Wenn man den Counter in dasselbe Dokument wie andere Tenant-Konfiguration schreibt, kollidieren Bestellungen mit ganz anderen Updates. Immer ein eigenes Dokument pro Counter.
2. Jahres-Rollover automatisieren
Am 1. Januar um 00:00 UTC fängt der Counter bei 1 an. Das ergibt sich aus dem Pfad orders_{year} automatisch — das erste Dokument dieses Jahres existiert nicht, also startet der Counter bei 0 und wird zu 1. Kein manueller Reset-Cronjob nötig.
3. Audit-Trail in der Bestellung speichern
Neben der formatierten Nummer (BE-2026-4F3A-C) speichern wir in der Bestellung auch:
counter_value: 1234— der rohe Counter-Wertassigned_at— Server-Timestamp der Nummernvergabeassigned_by— User/System, das die Transaktion ausgelöst hat
Das gibt einem Steuerprüfer auf Knopfdruck die chronologische Liste aller Bestellungen — ohne dass er das Format parsen muss.
4. Idempotenz-Key für Checkout-Retries
Wenn der Browser im Checkout den Bestätigungs-Button zweimal klickt, landen zwei Requests bei unserem Server. Ohne Idempotenz-Handling würden beide Requests je eine Bestellnummer ziehen — zwei Counter-Inkrements für eine fachlich identische Bestellung. Wir lösen das über einen idempotency_key, der bei der ersten Anfrage generiert und bei Retries wiederverwendet wird. Die zweite Anfrage findet die bereits existierende Bestellung anhand des Keys und gibt die vorhandene Nummer zurück — ohne den Counter ein zweites Mal zu inkrementieren.
Was wir nicht tun
- Keine UUIDs als externe Bestellnummer. UUIDs sind technisch sauber, aber am Telefon eine Katastrophe. Ein Kunde kann
BE-2026-4F3A-Cdiktieren.550e8400-e29b-41d4-a716-446655440000kann er nicht. - Kein Timestamp als Teil der Nummer. Timestamps lecken Vergabezeit und sind nicht monoton bei paralleler Vergabe (NTP-Drift).
- Keine Hashes. Ein Hash kollidiert bei Counter-Wert-Wiederverwendung. Wir brauchen deterministische, umkehrbare Codierung — und genau das ist Crockford-Base32.
Fazit
Das Bestellnummern-Schema aus atomarer Firestore-Transaktion plus Crockford-Base32-Codierung plus Luhn-mod-32-Prüfziffer ist auf den ersten Blick overengineered. In der Praxis deckt es vier Anforderungen gleichzeitig ab: GoBD-Konformität, Race-Condition-Sicherheit, Telefon-Diktierbarkeit (wichtig für Mia-Anrufe) und Volumen-Privacy. Der Code ist knapp 30 Zeilen Python, läuft stabil und hat sich in der Sleepring-Produktion bewährt.
Für D2C-Brands, die über einen Plattform-Wechsel nachdenken, ist das Nummern-Schema selten der Hauptgrund für die Entscheidung — aber bei einem Steuer-Audit wird es zum Dankeschön, wenn das eigene System nicht zusammengefrickelt aussieht.
Commerce-Stack mit solidem Engineering darunter
Der voiceOne-Shop-Stack liefert Storefront, Checkout, KI-Berater und GoBD-konforme Rechnungslogik aus einer Plattform — ohne Integrations-Zoo.
Zum KI-Shop