> ## Documentation Index
> Fetch the complete documentation index at: https://docs.ntropii.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Custom subledgers

> Defining a runbook-owned subledger type — Row subclass, @register_type(owner_runbook=…), per-runbook migration, and GL handoff. Worked example: RentalStatementRow from nav-monthly-journals.

The platform ships two subledger types — [`expenses`](/sdk/subledgers/types/expenses) and [`journal_proposals`](/sdk/subledgers/types/journal-proposals) — that any runbook can reuse. Most runbooks need more: a rent-roll line, a capital-call schedule, a fixed-asset depreciation entry. These are **runbook-owned** types.

A runbook-owned subledger:

* Lives inside the runbook bundle (`runbooks/<name>/subledgers/<type>.py`).
* Ships its own migration (`runbooks/<name>/migrations/NNN_subledger_<type>.sql`).
* Registers with `@register_type("<name>", owner_runbook="<runbook-slug>")` so the platform registry can disambiguate types across hot-reloads and across runbooks that use the same logical name.

## Platform vs runbook-owned

| Aspect                | Platform type (`expenses`, `journal_proposals`) | Runbook-owned type (`rental_statement`, custom)    |
| --------------------- | ----------------------------------------------- | -------------------------------------------------- |
| Code location         | `ntro.subledger.types.<name>`                   | `runbooks/<runbook>/subledgers/<name>.py`          |
| Migration             | `ntro-infra/tenant-postgres/migrations/`        | `runbooks/<runbook>/migrations/`                   |
| Registration          | `@register_type("<name>")`                      | `@register_type("<name>", owner_runbook="<slug>")` |
| Reuse across runbooks | Designed for cross-runbook reuse                | Local to the runbook that owns it                  |
| Schema evolution      | Platform release cycle                          | Runbook release cycle                              |

`owner_runbook=` is the boundary marker — it tells the registry "this type belongs to that runbook" so the same logical name can carry different shapes in different bundles without conflicting, and so hot-reloading the runbook re-registers cleanly.

## Worked example — `RentalStatementRow`

