---
name: ledger
description: "Cash book and AR/AP ledger. Records invoices raised, bills received, payments, credits and loose cash movements in the graph, and computes what is outstanding rather than storing it. Provides ledger-invoice-record, ledger-payment-record, ledger-credit-record, ledger-cash-record, ledger-balance, ledger-statement and ledger-reconcile."
tools:
  - name: ledger-invoice-record
    publicAllowlist: false
    adminAllowlist: true
    riskClass: write_local
  - name: ledger-payment-record
    publicAllowlist: false
    adminAllowlist: true
    riskClass: write_local
  - name: ledger-credit-record
    publicAllowlist: false
    adminAllowlist: true
    riskClass: write_local
  - name: ledger-cash-record
    publicAllowlist: false
    adminAllowlist: true
    riskClass: write_local
  - name: ledger-balance
    publicAllowlist: false
    adminAllowlist: true
    riskClass: read
  - name: ledger-statement
    publicAllowlist: false
    adminAllowlist: true
    riskClass: read
  - name: ledger-reconcile
    publicAllowlist: false
    adminAllowlist: true
    riskClass: read
always: false
embed: false
metadata: {"platform":{}}
mcp:
  command: node
  args:
    - ${PLATFORM_ROOT}/lib/mcp-spawn-tee/dist/index.js
    - ${PLATFORM_ROOT}/plugins/ledger/mcp/dist/index.js
  env:
    MCP_SPAWN_TEE_NAME: ledger
    LOG_DIR: ${LOG_DIR}
    PLATFORM_ROOT: ${PLATFORM_ROOT}
    ACCOUNT_ID: ${ACCOUNT_ID}
    SESSION_ID: ${SESSION_ID}
    AGENT_SLUG: ${AGENT_SLUG}
mcp-manifest: auto
---

# Ledger

A cash book with receivables and payables, held in the graph. Admin agent only.

## What it does and does not do

It records what was invoiced, what was billed, what was paid, what was credited, and what money moved without an invoice. It answers what is outstanding on one document, and what the whole position looks like aged into buckets.

It is not double-entry. There is no chart of accounts, no journals and no trial balance. It computes no VAT and handles one currency. It does not import bank feeds, and it does not talk to QuickBooks.

## Outstanding is computed, never stored

`outstanding`, `balance` and `amountPaid` are forbidden properties on `Invoice` and `InboundInvoice` (see `schema-base.md` § Forbidden Properties). What is still owed is derived on every read as `totalPaymentDue` minus the summed `amount` of the invoice's `InvoicePayment` and `Credit` children.

The reason is that a stored figure drifts the moment a payment lands by a path that forgets to update it, and nothing fails when it does: the invoice simply reports a number that no longer reconciles against its own children. A computed figure cannot disagree with the payments it is computed from.

## Direction comes from the parent label

An `InvoicePayment` reached from an `Invoice` is money received. The same label reached from an `InboundInvoice` is money paid. There is no `direction` property on a payment, because a stored direction can contradict the edge it hangs from.

`CashEntry` does carry `direction`, because it has no parent invoice to derive one from.

## The counterparty is passed by id, never by name

`ledger-invoice-record` and `ledger-cash-record` take the element id of an existing `Organization` or `Person`, and nothing else about them. The ledger never creates or matches them: look the counterparty up first with `memory-search` or `contact-lookup` and pass the id you find.

The display name stored on the invoice is read from that node, not passed as a second argument. One lookup feeds both the stored name and the link, so an invoice cannot read as one company while pointing at another. An id that does not exist on this account is refused with `reason=unknown-counterparty` before anything is written.

This is a deliberate boundary, not a convenience gap. The ledger records money; deciding whether "Beagle Direct Ltd" is the same company as an existing "Beagle Direct" is entity resolution, and doing it badly inside a write tool creates duplicate organisations that then split a payment history in two.

A `CashEntry` must have a counterparty for a second reason: graph-write refuses a write with no relationship at all, so an unlinked entry cannot be stored.

## Over-application is refused

A payment that would take applied-to-date above the invoice total is rejected. It is never clamped to the remaining amount and never recorded anyway. The refusal names the figure that would settle the invoice, so the operator can correct it in one step.

## Every write is verified

After writing a payment, the tool re-reads the invoice from the graph and compares the outstanding figure against what the arithmetic predicted. A mismatch is reported as an error telling the operator to stop recording against that invoice until it is reconciled. Issuing a write proves only that a write was issued.

## The standing check

Four ways this can fail emit no event at all: a write narrated but never landed, a payment with no parent invoice, a stored balance property that has reappeared, and a duplicate payment created under a second id. None of them can be caught by logging an action, because no action occurs.

A `[ledger-census]` line is emitted every 15 minutes by the UI server, carrying counts for each, plus any ledger index declared in `schema.cypher` but absent from this database, plus `writeRejects24h`.

That last counter exists because of a real failure. On 2026-07-18 every ledger write was rejected for a full day while the census reported `invoices=0 payments=0`, which is byte-identical to a healthy ledger nobody has used yet. A rejected write leaves nothing in the graph by definition, so the count is read from the plugin's own log rather than from Neo4j. `alarm=true` on that line means one of them has happened. `ledger-reconcile` is the same check on demand, sharing the same reconcile and the same line formatter.

Registration drift between `PLUGIN.md`, the generated canonical-tool-names artifact and the specialist templates is **not** a census signal. It is caught at build time by `platform/scripts/check-canonical-tool-names.mjs` and `check-specialist-tool-surface.mjs`. A runtime counter for it was removed after review: the census runs in the UI server, which has no view of MCP spawns, so the counter could only ever have compared a constant against itself and reported healthy.

The absence of the census line is itself a signal: it means the interval is not running.
