# Reports

Pull the four core financial statements, the general ledger, aging, and CSV /
PDF / IIF exports. Every statement is computed from the general ledger at
request time — there is no separate reporting store to fall out of sync.

Base URL: `https://api.accountingorbit.com/api/v1`
Auth: `Authorization: Bearer ao_...` — see [/docs/auth.md](/docs/auth.md).
Errors and pagination: [/docs/conventions.md](/docs/conventions.md).

---

## The guarantee: balanced postings, hash-chained audit

Two invariants hold for everything in this section, and both are enforced in
code rather than asserted in marketing:

**1. Every posting is balanced.** The single ledger entry point checks
`total_debit == total_credit` in base currency and refuses the entry otherwise.
The one exception is deliberate, bounded and visible: a rounding difference of
at most `$0.05` (scaled at `$0.02` per line) is absorbed into a dedicated
**round-off account** as its own posting line memoed `auto round-off`. Anything
larger raises `entry does not balance (base currency): debits X != credits Y`
and nothing is written. There is no code path that inserts postings without
going through this check.

**2. Every posted entry is audited.** Posting writes an audit row with
`action: "post_entry"`, the entry id, the actor, and a
`chain_hash = SHA-256(previous_chain_hash || row_json)`. The chain makes
retroactive edits detectable: altering or removing any row breaks every hash
after it. Destructive operations (`void`, `delete`, `statement.import`,
`receipt.link`, wipes) are audited the same way. Read it with `GET /audit`:

```json
[{"id": 1361, "tenant_id": 457, "actor": "454",
  "action": "post_entry", "entity": "journal_entry", "entity_id": 578,
  "detail": "receipt 42.50",
  "chain_hash": "401c4cc6c408652005bfc5dbe92fa46e88cf1390924197046394a91645e52aa6",
  "at": "2026-08-11T00:26:21.484069",
  "previous_value": null, "new_value": null,
  "ip_address": null, "reason": null, "source": null}]
```

Scope note, so you can rely on this precisely: the guarantee covers **ledger
postings**. Non-ledger metadata edits — changing an invoice memo, renaming a
party — are protected by optimistic locking and versioning, not by an audit row.

Nothing is ever hard-deleted from the ledger. Corrections are reversing
entries, which is why `void` and `reverse` exist and `DELETE` does not.

---

## `GET /statements/trial-balance`

The one call to make when you want to know whether the books are sane.

```bash
curl -G https://api.accountingorbit.com/api/v1/statements/trial-balance \
  -H "Authorization: Bearer $ORBIT_KEY" -d as_of=2026-08-31
```

```json
{
  "as_of": "2026-08-31",
  "basis": "cumulative",
  "lines": [
    {"code": "1000", "name": "Cash",                "type": "asset",     "debit": 60.0,  "credit": 0.0},
    {"code": "1100", "name": "Accounts Receivable", "type": "asset",     "debit": 0.0,   "credit": 0.0},
    {"code": "2000", "name": "Accounts Payable",    "type": "liability", "debit": 0.0,   "credit": 0.0},
    {"code": "4000", "name": "Sales Revenue",       "type": "revenue",   "debit": 0.0,   "credit": 150.0},
    {"code": "5000", "name": "Cost of Goods Sold",  "type": "expense",   "debit": 90.0,  "credit": 0.0}
  ],
  "total_debit": 150.0,
  "total_credit": 150.0,
  "balanced": true,
  "expense_source_connected": true,
  "_meta": {"cached": false, "refreshing": false}
}
```

`as_of` is **required**. Optional `start_date` narrows it from cumulative to a
period. `type` is one of `asset` / `liability` / `equity` / `revenue` /
`expense`.

**Assert `balanced == true`.** If it is ever false, stop and investigate before
acting on any other number.

`_meta.cached` tells you whether the figures came from a cache;
`_meta.refreshing` whether a recompute is in flight. `expense_source_connected`
reports whether an expense feed is wired up.

---

## `GET /statements/income-statement`

```bash
curl -G https://api.accountingorbit.com/api/v1/statements/income-statement \
  -H "Authorization: Bearer $ORBIT_KEY" -d start=2026-08-01 -d end=2026-08-31
```

