Skip to main content

Accounting Engine

Document ingestion and accounting are decoupled through a Postgres queue. Nothing in the import path calls accounting directly; it enqueues, and a long-running workflow drains the queue.

The two phases

The decoupling buys independent scaling, resilient retries, and idempotency: a failed accounting run does not roll back the import.

The queue

accounting_work_queue carries version_hint, state, run_count, last_error, priority, and document_type alongside the document and org. syncAccountingQueueWorkflow (workflows/accounting/sync-accounting-queue-workflow.ts:48) drains it:
  • LeasingfetchJobs uses FOR UPDATE SKIP LOCKED and sets state = 'leased' (:66), so parallel lanes never collide.
  • Pipelining — the next batch is prefetched while the current one processes (:65).
  • Bounded concurrency — 6 documents per batch activity; total in-flight is laneCount × 6. The comment at :44-45 says to raise this only after observing direct-pool utilisation.
  • Heartbeating — the batch activity heartbeats after every concurrency wave, under a 30-minute startToCloseTimeout (:37-39).
  • continueAsNew only when Temporal suggests it (:106-107), and the prefetched leased batch is drained first so those rows are not stranded (:108-110).
  • Stale-lease recovery — if draining fails, leases recover via the stale-lease clause.
  • Cancellation propagates deliberately (:82); swallowing it would let a lane run on.
A second workflow, recreateAccountingImpactViaQueueWorkflow, runs on its own queue (recreate-accounting-impact-batch) for bulk re-derivation.

Inside createAccountingImpact

packages/core/src/services/create-accounting-impact.ts:121 — the single function all accounting converges on. It runs inside a transaction and touches five domains. Two things worth noting from the order: idempotency is checked first, so a redelivered job is cheap; and an actor is resolved before any write, satisfying the attribution rule.

The posting matrix

Rules are looked up by dimension label and trigger event slug, then cached. getPostingMatrixRulesCached is called once per dimension per document — debit, credit, and each custom dimension (create-accounting-impact.ts:602-619). This is a hot read path and is Redis-cached; see Redis Caching.

Deferred revenue

deferred-revenue (17 files) produces deferred_revenue_schedules, and revenue_recognition_entries links each recognised amount to a general_ledger row. revenue_recognition_entries has no org_id — it is tenant-scoped only through schedule_id and general_ledger_id. Any query against it must join a parent to stay tenant-safe. See Data Model.

Notes

The queue workflow is self-healing. worker.ts:62 calls ensureAccountingQueueRunning at startup, using a no-op ping signal (:23, :55) to detect whether the long-lived workflow is already active. Connection pooling is the real constraint. The accounting process uses a lazy prismaDirect pool capped at ACCOUNTING_QUEUE_LANES * 6 + 4 per Machine — 28 at four lanes. WORKER_GROUPS.md normalises Supabase pooler URLs to transaction mode on port 6543 for this client to avoid Supavisor’s session-client cap. CONA-986/987 governs batch sizing. The in-code comment ties the 6-per-batch limit to those tickets — check them before tuning. DATEV is an export format, not an integration. datevExportWorkflow generates files; no CONA code calls a DATEV API.