### Zoho Books — organizations, payloads, lifecycles and workflows (read before writing)

Books is **not** shaped like CRM, and most mistakes come from carrying CRM habits over. Every shape below was read back from a live organization.

#### Every call is scoped to one organization

One login usually sees several organizations (this account sees 11). Every endpoint takes `organization_id`; store it once and zone injects it, or pass it per call to address another org:

```bash
zone books org list --toon                       # organization_id, name, currency
zone ctx books organization_id=800000001         # default for every books call from now on
zone books invoice list --query organization_id=753612770 --toon   # one call against a different org
```

Without it, Zoho answers "belongs to multiple organizations" — the fix is the `ctx` line, not a retry.

#### Response envelope and pagination

Every response is `{"code": 0, "message": "success", ...}`; the payload key is the **plural resource name** for lists (`invoices`, `contacts`, `items`, `customerpayments`) and the **singular** for one record (`invoice`, `contact`). `code` is `0` on success and a Zoho error number otherwise — zone already turns non-zero into exit 5 with the message, so a command that returned normally succeeded.

Lists carry `page_context`:

```json
{"page":1,"per_page":200,"has_more_page":true,"sort_column":"created_time","sort_order":"D"}
```

Page with `--page`/`--per-page` (max 200) until `has_more_page` is `false`. Filter with the list flags (`--status`, `--customer-id`, `--vendor-id`, `--search`, `--filter`), not by fetching everything.

#### The write body — no envelope, no array

A create/update body **is the record**. There is no `{"data":[…]}` wrapper and no batching; one call creates one record.

```bash
zone books contact create --data '{"contact_name":"Acme LLC","contact_type":"customer","currency_code":"AED","email":"ap@acme.ae","contact_persons":[{"first_name":"Jane","email":"jane@acme.ae","is_primary_contact":true}]}'

zone books item create --data '{"name":"Consulting hour","rate":300,"product_type":"service","tax_id":"4936746000001627022","account_id":"<sales account_id>"}'

zone books invoice create --data '{"customer_id":"4936746000000208017","date":"2026-09-01","due_date":"2026-09-30","reference_number":"PO-118","line_items":[{"item_id":"4936746000000269001","quantity":10,"rate":300,"tax_id":"4936746000001627022"},{"name":"Travel","description":"On-site day","quantity":1,"rate":450}],"notes":"Thank you.","custom_fields":[{"api_name":"cf_project","value":"Rollout"}]}'

zone books invoice update <invoiceId> --data '{"due_date":"2026-10-15"}'
```

`--data` accepts inline JSON, `@file.json`, or `-` for stdin. Responses echo the full record under the singular key — read the id from `invoice.invoice_id`, `contact.contact_id`, and so on.

#### How each field is written

| Field | Write it as | Notes |
|---|---|---|
| ids (`customer_id`, `item_id`, `tax_id`, `account_id`) | `"4936746000000269001"` | 19-digit **strings**; never parse to a number |
| dates (`date`, `due_date`) | `"2026-09-01"` (`yyyy-MM-dd`) | read back the same; `created_time` reads as `2026-09-03T13:45:27+0400` |
| money (`rate`, `amount`, `total`) | number `2752.5` | in the record's `currency_code`; `bcy_*` fields are base currency |
| `line_items[]` | `{"item_id", "quantity", "rate"}` or `{"name", "description", "quantity", "rate"}` for an ad-hoc line; optional `tax_id`, `discount`, `account_id`, `unit` | `rate` × `quantity` → `item_total`; taxes come from `tax_id` (see `zone books tax list`) |
| `contact_type` | `"customer"` or `"vendor"` | one contact is one or the other |
| `product_type` (items) | `"service"` or `"goods"` | goods can `track_inventory` |
| `custom_fields` | `[{"api_name":"cf_x","value":"…"}]` or `[{"label":"X","value":"…"}]` | discover with `zone books customfield list --entity invoice` |
| `is_inclusive_tax` | `true`/`false` | whether line rates already include tax |
| `payment_terms` | `30`, with `payment_terms_label` | days |
| `status` | **never written** — it moves through the status commands below | read: `draft`, `sent`, `overdue`, `paid`, `partially_paid`, `void`, `unpaid` |

Books ids are strings like CRM's. `currency_code` on a record must match the contact's currency unless multi-currency is enabled for that contact.

#### Lifecycles: state changes are commands, not field edits

```bash
zone books invoice create --data @inv.json --send        # create and email in one step
zone books invoice sent <id>                             # draft → sent (marks it, no email)
zone books invoice email <id> --data '{"to_mail_ids":["ap@acme.ae"],"subject":"Invoice INV-000044","body":"..."}'
zone books invoice reminder <id> --data '{}'             # payment reminder
zone books invoice void <id>                             # void; drafts are deleted instead
zone books invoice writeoff <id>
zone books invoice pdf <id> --out INV-000044.pdf         # --accept pdf is applied for you
zone books estimate accept <id>   |  estimate decline <id>   |  salesorder open <id>  |  bill open <id>
```

Estimates → sales orders → invoices, and purchase orders → bills, are conversions done with `create` bodies that reference the source (`estimate_id`, `salesorder_id`, `purchaseorder_id`) — there is no "convert" verb.

#### Recording money