```json
{
  "period": {"start": "2026-08-01", "end": "2026-08-31"},
  "revenue":  [{"code": "4000", "name": "Sales Revenue",      "amount": 150.0}],
  "expenses": [{"code": "5000", "name": "Cost of Goods Sold", "amount": 90.0}],
  "net_sales": 150.0,
  "cogs": 90.0,
  "gross_profit": 60.0,
  "operating_expenses": 0.0,
  "operating_income": 60.0,
  "other_income": 0.0,
  "ebit": 60.0,
  "interest_expense": 0.0,
  "pretax_income": 60.0,
  "tax_expense": 0.0,
  "net_income": 60.0,
  "expense_source_connected": true,
  "_meta": {"cached": false, "refreshing": false}
}
```

`start` and `end` are **required**. Optional `tenant_id` targets a client
tenant you are a member of — see [/docs/white-label.md](/docs/white-label.md).

---

## `GET /statements/balance-sheet`

```bash
curl -G https://api.accountingorbit.com/api/v1/statements/balance-sheet \
  -H "Authorization: Bearer $ORBIT_KEY" -d as_of=2026-08-31
```

```json
{
  "as_of": "2026-08-31",
  "assets": {
    "current_assets": [
      {"code": "1000", "name": "Cash",                "amount": 60.0},
      {"code": "1100", "name": "Accounts Receivable", "amount": 0.0}
    ],
    "fixed_assets": [],
    "other_assets": [],
    "current_liabilities": [
      {"code": "2000", "name": "Accounts Payable", "amount": 0.0}
    ],
    "long_term_liabilities": [],
    "equity": [
      {"code": "—", "name": "Current Year Earnings", "amount": 60.0}
    ]
  },
  "total_assets": 60.0,
  "total_liabilities": 0.0,
  "total_equity": 60.0,
  "liabilities_plus_equity": 60.0,
  "balanced": true,
  "expense_source_connected": true,
  "_meta": {"cached": false, "refreshing": false}
}
```

`as_of` is **required**. Optional `tenant_id`.

**Shape gotcha, verified against the live API:** the `assets` object contains
*all six* sections — `current_assets`, `fixed_assets`, `other_assets`,
`current_liabilities`, `long_term_liabilities` and `equity`. There is no
top-level `liabilities` or `equity` key. Read liabilities from
`assets.current_liabilities` and `assets.long_term_liabilities`, and equity from
`assets.equity`. The totals (`total_assets`, `total_liabilities`,
`total_equity`, `liabilities_plus_equity`) *are* top-level and are the safer
things to key off.

`Current Year Earnings` appears in `equity` with `code: "—"` (an em dash, not an
account code) because it is derived, not a real account.

Assert `balanced == true` here too.

---

## `GET /statements/cash-flow`

Indirect method, reconciled against the actual change in cash.

```bash
curl -G https://api.accountingorbit.com/api/v1/statements/cash-flow \
  -H "Authorization: Bearer $ORBIT_KEY" -d start=2026-08-01 -d end=2026-08-31
```

```json
{
  "period": {"start": "2026-08-01", "end": "2026-08-31"},
  "net_income": 60.0,
  "adjustments": {
    "depreciation": 0.0,
    "amortization": 0.0,
    "working_capital": 0.0,
    "working_capital_lines": [],
    "disposal_gain_loss": 0.0
  },
  "cash_from_operations": 60.0,
  "cash_from_investing": 0.0,
  "cash_from_financing": 0.0,
  "investing_lines": [],
  "financing_lines": [],
  "non_cash_transactions": [],
  "net_change_in_cash_classified": 60.0,
  "net_change_in_cash": 60.0,
  "reconciliation_difference": 0.0,
  "reconciled": true,
  "expense_source_connected": true,
  "_meta": {"cached": false, "refreshing": false}
}
```

`start` and `end` are **required**.

`net_change_in_cash_classified` is the sum of the three classified sections;
`net_change_in_cash` is the movement in the cash accounts themselves.
`reconciliation_difference` is the gap and `reconciled` says whether it is zero.
**Assert `reconciled == true`** — a non-zero difference means some cash movement
did not classify, and the section totals are then incomplete.

---

## `GET /statements/retained-earnings`

