Skip to content

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:

  1. 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_marker returns the existing draft id and the booker reuses it.
  2. State machine. set_reconciliation_booked(recon_id, efin_entry_id=...) is only valid when the row currently has efin_entry_id IS NULL. Double-write is a no-op.
  3. Atomic stamp. The booker only stamps the recon row AFTER the e-Fin register call 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 booked status.
  • 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.