```bash
# customer pays an invoice
zone books payment create --data '{"customer_id":"4936746000001370001","payment_mode":"Bank Transfer","amount":5250,"date":"2026-09-02","reference_number":"TRF-2231","account_id":"<bank account_id>","invoices":[{"invoice_id":"4936746000002070061","amount_applied":5250}]}'
zone books invoice payments <invoiceId>                  # what has been applied
zone books invoice apply-credits <invoiceId> --data '{"apply_creditnotes":[{"creditnote_id":"…","amount_applied":500}]}'

# you pay a bill
zone books vendorpayment create --data '{"vendor_id":"…","payment_mode":"Bank Transfer","amount":1200,"date":"2026-09-02","paid_through_account_id":"<bank account_id>","bills":[{"bill_id":"…","amount_applied":1200}]}'

# an expense with a receipt
zone books expense create --data '{"account_id":"<expense account_id>","paid_through_account_id":"<bank account_id>","date":"2026-09-02","amount":89.5,"vendor_id":"…","description":"Domain renewal"}'
```

`payment_mode` is free text as configured (`Bank Transfer`, `Cash`, `Credit Card` …). `account_id` / `paid_through_account_id` are chart-of-accounts ids: `zone books coa list --toon` or `zone books bank accounts --toon`.

#### The books of account

```bash
zone books coa list --toon                # chart of accounts: account_id, account_name, account_type
zone books tax list                       # tax_id, tax_name, tax_percentage
zone books currency list
zone books journal create --data '{"journal_date":"2026-09-30","reference_number":"ADJ-9","line_items":[{"account_id":"<id>","debit_or_credit":"debit","amount":100},{"account_id":"<id>","debit_or_credit":"credit","amount":100}]}'
zone books bank transactions --account-id <accountId> --status uncategorized
zone books bank categorize-customerpayment <transactionId> --data '{"customer_id":"…","payment_mode":"Bank Transfer","amount":…,"date":"…","invoices":[…]}'
```

Journals must balance: total debits equal total credits, or the call fails with a validation message.

#### Snapshot every organization's setup

```bash
zone books pull -o ./books-settings            # every org: users, chart of accounts, taxes, currencies, custom fields, workflows, ...
zone books pull --org 800000001                # one org
zone books pull --only settings/taxes          # one collection across all orgs
zone books pull --with-records                 # also contacts, items, invoices, bills
```

Writes `<out>/<Org Name (id)>/…` per organization; orgs where your access is disabled are skipped after one denial. The layout is stable, so `diff -r` between two org folders shows exactly how their setups differ.

#### Zoho Books ↔ CRM

Records created from CRM carry `zcrm_potential_id` / `zcrm_contact_id` / `is_linked_with_zohocrm`. When the same customer exists in both, act on the Books contact by `contact_id`; do not create a duplicate from the CRM name.

#### Purchase returns, item variants and categories

Three resources Zoho's own OpenAPI bundle lists and zone added in 0.8.3 — shared, path for path, with Inventory (`src/specs/finance-shared/`):

```bash
zone books purchasereturn list --status open --toon
zone books purchasereturn confirm <id>                # draft -> open; also void | fulfill | unfulfill
zone books purchasereturn close --data '{"purchasereturn_ids":"<id>,<id>"}'
zone books itemmaster list && zone books itemvariant list --item-master-id <id>
zone books itemvariant move <variantId> --data '{...}'
zone books category list
```

- **Status changes come in two shapes.** `confirm`, `void`, `fulfill`, `unfulfill` are per-record `POST /purchasereturns/{id}/status/<x>`; `close` and `reopen` are collection-level `PUT /purchasereturns/status/<x>` with the ids in the body.
- **An item master owns variants** (size x colour); each variant has its own SKU, rate and stock. The flat `item` group is unchanged. `itemvariant move` puts the id *after* `/move` (`/itemvariants/move/{id}`).
- **Recurring bills are `/recurringbills`.** Zoho's OpenAPI bundle says `/recurring_bills` — that path answers 404 on a live org; zone's `recurringbill` group is the working one.

#### Errors → what to do

| What you see | Meaning | Do |
|---|---|---|
| "belongs to multiple organizations" / missing organization | no `organization_id` | `zone ctx books organization_id=<id>` (ids from `zone books org list`) |
| `HTTP 404 Resource does not exist` | wrong id, or the id belongs to another org | check the id against a list call **in the same org** |
| `code` 1002 / 1004 style validation messages (`"Please enter a valid …"`, `"… is mandatory"`) | a field is missing or malformed — the message names it | fix the body; ids are strings, dates `yyyy-MM-dd`, money numbers |
| "already exists" | duplicate `contact_name` / `invoice_number` / `sku` | update the existing record, or let Books auto-number (`--ignore-auto-number` only when you must set the number) |
| "You do not have access as your account is disabled" | this org exists but your user is disabled in it | pick another org; nothing to retry |
| `HTTP 405 GET not allowed` | that resource has no list endpoint (e.g. tax groups) | read it through its parent resource |
| exit 4 | token lacks `ZohoBooks.fullaccess.ALL` | human runs `zone login books` |
| rate limit / "too many requests" | Books caps requests per minute and per day per org | back off; use list filters and `pull` instead of many single GETs |

A command that exits 0 succeeded (`code: 0`); still read the returned id before using it in the next call.