```bash
curl -G https://api.accountingorbit.com/api/v1/statements/retained-earnings \
  -H "Authorization: Bearer $ORBIT_KEY" -d start=2026-08-01 -d end=2026-08-31
```

```json
{
  "period": {"start": "2026-08-01", "end": "2026-08-31"},
  "opening_retained_earnings": 0.0,
  "net_income": 60.0,
  "dividends": 0.0,
  "closing_retained_earnings": 60.0
}
```

---

## Aging

| Endpoint | Requires | Returns |
|---|---|---|
| `GET /ar-aging?as_of=` | `as_of` | Receivables by bucket — [/docs/invoices.md](/docs/invoices.md) |
| `GET /ap-aging?as_of=` | `as_of` | Payables by bucket — [/docs/bills.md](/docs/bills.md) |
| `GET /collections/aging?as_of=` | — | Collections-oriented AR view |

```json
{"as_of": "2026-08-10",
 "buckets": {"current": 90.0, "1_30": 0.0, "31_60": 0.0, "61_90": 0.0, "over_90": 0.0},
 "total": 90.0, "credits": 0,
 "detail": [{"number": "BILL-0001", "due_date": "2026-09-09",
             "outstanding": 90.0, "days_overdue": 0, "bucket": "current"}]}
```

---

## Drilling into the ledger

### `GET /general-ledger/{account_id}`

Running-balance detail for one account.

```bash
curl -G https://api.accountingorbit.com/api/v1/general-ledger/23267 \
  -H "Authorization: Bearer $ORBIT_KEY" -d limit=50 -d offset=0
```

```json
{
  "account": {"code": "1000", "name": "Cash"},
  "lines": [
    {"date": "2026-07-15", "entry_id": 578,
     "memo": "Receipt: Staples Office Supply — 2026-07-15",
     "debit": "0.00", "credit": "42.50", "balance": "-42.50"},
    {"date": "2026-08-10", "entry_id": 575,
     "memo": "Payment for invoice SINV-0001",
     "debit": "150.00", "credit": "0.00", "balance": "107.50"}
  ],
  "total": 3, "limit": 50, "offset": 0
}
```

Note the amounts here are decimal **strings**, while the statement endpoints
return numbers. Parse defensively.

### `GET /accounts`

The chart of accounts — how you find `account_id` values, and the
`revenue_account_id` / `expense_account_id` you need for invoice and bill lines.

```json
{"items": [{"id": 23267, "code": "1000", "name": "Cash", "type": "asset",
            "subtype": "current_asset", "parent_id": null,
            "system_roles": ["cash", "monetary"], "is_active": true,
            "description": null, "tenant_id": 457,
            "created_at": "2026-08-11T00:21:41.421183"}],
 "total": 51}
```

### `GET /account-balances` and `GET /account-balances/summary`

```json
{"total_balance": 107.5, "total_transactions": 2,
 "matched": 1, "suggested": 0, "unmatched": 1, "match_rate_pct": 50.0}
```

### `GET /ratios?start=&end=`

Liquidity, margin and return ratios for the period. Both params required.
Values that cannot be computed come back `null` — the CSV export renders those
as the literal string `None`.

---

## Exports

### `GET /reports/export-csv?start=&end=`

`text/plain` CSV of the income statement plus ratios. Both params required.

```
Metric,Value
Period,2026-08-01 to 2026-08-31
Net Sales,150.00
COGS,90.00
Gross Profit,60.00
Operating Expenses,0.00
Operating Income,60.00
Net Income,60.00

Ratio,Value
Current Ratio,None
Quick Ratio,None
Gross Margin,0.4
Operating Margin,0.4
Net Margin,0.4
ROA,1.0
ROE,1.0
```

Two blocks separated by a blank line — not a single table. Note `None` for
undefined ratios.

### `GET /reports/export-pdf?start=&end=`

The same income statement as a PDF.

### `POST /reports/export` — document exports

```bash
curl -X POST https://api.accountingorbit.com/api/v1/reports/export \
  -H "Authorization: Bearer $ORBIT_KEY" -H "Content-Type: application/json" \
  -d '{"doc_type":"invoice","ids":[36],"format":"csv"}'
```