From [`runbooks/nav-monthly-journals/subledgers/rental_statement.py`](https://github.com/ntropii-com):

```python theme={null}
from datetime import date
from decimal import Decimal
from uuid import UUID

from pydantic import Field, model_validator

from ntro.subledger.registry import register_type
from ntro.subledger.row import Row
from ntro.subledger.status import SubledgerStatus


@register_type("rental_statement", owner_runbook="nav-monthly-journals")
class RentalStatementRow(Row):
    unit: str
    tenant_name: str
    monthly_rent: Decimal = Field(gt=0)
    rent_received: Decimal = Field(default=Decimal("0"))
    arrears_30d: Decimal = Field(default=Decimal("0"))
    arrears_60d: Decimal = Field(default=Decimal("0"))
    arrears_90d_plus: Decimal = Field(default=Decimal("0"))
    deductions_total: Decimal = Field(default=Decimal("0"))
    management_fee_gross: Decimal = Field(default=Decimal("0"))
    repairs_gross: Decimal = Field(default=Decimal("0"))
    net_cash_received: Decimal = Field(default=Decimal("0"))
    lease_start: date | None = None
    lease_end: date | None = None
    vacant: bool = False
    confidence: float | None = Field(default=None, ge=0.0, le=1.0)
    # GL-handoff fields — opt-in. Mirror the platform types so
    # mark_posted-style updates work if this type later posts directly.
    posted_to_gl: bool = False
    posted_journal_ref: str | None = None

    @model_validator(mode="after")
    def _non_negative_money_fields(self) -> "RentalStatementRow":
        for key in (
            "rent_received", "arrears_30d", "arrears_60d", "arrears_90d_plus",
            "deductions_total", "management_fee_gross", "repairs_gross",
            "net_cash_received",
        ):
            if getattr(self, key) < 0:
                raise ValueError(f"{key} cannot be negative")
        return self
```

Things to notice:

* **Subclasses `Row`** — gets the [standard column block](/sdk/subledgers#standard-column-block) for free (`id`, `entity_id`, `period`, `task_id`, `status`, `source_ref`, `validation_errors`, `raw_payload`, timestamps).
* **Domain validators** live on the row class — same Pydantic `model_validator` pattern as `ExpenseRow` / `JournalProposalRow`.
* **No `propose_for_gl`** — this type stages period-scoped statement facts; it's read by `nav-monthly-journals`'s journal proposer and never posts to the GL directly. GL handoff is opt-in per type.

## Migration

The matching migration ships in the runbook bundle, not the platform schema. It applies on tenant Postgres alongside the platform migrations and creates the `ledgers.<type>` table:

```sql theme={null}
-- runbooks/nav-monthly-journals/migrations/001_subledger_rental_statement.sql

CREATE TABLE IF NOT EXISTS ledgers.rental_statement (
    id              UUID PRIMARY KEY,
    entity_id       UUID NOT NULL REFERENCES system.entities(id),
    period          TEXT NOT NULL,
    task_id         UUID NOT NULL,
    status          TEXT NOT NULL,
    source_ref      TEXT,
    validation_errors  JSONB,
    raw_payload        JSONB,
    -- Type-specific columns:
    unit            TEXT NOT NULL,
    tenant_name     TEXT NOT NULL,
    monthly_rent    NUMERIC NOT NULL,
    rent_received   NUMERIC DEFAULT 0,
    -- … one column per Row field …
    posted_to_gl    BOOLEAN DEFAULT FALSE,
    posted_journal_ref TEXT,
    created_at      TIMESTAMPTZ DEFAULT NOW(),
    updated_at      TIMESTAMPTZ DEFAULT NOW()
);
```

The runbook deploy step picks up `runbooks/<slug>/migrations/*.sql` and runs them against the tenant database in filename order. Keep the standard column names exact — the `Row` base relies on them for `SubledgerHandle.insert` / `query` / status mutations.

## Using the handle

Once registered + migrated, the type is reachable via the same `subledger.open(...)` flow as platform types:

```python theme={null}
from uuid import UUID
from ntro.subledger import open as subledger_open

# RentalStatementRow has been imported (registration runs at import).
handle = subledger_open(
    name="rental_statement",
    entity_id=ctx.entity_id,
    task_id=ctx.task_id,
    tenant_slug=ctx.tenant_slug,
)

await handle.insert(RentalStatementRow(
    entity_id=ctx.entity_id,
    period=ctx.period,
    task_id=ctx.task_id,
    unit="Flat 4A",
    tenant_name="Tenant Co Ltd",
    monthly_rent=Decimal("2400.00"),
    rent_received=Decimal("2400.00"),
))

rows = await handle.query(period=ctx.period)
```

The handle reads the type from the import-time registry, so the runbook's `subledgers/__init__.py` must import `rental_statement` (or be discovered by the runbook loader) for `subledger_open(name="rental_statement", …)` to work.

## GL handoff (opt-in)

Add a `propose_for_gl` classmethod when your type *does* post to the external GL — same shape as [`ExpenseRow.propose_for_gl`](/sdk/subledgers/types/expenses#gl-handoff--expenserowpropose_for_gl) or [`JournalProposalRow.propose_for_gl`](/sdk/subledgers/types/journal-proposals#gl-handoff--journalproposalrowpropose_for_gl). Most pure staging types (rent rolls, raw statements) skip it — they're read by a downstream proposer that produces the actual journal.

## When to use the `status` enum override

The default `SubledgerStatus` lifecycle (`NEEDS_ATTENTION → PENDING → APPROVED → POSTED/REJECTED/EXCLUDED`) covers most types. Override only when your type has fundamentally different states — e.g. a `capital_calls` type with `AWAITING_BANK / PAID / DEFAULTED`. Override via Pydantic field annotation on your Row subclass; the column name stays `status`, only the enum type changes.

## Related

<CardGroup cols={2}>
  <Card title="Subledgers overview" icon="table" href="/sdk/subledgers">
    `Row` base, standard column block, `SubledgerStatus` lifecycle.
  </Card>

  <Card title="expenses" icon="receipt" href="/sdk/subledgers/types/expenses">
    Platform reference type — full `propose_for_gl` example.
  </Card>

  <Card title="journal_proposals" icon="file-spreadsheet" href="/sdk/subledgers/types/journal-proposals">
    Platform reference type — multi-row aggregation pattern.
  </Card>

  <Card title="UI and Temporal signals" icon="right-left" href="/sdk/ui-and-temporal-signals">
    HITL row mutations against custom types via workflow signals.
  </Card>
</CardGroup>
