Three LLMs, one dunning run
A Python script reads 40 open invoices, assesses each case, enforces clean JSON and drafts the reminder email — using the right model for each step.
- 40 invoices processed
- 0 invalid responses
- 2 languages detected
The question almost everyone asks
“We can chat with AI now. But how do we use LLMs in daily operations — without someone typing prompts all day?”
The answer is unspectacular: take away the chat window. An LLM becomes a working tool when it sits inside a loop — a script feeds it one data row at a time, machine-checks every answer, and writes the result to wherever the work continues.
That is exactly what this showcase demonstrates, on a process every SME knows: the dunning run.
The scenario
The fictional Muster Handwerk AG has 40 open invoices. The receivables list looks the way such lists really look: bookkeeping notes in German, French and English, half-sentences, capital letters, contradictions. Four examples:
| Invoice | Customer | Amount | Days overdue | Bookkeeping note |
|---|---|---|---|---|
| 2026-1002 | Café Bellevue GmbH | CHF 1'350.00 | 34 | hat angerufen, zahlt Ende Monat. tel. bestätigt 08.07. |
| 2026-1003 | Menuiserie Perrin Sàrl | CHF 8'920.00 | 41 | attend le décompte final avant de payer – à clarifier avec chef de projet |
| 2026-1009 | Elektro Wyss AG | CHF 6'540.00 | 88 | Gerücht vom Lieferanten: Zahlungsschwierigkeiten?? nichts konkretes. VORSICHT mit Ton |
| 2026-1031 | Tech Solutions Ltd | CHF 21'430.00 | 42 | invoice stuck in their approval workflow, says 'processed next run' – 2nd time they say this |
All 40 rows are synthetic — companies, amounts and notes are invented. The full dataset is in the download.
The pipeline: three steps, three models
Every row passes through three steps. A different model handles each one — always the one that is sufficient for the job:
→ swipe the diagram sideways
Notation: ISO 5807 flowchart — parallelogram = data, rectangle = process, double bars = predefined process (API call), diamond = decision, trapezoid = manual operation, wave = document.
Claude Opus 4.8
Anthropic · CHF 4.05 / 20.25 per 1M TokensReads the messy note and weighs the case: payment promise? dispute? partial payment? The strongest model sits where judgement is required.
GPT-5 mini
OpenAI · CHF 0.20 / 1.62 per 1M TokensPresses the assessment into strict JSON that software can process. Formatting work — a small, cheap model is enough.
Gemini 2.5 Flash
Google · CHF 0.24 / 2.03 per 1M TokensDrafts the email in the customer's language — German, French or English, in the tone step 1 prescribed.
Why not one model for everything?
That would work too — the split is deliberately didactic. It shows two things: providers can be swapped per step (no lock-in), and the expensive model only works where it is needed. With 40 invoices that hardly matters; with 40,000 it does.
The code
The full script is a good 200 lines, runs with three API keys as environment variables and sends nothing — emails end up as drafts on disk. The three decisive parts (code comments in German — it is written for Swiss SMEs):
The step-1 prompt — business rules in plain language
The company's collection rules live directly in the prompt. This is the part you can adjust yourself without programming knowledge:
PROMPT_BEURTEILEN = """Du bist erfahrene:r Debitoren-Sachbearbeiter:in der Muster Handwerk AG,
einem Schweizer KMU. Beurteile diesen offenen Posten (Stichtag {stichtag}):
Rechnung {rechnung_nr} an {kunde_pseudo} über CHF {betrag_chf},
fällig seit {tage_ueberfaellig} Tagen, bisherige Mahnungen: {mahnstufe}.
Notiz aus der Buchhaltung (Originalton, evtl. DE/FR/EN): «{notizen}»
Empfiehl in 3–5 Sätzen das weitere Vorgehen. Regeln des Betriebs:
- Zahlungszusagen, Ratenvereinbarungen, Streitfälle und Teilzahlungen NICHT stur weitermahnen.
- Unklare Fälle (Insolvenzgerüchte, unzustellbare Post, Rechts-/Verrechnungsfragen)
erfordern eine manuelle Prüfung durch die Geschäftsleitung — nicht mahnen.
- Bei wichtigen Kunden bestimmt, aber beziehungsschonend formulieren.
- Erkenne die Sprache des Kunden (de/fr/en) aus Notiz und Firmenname."""Three providers, three official SDKs
Each step is a short function call following the official SDK documentation of the respective provider — including token counts for the cost meter:
def beurteilen(client, prompt: str):
"""Schritt 1 — Claude: liest den Fall und empfiehlt das Vorgehen."""
msg = client.messages.create(
model=MODELL_BEURTEILEN,
max_tokens=600,
thinking={"type": "adaptive"},
messages=[{"role": "user", "content": prompt}],
)
text = "".join(b.text for b in msg.content if b.type == "text")
return text, msg.usage.input_tokens, msg.usage.output_tokens
def strukturieren(client, prompt: str):
"""Schritt 2 — GPT-5 mini: erzwingt sauberes JSON."""
r = client.chat.completions.create(
model=MODELL_STRUKTUR,
response_format={"type": "json_object"},
messages=[{"role": "user", "content": prompt}],
)
return r.choices[0].message.content, r.usage.prompt_tokens, r.usage.completion_tokens
def schreiben(client, prompt: str):
"""Schritt 3 — Gemini: formuliert den E-Mail-Entwurf."""
r = client.models.generate_content(model=MODELL_TEXT, contents=prompt)
u = r.usage_metadata
return r.text, u.prompt_token_count, u.candidates_token_countThe loop — validate, retry, log
The JSON from step 2 is machine-validated (allowed actions, deadlines, languages). If it is invalid, the model gets exactly one retry with the error message — after that the run aborts rather than producing garbage:
for p in posten:
# Schritt 1: beurteilen
beurteilung, t_in, t_out = beurteilen(c_claude, PROMPT_BEURTEILEN.format(**p))
kosten_chf += (t_in * PREISE[MODELL_BEURTEILEN][0] + t_out * PREISE[MODELL_BEURTEILEN][1]) / 1e6 * KURS_USD_CHF
# Schritt 2: strukturieren (1 Wiederholung bei ungültigem JSON)
prompt2 = PROMPT_STRUKTUR.format(beurteilung=beurteilung, **p)
for versuch in (1, 2):
roh, t_in, t_out = strukturieren(c_openai, prompt2)
kosten_chf += (t_in * PREISE[MODELL_STRUKTUR][0] + t_out * PREISE[MODELL_STRUKTUR][1]) / 1e6 * KURS_USD_CHF
try:
entscheid = json_pruefen(roh)
break
except (ValueError, json.JSONDecodeError) as e:
if versuch == 2:
raise SystemExit(f"{p['rechnung_nr']}: JSON nach 2 Versuchen ungültig — {e}")
prompt2 += f"\n\nDeine letzte Antwort war ungültig ({e}). Korrigiere sie."
# Schritt 3: schreiben — nur wenn wirklich eine E-Mail raus soll
if entscheid["email_senden"] and entscheid["aktion"] not in ("zurueckstellen", "manuelle_pruefung"):
entwurf, t_in, t_out = schreiben(c_gemini, PROMPT_TEXT.format(**entscheid, **p))
kosten_chf += (t_in * PREISE[MODELL_TEXT][0] + t_out * PREISE[MODELL_TEXT][1]) / 1e6 * KURS_USD_CHF
# Pseudonym erst jetzt, lokal, wieder durch den Klarnamen ersetzen
entwurf = entwurf.replace(p["kunde_pseudo"], p["kunde"])
with open(f"entwuerfe/{p['rechnung_nr']}.txt", "w", encoding="utf-8") as f:
f.write(entwurf)
ergebnisse.append({"rechnung_nr": p["rechnung_nr"], "kunde": p["kunde"],
"betrag_chf": p["betrag_chf"], "tage": p["tage_ueberfaellig"],
**{k: entscheid[k] for k in ("aktion", "sprache", "ton",
"frist_tage", "risiko", "grund")}})
print(f"{p['rechnung_nr']} {entscheid['aktion']:<16} {entscheid['risiko']:<7} {entscheid['grund']}")The test run: real outputs
We ran the pipeline over all 40 invoices. Result: 18× friendly reminder, 2× reminder, 1× final reminder — and 19 cases the system deliberately did NOT dun, but put on hold or referred to management for manual review. Those cases are the whole point: a rigid dunning program would still have sent a reminder to the customer whose payment promise was confirmed by phone.
- Friendly reminder 18
- Payment reminder 2
- Final reminder 1
- On hold 5
- Manual review 14
| Case | Note (original) | Decision | Model's reasoning |
|---|---|---|---|
| 2026-1002 CHF 1'350.00 · 34 days overdue | hat angerufen, zahlt Ende Monat. tel. bestätigt 08.07. | On hold risk: medium | Zahlungszusage per Telefon am 08.07. bis Ende Monat; Mahnstopp bis Anfang August gesetzt. |
| 2026-1004 CHF 23'760.00 · 67 days overdue | 2. Mahnung per Post 12.06. KEINE Reaktion. grosser Folgeauftrag offen!! | Manual review risk: high | Wichtiger Kunde mit grossem Folgeauftrag; standardisierter Mahnlauf kontraproduktiv. Manuelle Prüfung durch Geschäftsleitung empfohlen. |
| 2026-1007 CHF 4'870.00 · 51 days overdue | Teilzahlung CHF 2'000 am 30.06. eingegangen, Rest offen. war ok für ihn per mail | Friendly reminder risk: medium | Teilzahlung geleistet, Restbetrag offen. Keine neue Zahlungszusage. Beziehungsschonende Erinnerung per E-Mail empfohlen. |
| 2026-1009 CHF 6'540.00 · 88 days overdue | Gerücht vom Lieferanten: Zahlungsschwierigkeiten?? nichts konkretes. VORSICHT mit Ton | Manual review risk: high | 88 Tage überfällig, 2. Mahnung, Gerücht über Zahlungsschwierigkeiten – Fall zur manuellen Prüfung durch Geschäftsleitung. |
| 2026-1006 CHF 15'300.00 · 28 days overdue | says PO number missing on invoice – resend with PO 4711, then payment 'immediately' | On hold risk: low | Kunde beanstandet fehlende Bestellnummer, sagt Zahlung nach Korrektur zu. Mahnstopp, korrigierte Rechnung mit PO 4711 ausstellen. |
| 2026-1023 CHF 430.00 · 63 days overdue | Kleinbetrag. 2 Mahnungen raus. lohnt sich eine Betreibung überhaupt?? KLÄREN | Final reminder risk: medium | Kleinbetrag CHF 430, 63 Tage überfällig, 2. Mahnung. Letzte Zahlungsaufforderung mit Betreibungsandrohung, danach Prüfung mit GL. |
21 email drafts, three languages
Two examples from the run — unedited, exactly as the model filed them:
Betreff: Letzte Zahlungserinnerung – Rechnung 2026-1023 Sehr geehrte Damen und Herren Bereits am [Datum der ersten Mahnung] und [Datum der zweiten Mahnung] haben wir Sie an die offene Rechnung 2026-1023 über CHF 430.00 erinnert. Der Betrag ist seit 63 Tagen fällig. Wir bitten Sie, den offenen Betrag von CHF 430.00 innerhalb der nächsten 10 Tage auf unser Konto [IBAN] zu überweisen. Sollte der Zahlungseingang bis dahin ausbleiben, sehen wir uns gezwungen, den Fall der Geschäftsleitung zur Prüfung weiterzuleiten und ein Betreibungsverfahren einzuleiten. Wir hoffen auf Ihre umgehende Zahlung und danken für Ihre geschätzte Aufmerksamkeit. Freundliche Grüsse Muster Handwerk AG
Objet: Rappel de paiement – Facture 2026-1026 Madame, Monsieur, Nous faisons suite à notre précédent échange et à votre engagement de régler la facture 2026-1026 d’un montant de CHF 27 890.00. À ce jour, malgré votre promesse, ce montant est toujours impayé depuis 47 jours. Nous vous prions de bien vouloir effectuer le virement sous 10 jours sur notre compte [IBAN]. Nous comptons sur votre respect de cet engagement et vous remercions de votre compréhension. Meilleures salutations, Muster Handwerk AG Musterstrasse 1, 2540 Grenchen
How these outputs were produced
Our development environment holds no Anthropic, OpenAI or Google keys. The test run therefore used the exact same prompts and validation through a single OpenAI-compatible endpoint (DeepSeek — the same model that powers the chat on this website). The published code calls the three providers per their official SDKs; the decisions and drafts shown are real, unedited model outputs from that test run.
What does a run cost?
Projection for the 40 invoices: text volumes measured in the test run, converted to tokens (~3.6 characters/token) and multiplied by the three providers' list prices (published in USD, converted here at 1 USD = 0.81 CHF as of July 2026; figures in CHF per 1M tokens):
| Step | Price in / out (CHF) | Tokens in / out (40 invoices) | Cost |
|---|---|---|---|
| Assess Claude Opus 4.8 | 4.05 / 20.25 | ~8'998 / ~8'715 | ~CHF 0.21 |
| Structure GPT-5 mini | 0.20 / 1.62 | ~15'242 / ~2'731 | ~CHF 0.01 |
| Write Gemini 2.5 Flash | 0.24 / 2.03 | ~3'907 / ~3'010 | ~CHF 0.01 |
Total per run: ~CHF 0.23 — with a weekly dunning run, roughly CHF 0.98 per month.
This is a projection, not a bill: token counts are estimated from character volumes, exchange rates move, and real provider invoices may differ. The order of magnitude is the point — the most expensive item in dunning is not the AI, it is the hour of manual work.
Security, privacy, limits
Real names stay in-house
Before every API call, customer names are replaced by pseudonyms (KUNDE_07) and only re-inserted locally in the finished draft. Providers see amounts and notes, but no names.
Nothing goes out automatically
The script sends not a single email — it writes drafts. And it flagged 19 of 40 cases for manual review or hold on its own, instead of dunning.
Keys do not belong in code
API keys come from environment variables. There is not a single key in the published code — that is how it should be.
Limits
An LLM can misread a note. Hence: drafts instead of sending, strict JSON validation, edge cases to a human. Automation here means: machine-prepare the 80% routine, leave the 20% judgement with people.
Transparency
- Not a client project — a teaching piece, built for this website.
- All data synthetic: companies, amounts and notes are invented.
- The code is runnable, provided you supply your own API keys for the three providers.
- The outputs shown come from a test run through a single DeepSeek endpoint (see above) — not from runs at Anthropic, OpenAI or Google.
- The cost table is a projection from estimated token counts × the providers' list prices (published in USD, converted to CHF).
Try it yourself
Script, dataset and instructions as a ZIP — MIT licence, free to use. Leave your business email and the download starts right away.
Thank you! The download is starting — if not, click here:
Download ZIP directlyWhich of your processes has a “notes column”?
Dunning, quoting, order handling, the support inbox — wherever text piles up, this pattern works. In a free first call we find the process with the best effort-to-impact ratio.
Free strategy call