Booker daemon
Source:
- agent/src/accounting_agent/reconcile/booker.py (the Booker class — pure logic)
- agent/src/accounting_agent/booker_daemon/__main__.py (the daemon wrapper)
- agent/src/accounting_agent/reconcile/book.py (a CLI for book --id N one-offs)
Container: prod-accounting-booker. CMD: python -m
accounting_agent.booker_daemon. Tick: every 900s.
What one tick does
1. list_reconciliations_by_status('booked') → up to 50 rows
2. Filter to rows where efin_entry_id IS NULL
3. For each row, sequentially:
a. Load summary (joined document_fields)
b. Check minimum required fields
c. Find supplier in e-Fin via clients_api.find_by_name
↳ missing → open client_create_approval HITL → next row
d. Check for existing draft via notes-marker
↳ if found → reuse; skip create
e. vat_decision → pick rate + confidence
↳ low confidence + no OCR rate → SKIPPED_VAT_UNCERTAIN
f. Get purchase-articles cache; pull dimension id
g. Create purchase_invoice (POST)
h. Update totals (PATCH with items echoed back)
i. Attach PDF (PUT /document_user, base64 body)
j. Register (PATCH /register; PROJECT → REGISTERED)
k. Stamp efin_entry_id back on the recon row
4. Sleep 900s, repeat
Each row's pipeline is ~3-5 seconds. A 50-row batch takes ~3 min; well within tick budget.
Idempotency
Three layers:
- Notes marker. Every purchase_invoice carries
notes: "wise:synth:2026-06-27:45.61:ce1c03f9fde0". If the booker is re-run on a recon whose draft was created in a previous tick,find_by_notes_markerreturns the existing draft id and the booker reuses it. - State machine.
set_reconciliation_booked(recon_id, efin_entry_id=...)is only valid when the row currently hasefin_entry_id IS NULL. Double-write is a no-op. - Atomic stamp. The booker only stamps the recon row AFTER
the e-Fin
registercall succeeds. If the network drops mid-attach, the next tick re-finds the draft via the notes marker and resumes from where it left off.
What the booker does NOT do
- It does not run the matcher. Matching happens before a row gets
to
bookedstatus. - It does not talk to Discord. The HITL gates (
booker_approve,client_create_approval) are opened by the booker but posted by the bot. - It does not modify documents in R2.
Why a daemon, not a cron
The booker runs in-process so it can keep the e-Fin purchase-articles list cached across rows in the same tick. A cron-style "fresh process per run" pattern would re-fetch the article list every tick. The cache is ephemeral (rebuilt at process start) and bounded (single dict).
Gates
- Phase 5 booker gate (
BookerGate): pauses bookings where amount > €500 OR counterparty has never been booked before. Returns SKIPPED_HITL_PENDING; bot poller picks it up. - Phase 1 client-create gate: pauses bookings where the vendor isn't in e-Fin's client list. Returns WAITING_CLIENT_APPROVAL; bot poster surfaces the prompt.
- Phase D VAT gate: bails with SKIPPED_VAT_UNCERTAIN when both the LLM and OCR fail to determine a rate.
All three gates produce BookOutcome with a reason enum so
the daemon's per-tick summary log makes the state visible.