```
Number,Customer,Date,Due,Status,Total,Paid
SINV-0001,52,2026-08-10,2026-09-09,paid,150.00,150.00
```

`doc_type`: `invoice` | `bill` | `journal`. `format`: `csv` (default) |
`iif` | `pdf`. `format: "iif"` with `doc_type: "journal"` exports the whole
journal in QuickBooks IIF.

### `POST /reports/{report_type}/generate` — stored PDF snapshots

```bash
curl -X POST https://api.accountingorbit.com/api/v1/reports/trial_balance/generate \
  -H "Authorization: Bearer $ORBIT_KEY" -H "Content-Type: application/json" \
  -d '{"period_start":"2026-08-01","period_end":"2026-08-31"}' \
  -o trial-balance.pdf
```

Returns `application/pdf` bytes. `report_type` must be one of
`income_statement`, `balance_sheet`, `cash_flow`, `trial_balance`, `aging` —
anything else returns `400 "report_type must be one of [...]"`.

The same inputs return the cached snapshot rather than regenerating, so the PDF
you hand an auditor is stable.

| Endpoint | Purpose |
|---|---|
| `GET /reports?report_type=` | List stored snapshots |
| `GET /reports/{snapshot_id}` | Fetch one |

```json
[{"id": 12, "report_type": "trial_balance",
  "period_start": "2026-08-01", "period_end": "2026-08-31",
  "input_hash": "...", "integrity_hash": "...",
  "generated_at": "2026-08-11T00:27:02.113000", "has_pdf": true}]
```

`integrity_hash` lets you prove a stored report was not altered after
generation.

### Other exports

| Endpoint | Output |
|---|---|
| `GET /export/qbo?start=&end=` | QuickBooks-compatible export |
| `GET /export/bank-view?start=&end=` | Bank-style transaction view |
| `GET /export/bank-view-with-corrections?start=&end=` | ...including corrections |
| `POST /export/transactions` | Filtered transaction export — `{columns?, format?, filters?, excluded_transaction_ids?, include_metadata?, max_rows?}` |
| `POST /exports/journal-csv?start=&end=` | Journal as CSV |
| `POST /exports/iif?start=&end=` | Journal as IIF |
| `GET /exports/log?limit=` | Who exported what, and when |
| `POST /exports/log?export_type=&start=&end=` | Record an export you performed elsewhere |

---

## Failure cases specific to reports

| Status | Body | Cause |
|---|---|---|
| 400 | `{"detail": "report_type must be one of ['aging', 'balance_sheet', 'cash_flow', 'income_statement', 'trial_balance']"}` | Unknown snapshot type |
| 404 | `{"detail": "Not Found"}` | Unknown `account_id` on `/general-ledger/{id}`, or a snapshot id that is not yours |
| 422 | list of field errors | A required date param (`as_of`, `start`, `end`, `period_start`, `period_end`) is missing or not `YYYY-MM-DD` |
| 500 | `{"detail": "fpdf2 not installed. Run: pip install fpdf2"}` | PDF export on a server without the PDF library |

Reports are read-only: they never mutate the ledger, so they are safe to
retry and safe for a `read`-scoped API key.

---

## Typical integration sequence

1. `GET /accounts` once, and cache the `code -> id` mapping. You need it for
   everything else.
2. `GET /statements/trial-balance?as_of=<today>` and assert
   `balanced == true`. This is the health check — if it fails, nothing
   downstream is trustworthy.
3. Pull the period statements you actually need:
   `GET /statements/income-statement?start=&end=`,
   `GET /statements/balance-sheet?as_of=` (remember the nesting gotcha above),
   `GET /statements/cash-flow?start=&end=` and assert `reconciled == true`.
4. `GET /ar-aging?as_of=` and `GET /ap-aging?as_of=` for what is owed each way.
5. To explain a number, drill in: `GET /general-ledger/{account_id}` gives you
   the `entry_id` behind each movement.
6. For a document to file or hand over:
   `POST /reports/trial_balance/generate` with the period, and keep the
   snapshot's `integrity_hash`. For a spreadsheet, `GET /reports/export-csv` or
   `POST /reports/export`.
7. To prove provenance, `GET /audit` — every posting has a row with a
   `chain_hash`.
