Skip to main content
ntro.capabilities.gl is the external accounting system integration layer: one stable Python surface (GLProvider, resource clients, typed models) regardless of which underlying GL the customer connects. It is distinct from:
LayerRole
ntro.accountingFund-ops domain models (trial balance shape, journal proposals, validation) inside Ntropii.
ntro.subledgerTenant ledgers.* rows and lifecycle before/at posting.
ntro.capabilities.glRead/write the customer’s GL (bills, invoices, TB reports, …) through the unified provider.
Subledger proposals (BillProposal, etc.) strip Ntropii-only metadata before crossing into gl — see ntro.subledger.proposals and the unified-adapter docs in source.

Install

pip install 'ntro[gl]'
The [gl] extra pulls the unified-GL SDK. Worker images that post to Xero / QuickBooks / Sage should include this extra — same as runbooks that call gl.for_entity. Workflow authoring typically still pins ntro[workflow] for Temporal + capabilities; add [gl] when the runbook posts to a connected ledger.

Resolve a provider

Providers are selected from tenant.config.gl:
  • provider — the registered GL adapter (e.g. the bundled unified adapter, or a future direct provider).
  • options — provider-specific connection (consumer id, service id, etc.), populated after the tenant completes GL connect in Ntropii Workspace.
from uuid import UUID

import ntro.capabilities.gl as gl

provider = await gl.for_entity(
    UUID("…"),
    tenant_config=ctx.tenant_config,  # includes merged tenant.config.gl
)

# Resource-oriented API:
# provider.journal_entries, provider.bills, provider.trial_balance, …
If tenant.config.gl.provider is missing or unknown, resolution raises ProviderNotRegisteredError or ConnectionError_ — handle these in activities (many runbooks short-circuit posting when no GL is configured).

Example: post bills from approved expenses

From expense_processor.post_expenses_to_gl — proposals built from subledger rows, then provider.bills.create with an idempotency external id:
from ntro.capabilities import gl
from ntro.capabilities.gl.errors import (
    ConnectionError_,
    IdempotencyConflictError,
    ValidationFailedError,
)

provider = await gl.for_entity(input.entity_id, tenant_config=input.tenant_config)
proposals = ExpenseRow.propose_for_gl(rows, task_id=input.task_id)

for proposal in proposals:
    result = await provider.bills.create(
        proposal,
        external_id=proposal.idempotency_key,
    )
Idempotency: writers take external_id; providers must honour deduplication and expose find_by_external_id so Temporal retries stay safe.

Errors you should handle

ExceptionTypical meaning
ConnectionError_GL not linked / credentials missing.
ValidationFailedErrorProvider rejected payload (posted journal, invalid account, …).
IdempotencyConflictErrorSame external_id, different body — logic bug or race.
TransientProviderErrorRetryable — often re-raised so Temporal retries the activity.

Models

Types such as JournalEntry, Bill, LedgerAccount, … are re-exported from ntro.capabilities.gl.models. Prefer importing from gl.models in runbook code so upgrades stay centralized.

Reports — typed read-only views of GL state

A report is a typed, point-in-time view of the customer’s GL state — TrialBalanceReport, future BalanceSheetReport, ProfitAndLossReport. They live at ntro.capabilities.gl.reports (sibling to models). Reports differ from subledgers: no per-row HITL lifecycle, no posting back, no transactional shape. They’re inputs to a workflow run, ephemeral after the run closes. The same typed shape is hydrated regardless of source.

Pattern B — the canonical flow for reports

customer uploads doc        document-ingest extracts          consuming
        │                            │                           workflow
        ▼                            ▼                              │
ingest.submitted_documents  →  ingest.extracted_payloads  ─────────┘
                                       (typed JSON cached       reads + hydrates
                                        once at upload time)    via from_extracted_payload
Workflows consume reports via the typed classmethod:
from ntro.capabilities.gl.reports import TrialBalanceReport
from ntro.workflow.task import find_committed_document

# In the workflow step (mirror of mortgage_agreement / rental_statement reads):
cached = await self.find_committed_document(
    workflow_slug="document-ingest",
    entity_id=ctx.entity_slug,
    source="xero-trial-balance",
)
tb = TrialBalanceReport.from_extracted_payload(cached, period=ctx.period)
tb.assert_balanced()
That’s <100ms, vs ~30s if the workflow re-runs ai.extract itself (the original parse_starting_tb shape pre-N-70).

Two source paths, one shape

SourceWhenHow
Cached extraction (Pattern B)Greenfield — customer’s doc-uploaded TB; no GL bound or no opening balance seeded yetTrialBalanceReport.from_extracted_payload(cached_payload, period)
Live API (future)Once customer’s GL is bound and opening balances postedawait provider.trial_balance.get(period=…)
The classmethod from_extracted_payload adapts the document-ingest payload dict; the TrialBalanceClient.get(...) Protocol returns the same TrialBalanceReport from the underlying provider. Workflow code is invariant across sources.

The <capability>.reports.<Type> convention

This pattern generalises across capabilities. Each “external ledger” capability (gl, banking, …) gets a sibling reports module:
ModuleTypes it owns
ntro.capabilities.gl.reportsTrialBalanceReport, future BalanceSheetReport, ProfitAndLossReport
ntro.capabilities.banking.reports (forthcoming, N-72)BankStatementReport
Same from_extracted_payload(...) constructor shape across all of them.

Accounting

Domain journals and proposals before external posting.

Subledgers

Typed rows and propose_for_gl metadata.

Data plane

Resolving tenant DB connections inside activities.