# Jaz API Endpoint Reference

> Full request/response examples for every Jaz API endpoint.
> See SKILL.md for rules, errors.md for troubleshooting, field-map.md for name lookups.

---

## Base URL & Auth

```
Base URL: https://api.getjaz.com
Auth: OAuth through the configured CLI/MCP connection by default.
Optional API-key header: x-jk-api-key: <key>
Content-Type: application/json
```

---

## Date Format (All Endpoints)

**All dates must be `YYYY-MM-DD` strings** (e.g., `"2026-02-08"`).

- ISO datetime strings (e.g., `"2026-02-08T00:00:00Z"`) are REJECTED; bill payment validation returns "does not match 2006-01-02 format"
- Epoch milliseconds are REJECTED
- The OAS may declare some date fields as `integer/int64` (e.g., cash journals) but `YYYY-MM-DD` strings work in practice
- Production clients send all dates as `YYYY-MM-DD` via Python `date` type

**Timezone convention**: All business dates (`valueDate`, `dueDate`, `startDate`, `endDate`, etc.) are in the **organization's timezone**; no timezone conversion is needed, in either requests or responses. The DB stores epoch ms representing the org-local date. Only audit timestamps (`createdAt`, `updatedAt`, `action_at`) are UTC.

---

## Pagination (All List Endpoints)

All GET list endpoints and POST `/search` endpoints use **`limit`/`offset` pagination**, NOT `page`/`size`.

**IMPORTANT: `offset` is a page number (0-indexed), NOT a row-skip count.** `offset=0` returns the first page, `offset=1` returns the second page, etc. Example: `offset=2, limit=50` returns items 100 to 149.

**Exceptions, where `offset` is a 0-indexed ROW offset (next page = offset + limit):** `POST /generate-reports/general-ledger` and `templated-general-ledger`, the AR/AP details reports (`ar-details-report`, `ap-details-report`, `templated-ar-details-report`, `templated-ap-details-report`), `/purchase-items` (list and search), `GET /organization/currencies/{code}/rates`, and `POST /employees/payouts/search`. There, `offset=2, limit=50` returns items 2-51.

Paging one of these by page number reads overlapping windows: duplicates in, the rest never reached, while `totalElements` still looks right.

| Property | Value |
|----------|-------|
| **GET list endpoints** | `?limit=100&offset=0` (query params) |
| **POST /search endpoints** | `{ "limit": 100, "offset": 0 }` (JSON body) |
| **Default limit** | 100 (if omitted) |
| **Default offset** | 0 (first page) |
| **Max limit** | 1000 |
| **Min limit** | 1 |
| **Max offset** | 65536 |
| **Response shape** | `{ totalPages, totalElements, data: [...] }` |

**`page`/`size` are NOT supported**: sending `?page=0&size=100` is silently ignored (API returns default 100 items as if no params were sent). Always use `limit`/`offset`.

**POST /search sort requirement**: When `offset` is present in the body (even `offset: 0`), `sort` is required:
```json
{ "limit": 100, "offset": 0, "sort": { "sortBy": ["createdAt"], "order": "DESC" } }
```

---

## Success Status Codes (All Endpoints)

**Never branch on the exact 2xx; check `response.ok` (or `status < 300`) and read the body.** Every success carries the same `{ data: ... }` envelope regardless of code.

| Shape | Code |
|---|---|
| `POST` that creates a NEW record: `/invoices`, `/bills`, `/journals`, `/contacts`, `/items`, `/chart-of-accounts`, `/tags`, `/tax-profiles`, `/capsules`, `/bookmarks`, `/bank-records/:acct`, `/:type/:id/payments`, `/:type/:id/refunds`, `/organization[-]currencies/:code/rates`, `/scheduled/*`, `/magic/*`, `/sale-orders/:id/convert-to-invoice` (and the other `convert-to-*`), the fixed-asset disposal actions (`/discard-fixed-assets/:id`, `/mark-as-sold/fixed-assets`, `/transfer-fixed-assets`) | **201** |
| `PUT` (update), all 40 of them, no exceptions | **200** |
| `GET` (all), `DELETE` (all) | **200** |
| `POST /search`, `POST /bulk-upsert`, and action POSTs that mutate an EXISTING record (`/:id/request-changes`, `/:id/credits`, `/:id/attachments`) | **200** |
| Async batch kickoff (`/bulk-request-changes`, claims `bulk/*`) | **202** |
| Quick Fix / bulk partial failure | **207** (body shape identical to 200; check `failed[]`) |

**Changed 2026-08-10**: seven `PUT` endpoints moved 201 → 200: `/bills/{id}`, `/contacts/{id}`, `/nano-classifiers/{id}`, `/items/{id}`, `/journals/{id}`, `/scheduled/journals/{id}`, `/organization/currencies/{code}/rates/{id}`. Request shapes and response bodies are byte-identical; only the status changed. The same release corrected 114 published success codes that disagreed with what the endpoints actually returned, so the API reference now matches runtime everywhere. A client that asserted `status === 201` on an update breaks; one that checks `response.ok` does not.

---

## 1. Organization

### GET /api/v1/organization

```json
// Response (returns a SINGLE OBJECT, not array):
{
  "data": {
    "resourceId": "31eb050a-...",
    "name": "Jaz Global SG",
    "currency": "SGD",
    "country": "SG",
    "status": "ACTIVE",
    "lockDate": null
  }
}
```

Access org via `data` directly (single object). The API previously returned an array but now returns a single object. Use `Array.isArray(data) ? data[0] : data` to handle both formats. Check `lockDate`; don't seed transactions before it.

---

## 2. Chart of Accounts

### GET /api/v1/chart-of-accounts?limit=200&offset=0

```json
// Response (flat list, NOT double-nested):
{
  "totalElements": 52,
  "totalPages": 1,
  "data": [{
    "resourceId": "uuid",
    "name": "Business Bank Account",
    "status": "ACTIVE",
    "accountClass": "Asset",
    "accountType": "Bank Accounts",
    "code": "90",
    "currencyCode": "SGD",
    "locked": false,
    "controlFlag": true
  }]
}
```

### POST /api/v1/chart-of-accounts/bulk-upsert

```json
// Request:
{
  "accounts": [
    {
      "name": "Sales Revenue",
      "currency": "SGD",
      "classificationType": "Operating Revenue",
      "code": "4000"
    },
    {
      "name": "Office Expenses",
      "currency": "SGD",
      "classificationType": "Operating Expense",
      "code": "5000"
    }
  ]
}

// Response:
{ "data": { "resourceIds": ["uuid1", "uuid2"] } }
```

Upsert matches by name: existing accounts updated, new ones created.

**Important**: The bulk-upsert endpoint does NOT return individual resourceIds for created/updated accounts. After a successful bulk-upsert, you MUST re-fetch the full CoA via `GET /api/v1/chart-of-accounts` to collect the new resourceIds.

**CRITICAL: CoA code mapping (match by NAME, not code)**:
- Pre-existing accounts may have different codes than your templates
- Example: "Cost of Goods Sold" = code 310 in the API, but code 5000 in template
- "Accounts Receivable" can have `code: null` in the API
- Always map template accounts to resource IDs via **name matching**, not code matching
- Resource IDs are the universal identifier, not codes
- When building lookup maps, key by BOTH `name` AND `code` for maximum compatibility:
```javascript
ctx.coaIds[acct.name] = acct.resourceId;
if (acct.code) ctx.coaIds[acct.code] = acct.resourceId;
```

### POST /api/v1/chart-of-accounts (Single Create)

```json
// Request:
{
  "name": "USD Bank Account",
  "currency": "USD",
  "classificationType": "Bank Accounts",
  "code": "91"
}

// Response:
{ "data": { "resourceId": "uuid" } }
```

Creates a single account. Unlike bulk-upsert, this returns the `resourceId` directly, no need to re-fetch.

**Key behaviors**:
- `classificationType` determines the account type (see mapping table below bulk-upsert). Using `"Bank Accounts"` creates an account that appears in the bank accounts list and can be used as `accountResourceId` in payments and cash entries.
- `currency` is optional. If omitted, defaults to the org's base currency. Pass a foreign currency code (e.g., `"USD"`) to create a **foreign-currency account**, useful for foreign-currency bank accounts.
- `code` is optional. If omitted, the system may auto-assign one (behavior varies).
- `name` must be unique within the organization; duplicate names return 409.
- **Alias**: `accountType` is accepted as an alias for `classificationType` (same as bulk-upsert).

**Creating a bank account**: Set `classificationType: "Bank Accounts"` and the account immediately becomes available as a bank account across the platform (payments, cash entries, bank records, reports).

### DELETE /api/v1/chart-of-accounts/:id

```json
// Response:
(empty body, 200 OK)
```

Deletes an account by `resourceId`. Returns 200 on success.

**Restrictions**:
- Cannot delete accounts with `controlFlag: true` (system accounts like Accounts Receivable, Accounts Payable); returns 400.
- Cannot delete accounts that have been used in transactions; returns 400 with `"Account has transactions"`.
- Cannot delete locked accounts (`locked: true`); returns 400.

---

## 3. Tax Profiles

### GET /api/v1/tax-profiles?limit=100&offset=0

```json
// Response:
{
  "totalElements": 10,
  "totalPages": 1,
  "data": [{
    "resourceId": "d8a5afbb-...",
    "taxTypeCode": "STANDARD_RATED_SUPPLIES",
    "displayName": "Standard-Rated Supplies (SR)",
    "vatValue": 9,
    "status": "ACTIVE"
  }]
}
```

Map `taxTypeCode` to `resourceId`. SG defaults: SR (9%), TX (9%), OS (0%), ZR (0%), ES (0%), EP (0%), IM (9%), plus a few more.

### POST /api/v1/tax-profiles

```json
// Request:
{ "name": "GST 9%", "taxRate": 9, "taxTypeCode": "STANDARD_RATED_SUPPLIES" }
// Response:
{ "data": { "resourceId": "..." } }
```

Name must be unique (422 if duplicate). Agent tools auto-guard with search-before-create.

### POST /api/v1/tax-profiles/search

```json
// Request: filter by appliesTo for transaction type scoping:
{
  "filter": {
    "name": { "contains": "GST" },
    "appliesToPurchase": { "eq": true }
  },
  "limit": 10, "sort": { "sortBy": ["name"], "order": "ASC" }
}
```

Key filters: `appliesToSale`, `appliesToPurchase`, `appliesToSaleCreditNote`, `appliesToPurchaseCreditNote` (all BooleanExpression). Use to avoid picking a sales-only profile for a bill.

### POST /api/v1/filing-submissions/search

Tax return filings (PH BIR forms such as 2550Q, SG GST F5): period, due date, tax payable, lifecycle and payment status. Read only. Tool: `search_filing_submissions`; CLI: `clio filing-submissions search`.

```json
// Request:
{
  "filter": {
    "formType": { "eq": "2550Q" },
    "periodYear": { "eq": 2026 },
    "lifecycleStatus": { "in": ["READY_TO_FILE", "SENT_TO_CLIENT"] }
  },
  "sort": { "sortBy": ["filingDueDate"], "order": "ASC" },
  "limit": 100, "offset": 0
}
```

- Filter fields: `resourceId`, `organizationResourceId`, `formType`, `displayFilingGroupings`, `periodStartDate`, `periodEndDate`, `filingDueDate`, `periodType`, `periodYear`, `periodValue` (month 1-12 or quarter 1-4), `lifecycleStatus`, `paymentStatus`, `taxDueAmount`, `taxForm.categoryCode`, `taxType.categoryCode`, plus `and` / `or` / `andGroup` / `orGroup`. The filter decodes strictly: any other key is a 400.
- Sort fields: `resourceId`, `createdAt`, `updatedAt`, `formType`, `periodType`, `periodYear`, `periodValue`, `periodStartDate`, `periodEndDate`, `filingDueDate`, `lifecycleStatus`, `taxDueAmount`. Default `createdAt` DESC. Sort is required when `offset` is set.
- `limit` 1-1000, default 100. `offset` is treated as a 0-indexed PAGE number, the platform norm. Row vs page is NOT measured here: the orgs probed held 0 and 1 filings, and the handler passes `offset` through unchanged, which both page and row endpoints also do.
- Rows carry `taxForm { formType, formName, categoryCode, ... }`, `periodType`, `periodYear`, `periodStartDate`, `periodEndDate`, `filingDueDate`, `taxDueAmount` (a decimal STRING, e.g. `"-659.11"`), `lifecycleStatus`, `paymentStatus`, `filingMode`.

### PUT /api/v1/cash-in-entries/:parentEntityResourceId

```json
// Request: resourceId required in body, accountEntryResourceId auto-populated:
{ "resourceId": "<parentEntityResourceId>", "reference": "UPDATED-REF" }
// Response:
{ "data": { "resourceId": "..." } }
```

Same pattern for `PUT /cash-out-entries/:id`. URL uses `parentEntityResourceId` (from CREATE response). `accountEntryResourceId` is optional; API auto-populates from existing journal entry.

Measured 2026-09-24 (Global SG Demo): a reference-only PUT succeeds and keeps a taxed entry's tax. Any PUT carrying `internalNotes` returns **500**, on taxed and untaxed entries alike, and any PUT carrying `lines` on a taxed entry (or adding a tax profile to an untaxed one) returns **500**, with or without `saveAsDraft: false` / `taxInclusion`. A `lines` PUT on an untaxed entry succeeds. So a taxed cash entry's lines cannot currently be edited through the API: void it and create a new one.

---

## 4. Currencies

### GET /api/v1/organization/currencies

```json
// Response (flat):
{
  "totalElements": 2,
  "totalPages": 1,
  "data": [{
    "currencyCode": "SGD",
    "currencyName": "Singapore Dollar",
    "currencySymbol": "S$",
    "baseCurrency": true,
    "customRateCount": 0
  }, {
    "currencyCode": "USD",
    "currencyName": "US Dollar",
    "currencySymbol": "US$",
    "baseCurrency": false,
    "customRateCount": 3
  }]
}
```

**CRITICAL field names**: Response uses `currencyCode`, `currencyName`, `currencySymbol`, `baseCurrency`, `customRateCount`, NOT `code`/`name`/`symbol`. If you destructure with `{ code, name, symbol }` you get `undefined`. Always use the full prefixed names.

- `customRateCount`: number of org-level custom rates set for this currency (0 if none)
- `baseCurrency`: boolean, `true` for the org's base currency only

### POST /api/v1/organization/currencies

```json
// Request:
{ "currencies": ["USD", "EUR", "GBP"] }

// Response:
{ "data": { "resourceIds": ["uuid1", "uuid2", "uuid3"] } }
```

Enable currencies first, then set rates via the **separate** rate endpoints below.

**Gotchas**:
- Returns **400** if a currency is already enabled: `"Currency already exists"`. You cannot un-enable currencies from an org; this is a one-way operation.
- To check before enabling: `GET /organization/currencies` and check if the code is already in the list.
- Sending an empty array `{ "currencies": [] }` returns 400.

### Currency Rates: `/organization/currencies/:code/rates`

**Path note**: both enable and rates live under the nested `/organization/currencies` family. The older hyphenated `/organization-currencies/...` rate paths still resolve but are **superseded**. Use the nested form; do not rely on the hyphenated one being documented.

#### POST /api/v1/organization/currencies/:currencyCode/rates

```json
// Request:
{ "rate": 0.74, "rateApplicableFrom": "2026-02-10" }

// Response:
{ "data": "Rate added successfully" }
// HTTP 201
```

**CRITICAL**: Response `data` is a **plain string** `"Rate added successfully"`, NOT a CurrencyRate object. You do NOT get back a `resourceId`. If you need the rate's `resourceId` (e.g., for later PUT/DELETE), you must follow up with `GET /organization/currencies/:code/rates` and match by `rateApplicableFrom` date.

**Required fields**:
- `rate`: positive number (must be > 0). Direction is **functionalToSource** (1 base = X foreign). Example for SGD org setting USD rate: `rate: 0.74` means 1 SGD = 0.74 USD. **If your data is sourceToFunctional (1 USD = 1.35 SGD), invert: `rate = 1 / yourRate`.** You do not have to do this by hand: pass your figure as-is with `rateDirection` (`FUNCTIONAL_TO_SOURCE` | `SOURCE_TO_FUNCTIONAL`), which this endpoint accepts natively and applies server-side. Omitting it means `FUNCTIONAL_TO_SOURCE`. See SKILL.md Rule 49.
- `rateApplicableFrom`: `YYYY-MM-DD` string (NOT ISO datetime; `"2026-02-10T00:00:00Z"` is rejected with "does not match 2006-01-02 format")

**Optional fields**:
- `rateApplicableTo`: `YYYY-MM-DD` string. Must be after `rateApplicableFrom` (422 `INVALID_DATE_RANGE` otherwise).

#### GET /api/v1/organization/currencies/:currencyCode/rates

```json
// Response:
{
  "data": {
    "totalElements": 12,
    "totalPages": 1,
    "data": [{
      "resourceId": "uuid",
      "rateFunctionalToSource": 0.74,
      "rateSourceToFunctional": 1.3514,
      "rateApplicableFrom": "2026-01-01",
      "rateApplicableTo": "2026-01-31",
      "sourceCurrencyCode": "USD",
      "functionalCurrencyCode": "SGD",
      "notes": { "date": "2026-01-01", "name": "Admin" }
    }]
  }
}
```

#### PUT /api/v1/organization/currencies/:currencyCode/rates/:resourceId

Takes the same optional `rateDirection` as the POST above, with the same `FUNCTIONAL_TO_SOURCE` default. Edit and add read a bare `rate` identically; supply the label to send an everyday quote verbatim.

```json
// Request:
{ "rate": 0.71, "rateApplicableFrom": "2026-02-10" }

// Response:
{ "data": "Rate updated successfully" }
// HTTP 200
```

#### DELETE /api/v1/organization/currencies/:currencyCode/rates/:resourceId

```json
// Response:
{ "data": { "message": "Rate deleted successfully" } }
// HTTP 200
```

**Boundary behavior**:
- Base currency rates → 400: `"Cannot set rate for organization base currency"` (GET also 400: `"Cannot lookup rate for organization base currency"`)
- Invalid ISO code (e.g., `"XYZ"`) → 422: `"validation_error"` (vs 404 for valid-but-not-enabled codes)
- `rate: 0` or negative → 422: `"rate must be greater than 0"`
- Very small rates (e.g., `0.0001`) are accepted

#### POST /api/v1/organization/currencies/rates/bulk-upsert

Create exchange rates in bulk (max 500). **Auto-enables currencies not yet enabled in the org.**

```json
// Request:
{
  "rates": [
    {
      "sourceCurrencyCode": "USD",
      "rate": 1.35,
      "rateDirection": "SOURCE_TO_FUNCTIONAL",
      "rateApplicableFrom": "2026-03-29"
    },
    {
      "sourceCurrencyCode": "EUR",
      "rate": 0.68,
      "rateDirection": "FUNCTIONAL_TO_SOURCE",
      "rateApplicableFrom": "2026-03-29",
      "rateApplicableTo": "2026-04-30"
    }
  ]
}

// Response:
{ "data": { "resourceId": null, "resourceIds": ["uuid1", "uuid2"] } }
```

`rateDirection` (SGD-base org, USD source): `FUNCTIONAL_TO_SOURCE` means the value is source-per-base: 1 SGD = 0.74 USD → send `0.74`. `SOURCE_TO_FUNCTIONAL` means base-per-source: 1 USD = 1.35 SGD → send `1.35` as-is, no inversion. All three rate endpoints (single add, single edit, bulk-upsert) accept the everyday quote this way; the difference here is that `rateDirection` is **required** on bulk-upsert and optional on the single-rate pair, where omitting it means `FUNCTIONAL_TO_SOURCE`. Unlike the single-rate POST endpoint, this returns `resourceIds` directly.

---

## 5. Contacts

### GET /api/v1/contacts?limit=100&offset=0

```json
// Response item:
{
  "resourceId": "uuid",
  "name": "Sterling Enterprises",
  "billingName": "Sterling Enterprises",
  "customer": true,
  "supplier": false,
  "currency": "SGD",
  "phone": "+6591234567",
  "email": "accounts@sterling.sg",
  "taxNumber": "201812345A"
}
```

### POST /api/v1/contacts

```json
// Request:
{
  "name": "Sterling Enterprises",
  "billingName": "Sterling Enterprises",
  "customer": true,
  "supplier": false,
  "currency": "SGD",
  "phone": "+6591234567",
  "email": "accounts@sterling.sg",
  "taxNumber": "201812345A",
  "addressLine1": "100 Robinson Road",
  "addressLine2": "#08-01",
  "city": "Singapore",
  "postalCode": "068902",
  "countryCode": "SG"
}

// Response:
{ "data": { "resourceId": "uuid", ... } }
```

---

## 6. Items

### GET /api/v1/items?limit=100&offset=0

```json
// Response item (includes both canonical and alias names):
{
  "resourceId": "uuid",
  "internalName": "Premium Coffee Beans",
  "name": "Premium Coffee Beans",
  "itemCode": "PREM-COFFEE",
  "type": "PRODUCT",
  "status": "ACTIVE"
}
```

### POST /api/v1/items

`name` alias is accepted (resolves to `internalName`).

```json
// Request (either field name works):
{
  "itemCode": "PREM-COFFEE",
  "internalName": "Premium Coffee Beans",
  "type": "PRODUCT",
  "appliesToSale": true,
  "saleItemName": "Premium Coffee Beans",
  "salePrice": 45.00,
  "saleAccountResourceId": "uuid",
  "saleTaxProfileResourceId": "uuid",
  "appliesToPurchase": true,
  "purchaseItemName": "Premium Coffee Beans",
  "purchasePrice": 25.00,
  "purchaseAccountResourceId": "uuid",
  "purchaseTaxProfileResourceId": "uuid"
}

// Response:
{ "data": { "resourceId": "uuid", ... } }
```

### POST /api/v1/items/bulk-upsert

Create or update items in bulk (max 500). Provide `resourceId` to update (partial: server merges with existing). Omit to create.

```json
// Request:
{
  "items": [
    {
      "itemCode": "BULK-001",
      "internalName": "Bulk Item",
      "appliesToSale": true,
      "saleItemName": "Bulk Item",
      "salePrice": 99.99
    },
    {
      "resourceId": "existing-uuid",
      "salePrice": 149.99
    }
  ]
}

// Response:
{ "data": { "resourceId": null, "resourceIds": ["uuid1", "uuid2"] } }
```

Create defaults: `status=ACTIVE`, `itemCategory=NON_INVENTORY`. Update: only send changed fields.

---

## 7. Invoices

### POST /api/v1/invoices

```json
// Request:
{
  "contactResourceId": "uuid",
  "saveAsDraft": false,
  "reference": "INV-001",
  "valueDate": "2026-02-08",
  "dueDate": "2026-03-10",
  "currency": "SGD",
  "lineItems": [{
    "name": "Premium Coffee Beans x 50",
    "unitPrice": 45.00,
    "quantity": 50,
    "accountResourceId": "uuid",
    "taxProfileResourceId": "uuid",
    "itemResourceId": "uuid"
  }]
}

// Response:
{ "data": { "resourceId": "uuid", "reference": "INV-001" } }
```

**saveAsDraft**: Defaults to `false`; omitting it creates a finalized transaction. Sending `saveAsDraft: true` creates a draft.

**GET response note**: When fetching invoices via GET, line items use `organizationAccountResourceId` (not `accountResourceId`). POST uses `accountResourceId`. Request-side aliases resolve `issueDate` → `valueDate`, `bankAccountResourceId` → `accountResourceId`, etc.

**FX (foreign currency) invoices**: For invoices in a non-base currency, use the `currency` OBJECT form:
- **`currency: { sourceCurrency: "MYR" }`**: platform auto-fetches rate from ECB (FRANKFURTER). Response shows `rateSource: "EXTERNAL"`, `providerName: "FRANKFURTER"`.
- **`currency: { sourceCurrency: "MYR", exchangeRate: 3.15 }`**: custom rate. Response shows `rateSource: "INTERNAL_TRANSACTION"`, `providerName: "CUSTOM"`.
- **`currencyCode: "MYR"` (string) is SILENTLY IGNORED**: the invoice is created in the org's base currency (e.g., SGD) with rate 1:1. No error returned. This is a major gotcha.
- **`currency: "USD"` (string)** causes "Invalid request body" error (400).

**Rate hierarchy** (when using `currency: { sourceCurrency }` without `exchangeRate`):
1. Org-level rate (set via `/organization/currencies/:code/rates`), auto-filled if exists
2. Platform rate (ECB via FRANKFURTER), auto-fetched if no org rate
3. Transaction-level rate (via `exchangeRate` in the `currency` object), overrides all

**Invoice payments**: `POST /invoices/{invoiceResourceId}/payments` works reliably as a standalone endpoint.

---

## 8. Bills

### POST /api/v1/bills

Same structure as invoices. All field names identical. FX currency rules also apply; use `currency` object form (see Section 7 FX notes).

**Bill payments**: The standalone `POST /bills/{id}/payments` endpoint was broken (nil pointer dereference in the API backend), **fixed in backend PR #112**. Both standalone and embedded payment approaches now work. The embed-in-creation pattern remains a valid alternative:

```json
// Request: Bill with embedded payment:
{
  "contactResourceId": "uuid",
  "saveAsDraft": false,
  "reference": "BILL-001",
  "valueDate": "2026-02-08",
  "dueDate": "2026-03-10",
  "lineItems": [{
    "name": "Office Supplies",
    "unitPrice": 500.00,
    "quantity": 1,
    "accountResourceId": "uuid",
    "taxProfileResourceId": "uuid"
  }],
  "payments": [{
    "paymentAmount": 500.00,
    "transactionAmount": 500.00,
    "accountResourceId": "uuid-of-bank-account",
    "paymentMethod": "BANK_TRANSFER",
    "reference": "PAY-BILL-001",
    "valueDate": "2026-02-08"
  }]
}
```

The `payments` array uses the same 6-field structure as standalone payments. production clients uses embedded payments for bills.

**Note**: Bill payments do NOT support `TransactionFeeCollected` (model field missing). Only invoice payments support collected transaction fees.

### Withholding Tax on Line Items

Bills and supplier credit notes support expanded withholding tax (EWT, Philippines) per line item. Copy `code`, `rate`, `type` and `description` from one `GET /withholding-codes` row (`list_withholding_codes`); a field left out is not stored:

```json
{
  "lineItems": [{
    "name": "Consulting services",
    "unitPrice": 10000,
    "quantity": 1,
    "accountResourceId": "expense-uuid",
    "withholdingTax": {
      "code": "WC 158",
      "rate": 1,
      "type": "corp",
      "description": "Income Payment Made By Top Withholding Agents To Their Local/Resident Supplier Of Goods Other Than Those Covered By Other Rates Of Withholding Tax"
    }
  }]
}
```

**On `WITHHOLDING_CODE_NOT_FOUND`**: check the code against `list_withholding_codes` (codes contain a space). If the organization does not use withholding (non-PH), remove `withholdingTax` and say so; never strip it silently. See errors.md.

Customer-side withholding (CWT) is not a line field: record the customer's BIR Form 2307 with `record_withholding_tax_certificate` (`POST /sales/batch-payments`, `paymentMethod: WITHHOLDING_TAX_CERTIFICATE`, `ph2307Details`). SKILL.md rule 98 has the rules.

---

## 9. Journals

### POST /api/v1/journals

```json
// Request:
{
  "saveAsDraft": false,
  "reference": "JV-001",
  "valueDate": "2026-02-08",
  "journalEntries": [
    { "accountResourceId": "uuid", "amount": 500, "type": "DEBIT", "description": "Line note" },
    { "accountResourceId": "uuid", "amount": 500, "type": "CREDIT" }
  ]
}

// Response:
{ "data": { "resourceId": "uuid" } }
```

**CRITICAL corrections from live testing**:
- Each entry uses `amount` (number) + `type`: `"DEBIT"` or `"CREDIT"` (UPPERCASE strings)
- Do NOT use `debit`/`credit` as separate number fields; that is WRONG
- Top-level `currency: { sourceCurrency, exchangeRate? }` IS accepted: it makes a foreign-currency journal
  whose amounts are in `sourceCurrency` (verified 2026-09-24: `{sourceCurrency: "USD", exchangeRate: 0.75}`
  created a draft reading back `currencyExchange.sourceCurrencyCode: USD`, `baseToSourceRate: 0.75`).
- `PUT /journals/{id}` does NOT keep `taxVatApplicable` or `taxInclusion` when they are omitted:
  `taxVatApplicable` falls to false, which strips every line's tax profile once lines are sent, and
  `taxInclusion` is cleared. Measured 2026-09-24: an ACTIVE taxed journal edited with its own lines and a
  note came back with no tax profile and VAT 0 (HTTP 200). Always restate both from a GET of the journal.
  The Clio tools and CLI do this for you (`update_journal`, `bulk_update_journals`, `clio journals update`,
  `clio journals draft finalize`).
- Total DEBIT amounts MUST equal total CREDIT amounts
- `contactResourceId` is a TOP-LEVEL field, NOT per entry. Probed 2026-09-02: an entry-level
  `contactResourceId` is silently discarded (an int there is accepted like any unknown key, while an
  int on `description` or `taxProfileResourceId` returns 400), and a real contact id on an entry does
  not appear on the created journal. Put it at the top level, where it does land.
- Per-line `exchangeRate` (optional, number): the rate from the LINE's account currency to the
  organization's base currency. This is the OPPOSITE direction to the journal's `currency.exchangeRate`
  (base to source). Only a line posting to an account held in another currency takes one:
  `exchangeRate: 0` is a 422, and a rate other than exactly 1 on a base-currency line is a 422. Honoured
  by `POST /journals`, `POST /transfer-trial-balance` and `POST /reconciliations/manual-journal` (the
  reconcile path applies it as sent and skips the base-currency check). `PUT /journals/{id}` validates it
  but does not apply it, so an update cannot change a line's rate. Send the top-level `exchangeRate`
  only: a nested line `currency: { exchangeRate }` spelling is accepted upstream but unpublished, and
  sending both with different values is a 422.

---

## 10. Cash Entries

### POST /api/v1/cash-in-entries
### POST /api/v1/cash-out-entries

```json
// Request (Cash-In example):
{
  "saveAsDraft": false,
  "reference": "CI-001",
  "valueDate": "2026-02-08",
  "accountResourceId": "uuid-of-bank-account",
  "lines": [
    { "accountResourceId": "uuid-of-revenue-account", "amount": 500, "type": "CREDIT" }
  ]
}

// Request (Cash-Out example):
{
  "saveAsDraft": false,
  "reference": "CO-001",
  "valueDate": "2026-02-08",
  "accountResourceId": "uuid-of-bank-account",
  "lines": [
    { "accountResourceId": "uuid-of-expense-account", "amount": 300, "type": "DEBIT" }
  ]
}

// Response:
{ "data": { "resourceId": "uuid" } }
```

**CRITICAL corrections from live testing**:
- `saveAsDraft` is REQUIRED; omitting it causes validation failure
- `accountResourceId` at top level = the BANK account (NOT `bankAccountResourceId`)
- `lines` array for the offset entries, same `amount` + `type` format as regular journals. (`journalEntries` is accepted as an alias but `lines` is now canonical for cash entries.)
- Do NOT use a flat structure with `amount`, `bankAccountResourceId`, `description`; that is WRONG
- The system auto-creates the bank-side entry; you only specify the offset entries in `lines`
- For cash-in: offset entries are typically CREDIT (revenue/liability)
- For cash-out: offset entries are typically DEBIT (expense/asset)

### GET /api/v1/cash-in-entries (LIST)
### GET /api/v1/cash-out-entries (LIST)

```json
// Response (same for both):
{
  "totalElements": 523, "totalPages": 523,
  "data": [{
    "resourceId": "uuid-cashflow-txn",
    "businessTransactionResourceId": "uuid-journal",
    "parentEntityResourceId": "uuid-from-create",
    "transactionReference": "CI-UFK9WHE3",
    "transactionStatus": "ACTIVE",
    "totalAmount": 627.5,
    "valueDate": 1759881600000,
    "direction": "PAYIN",
    "businessTransactionType": "JOURNAL_DIRECT_CASH_IN",
    "currencyCode": "SGD", "currencySymbol": "S$",
    "organizationAccountResourceId": "uuid-bank",
    "account": { "name": "Business Bank Account", "resourceId": "uuid-bank", "accountType": "Bank Accounts" }
  }]
}
```

### GET /api/v1/cash-in-entries/:resourceId
### GET /api/v1/cash-out-entries/:resourceId

Same shape as list items, wrapped in `{ data: {...} }`. Use the `resourceId` from LIST (cashflow-transaction ID), NOT the CREATE-returned resourceId.

### PUT /api/v1/cash-in-entries/:resourceId
### PUT /api/v1/cash-out-entries/:resourceId

Same request body as POST. Use the `parentEntityResourceId` (= the CREATE-returned resourceId).

### DELETE /api/v1/cash-entries/:resourceId

Shared delete endpoint for ALL cash entry types (cash-in, cash-out, cash-transfer). Use the `parentEntityResourceId` from LIST (= the CREATE-returned resourceId). NOT the cashflow-transaction `resourceId`.

```json
// Response:
(empty body, 200 OK)
```

**CRITICAL ID gotcha (verified via live testing)**:
- CREATE returns `resourceId = A` (this is `parentEntityResourceId`)
- LIST returns `resourceId = B` (cashflow-transaction ID), `parentEntityResourceId = A`
- GET accepts `B` (cashflow-transaction ID) or `A` (parentEntityResourceId; a transfer's two rows share one `A`, so GET by `A` returns the first)
- DELETE expects `A` (parentEntityResourceId, via `/cash-entries/A`)
- `businessTransactionResourceId = C` (underlying journal ID); do NOT use for any CRUD operation

---

## 11. Payments

### POST /api/v1/invoices/{invoiceResourceId}/payments (WORKS)
### POST /api/v1/bills/{billResourceId}/payments (FIXED in PR #112)

```json
// Request:
{
  "payments": [{
    "paymentAmount": 2250.00,
    "transactionAmount": 2250.00,
    "accountResourceId": "uuid-of-bank-account",
    "paymentMethod": "BANK_TRANSFER",
    "reference": "PAY-001",
    "valueDate": "2026-02-05"
  }]
}

// Response:
{ "data": { "resourceIds": ["uuid"] } }
```

**CRITICAL corrections from live testing**. Payments require 6 fields:
- `paymentAmount`: NOT `amount`. The **bank account currency** amount (actual cash moved from bank).
- `transactionAmount`: The **transaction document currency (invoice/bill/credit note)** amount (applied to the balance). Equal to `paymentAmount` for same-currency. For cross-currency (e.g., USD invoice paid from SGD bank at 1.35): `paymentAmount: 1350` (SGD), `transactionAmount: 1000` (USD).
- `accountResourceId`: NOT `bankAccountResourceId`. This IS the bank account UUID.
- `paymentMethod`: required string: `"BANK_TRANSFER"` (other values may exist but this works universally)
- `reference`: payment reference string (required)
- `valueDate`: NOT `paymentDate`. ISO date string.

Always wrap in `{ payments: [...] }` even for single payment.

**Bill payments standalone endpoint**: Was broken (nil pointer dereference), **fixed in backend PR #112**. Now works for basic payments. Embed-in-creation pattern also remains valid (see Section 8). production clients uses embedded payments for bills.

**TransactionFeeCollected**: NOT supported on bill payments (model field missing in the API backend). Only invoice payments support collected transaction fees.

---

## 12. Credit Notes

### POST /api/v1/customer-credit-notes
### POST /api/v1/supplier-credit-notes

```json
// Request (same structure for both):
{
  "contactResourceId": "uuid",
  "saveAsDraft": false,
  "reference": "CN-001",
  "valueDate": "2026-02-08",
  "lineItems": [{
    "name": "Return - Defective items",
    "unitPrice": 45.00,
    "quantity": 5,
    "accountResourceId": "uuid",
    "taxProfileResourceId": "uuid"
  }]
}

// Response:
{ "data": { "resourceId": "uuid" } }
```

### POST /api/v1/invoices/{invoiceResourceId}/credits

```json
// Request:
{
  "credits": [
    { "creditNoteResourceId": "uuid-of-credit-note", "amountApplied": 225.00 }
  ]
}
```

**CRITICAL corrections from live testing**:
- Wrap in `credits` array (NOT a flat object)
- Use `amountApplied` (NOT `amount`)
- Can apply multiple credit notes in one call by adding more entries to the array

---

## 13. Tags

### GET /api/v1/tags?limit=100&offset=0
### POST /api/v1/tags

`name` alias is accepted (resolves to `tagName`).

```json
// Request (either field name works):
{ "tagName": "Department: Sales" }
// or: { "name": "Department: Sales" }

// Response (includes both canonical and alias names):
{ "data": { "tagName": "Department: Sales", "name": "Department: Sales", "status": "ACTIVE", "resourceId": "uuid" } }
```

---

## 14. Custom Fields

### GET /api/v1/custom-fields?limit=100&offset=0
### POST /api/v1/custom-fields

```json
// Request (free-text field on invoices and bills):
{ "name": "PO Number", "printOnDocuments": false, "appliesTo": { "invoices": true, "bills": true } }

// Request (picklist of every customer):
{ "name": "Account Manager", "printOnDocuments": false, "format": "ALL_CUSTOMERS", "appliesTo": { "invoices": true } }

// Response:
{ "data": { "customFieldName": "PO Number", "name": "PO Number", "status": "ACTIVE", "resourceId": "uuid" } }
```

**CRITICAL notes (re-probed live 2026-09-02, correcting several earlier entries)**:
- **PUT works.** The 500 this file and SKILL.md rule 46 recorded is gone: a PUT returned 200 and applied the change. But it is a FULL REPLACE of `appliesTo` + `printOnDocuments`, and the GET returns `printOnDocuments` as null; the truth is in the `applyTo*` enum, where `PRINT` means it prints and `SHOW` means it does not. `updateCustomField` hydrates from that enum; a raw caller that omits `printOnDocuments` will silently turn printing off.
- POST uses `name`, GET returns both `customFieldName` and `name`
- `printOnDocuments` is REQUIRED and is not defaulted server-side; omitting it returns 422 `printOnDocuments is a required field` (recorded as a 400 before that). `create_custom_field` sends `false` when you omit it.
- **`appliesTo` WORKS and you should send it.** `{ invoices, bills, customerCredits, supplierCredits, payments }` sets the matching `applyTo*` response fields to `SHOW`. Omit it and every one stays `NULL`, i.e. the field appears on nothing. The previous "do NOT send appliesTo, causes Invalid request body" entry is wrong.
- **`format` is the only control over the KIND of field**, and the datatype is derived from it, not chosen: `CUSTOM` (default) yields `datatypeCode: TEXT`; any `ALL_*` value (`ALL_CUSTOMERS`, `ALL_SUPPLIERS`, `ALL_CONTACTS`, `ALL_EMPLOYEES`, `ALL_USERS`) yields `datatypeCode: LIST`, a picklist of that population.
- **There is no NUMBER, DATE or DROPDOWN custom field, and `type`/`fieldType`/`entityType`/`datatypeCode`/`options` are all silently dropped**: sent with a value, they return 200 and the field is created as plain TEXT. Verified by readback across five spellings; `datatypeCode: 12345` also returns 200, so the DTO does not declare it. `datatypeCode` on the response is derived and read-only; `format` is settable on POST **and** PUT (both verified 2026-09-02).

### GET /api/v1/custom-fields/:resourceId

Returns full custom field definition including `applyToSales`, `applyToPurchase`, `applyToCreditNote`, `applyToPayment`, `printOnDocuments`, `listOptions`, `datatypeCode`.

### POST /api/v1/custom-fields/search

```json
// Request:
{ "filter": { "customFieldName": { "contains": "PO" } }, "sort": { "sortBy": ["customFieldName"], "order": "ASC" }, "limit": 20, "offset": 0 }
```

### Setting Custom Field Values on Transactions

```json
// On invoice/bill/CN create or update: add at transaction level (NOT line item level):
{
  "valueDate": "2026-03-06",
  "contactResourceId": "...",
  "lineItems": [...],
  "customFields": [
    { "customFieldName": "PO Number", "actualValue": "PO-2026-001" },
    { "customFieldName": "Department", "actualValue": "Engineering" }
  ]
}
// Applies to: invoices, bills, customer credit notes, supplier credit notes
// Does NOT apply to: journals, cash entries, cash transfers
```

### Nano Classifiers on Line Items

```json
// On invoice/bill/CN line items: add classifierConfig per line item:
{
  "lineItems": [
    {
      "name": "Consulting Services",
      "quantity": 1,
      "unitPrice": 5000,
      "classifierConfig": [
        {
          "resourceId": "<capsuleTypeResourceId>",
          "type": "invoice",
          "selectedClasses": [{ "className": "Project Alpha", "resourceId": "<classResourceId>" }],
          "printable": true
        }
      ]
    }
  ]
}
// Create capsule types first: POST /api/v1/capsule-types
// Then reference them in classifierConfig on line items
```

---

## 14b. Inventory Items

### POST /api/v1/inventory-items

```json
// Request:
{
  "name": "Widget A",
  "itemCode": "WDG-A",
  "unit": "pcs",
  "appliesToSale": true,
  "appliesToPurchase": true,
  "saleItemName": "Widget A",
  "salePrice": 50.00,
  "saleAccountResourceId": "uuid-operating-revenue",
  "saleTaxProfileResourceId": "uuid-tax",
  "purchaseItemName": "Widget A",
  "purchasePrice": 30.00,
  "purchaseAccountResourceId": "uuid-inventory-account",
  "purchaseTaxProfileResourceId": "uuid-tax",
  "costingMethod": "WAC",
  "cogsResourceId": "uuid-direct-costs",
  "blockInsufficientDeductions": false
}

// Response:
{ "data": { "resourceId": "uuid" } }
```

**CRITICAL notes from live testing**:
- Send `name`, not `internalName`. The endpoint declares no `name` property and marks `internalName` required, but the API populates `internalName` from the `name` you send (verified by readback 2026-09-02)
- `unit` is REQUIRED (e.g., `"pcs"`, `"box"`, `"kg"`); omitting causes ITEM_UNIT_EMPTY_ERROR
- `blockInsufficientDeductions` is REQUIRED and is NOT defaulted server-side; omitting it fails with "blockInsufficientDeductions is a required field"
- `costingMethod` must be `"FIXED"` or `"WAC"` (NOT `"FIXED_COST"`)
- `cogsResourceId` is required, and MUST point to a Direct Costs account; wrong type causes INVALID_ACCOUNT_TYPE_DIRECT_COST
- `purchaseAccountResourceId` MUST point to an Inventory-type CoA account (NOT Direct Costs); wrong type causes INVALID_ACCOUNT_TYPE_INVENTORY. An inventory purchase debits the asset; COGS is recognised on sale
- Because `cogsResourceId` is always required, so are `purchaseAccountResourceId`, `saleAccountResourceId`, `appliesToSale` and `appliesToPurchase`; the API reports them as "required if [cogsResourceId] is present", but that condition always holds
- `appliesToSale` and `appliesToPurchase` must both be `true`, not merely present: `false` returns APPLIES_TO_SALE_ERROR / APPLIES_TO_PURCHASE_ERROR, "must be true when cogs selected". An inventory-tracked item with COGS is necessarily both sale- and purchase-applicable
- There is no `inventoryAccountResourceId`; it appears in no request schema and a create succeeds without it
- Delete inventory items via `DELETE /items/:id` (NOT `/inventory-items/:id`)
- `GET /inventory-item-balance/:id` returns balance per item
- `GET /inventory-balances/:balanceStatus` lists balances across items; `balanceStatus` is `ALL`, `AVAILABLE` or `FULLY_DRAWN` (else 422). An empty result is a 404, which `list_inventory_balances` returns as `data: []`

---

## 14c. Cash Transfers

### POST /api/v1/cash-transfers

```json
// Request:
{
  "valueDate": "2026-02-09",
  "saveAsDraft": false,
  "reference": "XFER-001",
  "cashOut": { "accountResourceId": "uuid-from-bank", "amount": 500 },
  "cashIn": { "accountResourceId": "uuid-to-bank", "amount": 500 }
}

// Response:
{ "data": { "resourceId": "uuid" } }
```

**CRITICAL**: Uses `cashOut`/`cashIn` sub-objects, NOT `fromAccountResourceId`/`toAccountResourceId`/`amount` flat fields. Each sub-object has `accountResourceId` and `amount`. Must use TWO DIFFERENT bank accounts; same account for both fails.

### GET /api/v1/cash-transfers (LIST)

Same cashflow-transaction response shape as cash-in/out list. `businessTransactionType` = `"JOURNAL_CASH_TRANSFER"`.

### GET /api/v1/cash-transfers/:resourceId

Accepts the cashflow-transaction `resourceId` from LIST or the `parentEntityResourceId` from CREATE (both legs share it; GET by it returns the first). Returns same shape wrapped in `{ data: {...} }`.

**Cash transfers have NO update (PUT) endpoint.**

DELETE uses shared `/cash-entries/:id` with the CREATE-returned resourceId (= `parentEntityResourceId`).

---

## 14d. Credit Note Refunds

### POST /api/v1/customer-credit-notes/{id}/refunds

```json
// Request:
{
  "refunds": [{
    "refundAmount": 75.00,
    "refundMethod": "BANK_TRANSFER",
    "transactionAmount": 75.00,
    "accountResourceId": "uuid-bank",
    "reference": "REFUND-001",
    "valueDate": "2026-02-09"
  }]
}

// Response:
{ "data": { "resourceIds": ["uuid"] } }
```

**CRITICAL**: Uses `refunds` wrapper with `refundAmount`/`refundMethod`, NOT `payments` wrapper with `paymentAmount`/`paymentMethod`.

---

## 14e. Contact Groups

### POST /api/v1/contact-groups

```json
// Request:
{ "name": "VIP Clients", "description": "Top-tier customers" }

// Response:
{ "data": { "resourceId": "uuid" } }
```

**Known bug**: `PUT /contact-groups/:id` returns 500. Use create + delete as workaround.

---

## 14f. Organization Bookmarks

### POST /api/v1/organization/bookmarks

```json
// Request:
{
  "items": [{
    "name": "Company Policy",
    "value": "https://example.com/policy",
    "categoryCode": "GENERAL_INFORMATION",
    "datatypeCode": "LINK"
  }]
}

// Response:
{ "data": [{ "name": "Company Policy", "resourceId": "uuid" }] }
```

Valid `categoryCode`: `AUDIT_AND_ASSURANCE`, `BANKING_AND_FINANCE`, `BUDGETS_AND_CONTROLS`, `EMPLOYEES_AND_PAYROLL`, `EXTERNAL_DOCUMENTS`, `GENERAL_INFORMATION`, `OWNERS_AND_DIRECTORS`, `TAXATION_AND_COMPLIANCE`, `WORKFLOWS_AND_PROCESSES`.

Valid `datatypeCode`: `TEXT`, `NUMBER`, `BOOLEAN`, `DATE`, `LINK`.

---

## 14g. Attachments

### GET /api/v1/:type/:resourceId/attachments

List attachments on a transaction. `:type` is `invoices`, `bills`, `journals`, `customer-credit-notes`, `supplier-credit-notes`, or scheduled variants.

Response (non-standard shape):
```json
{
  "reference": "INV-001",
  "resourceId": "<transactionId>",
  "attachments": [
    {
      "fileName": "receipt.pdf",
      "fileType": "PDF",
      "fileId": "abc123",
      "attachmentResourceId": "a2f1aa45-..."
    }
  ]
}
```

**Note:** Response uses `attachments` array (not the standard `data` wrapper). Attachment ID field is `attachmentResourceId` (not `resourceId`).

### POST /api/v1/:type/:resourceId/attachments (multipart)

Upload a file attachment. Multipart form-data with `file` field (binary, `application/pdf` or `image/*`). Returns the parent transaction with updated `attachments` array.

### DELETE /api/v1/:type/:resourceId/attachments/:attachmentResourceId

Delete an attachment. Returns the parent transaction with the attachment removed from the `attachments` array. HTTP 200 on success.

```
DELETE /api/v1/invoices/fe7a92fa-.../attachments/a2f1aa45-...
→ 200 { "reference": "INV-001", "resourceId": "fe7a92fa-...", "attachments": [] }
```

---

## 14h. Jaz Magic: Extraction & Autofill

### POST /api/v1/magic/createBusinessTransactionFromAttachment

**When the user starts from an attachment (PDF, JPG, document image), this is the endpoint to use.** Do not manually parse files to construct `POST /invoices` or `POST /bills`; Jaz Magic handles the full extraction-and-autofill pipeline server-side: OCR, line item detection, contact matching, and CoA auto-mapping via ML learning. Creates a complete draft transaction with all fields pre-filled. Use `POST /invoices` or `POST /bills` only when building from structured data where the fields are already known.

Processing is **asynchronous**: the API response confirms file upload immediately. The extraction pipeline runs server-side and pushes status updates via Firebase Realtime Database.

**Supported document types:**
- `INVOICE` → creates a draft sale (response type: `SALE`)
- `BILL` → creates a draft purchase (response type: `PURCHASE`)
- `CUSTOMER_CREDIT_NOTE` → creates a draft customer CN (response type: `SALE_CREDIT_NOTE`)
- `SUPPLIER_CREDIT_NOTE` → creates a draft supplier CN (response type: `PURCHASE_CREDIT_NOTE`)
- `SALE_QUOTE` → creates a DRAFT sale quote (response type: `SALE_QUOTE`); issue it with `isDraftToActiveSaleQuote`
- `SALE_ORDER` → creates a PENDING sale order (response type: `SALE_ORDER`); take it live with `isPendingToActiveSaleOrder`
- `PURCHASE_REQUEST` → creates a DRAFT purchase request (response type: `PURCHASE_REQUEST`); issue it with `isDraftToActivePurchaseRequest`
- `PURCHASE_ORDER` → creates a PENDING purchase order (response type: `PURCHASE_ORDER`); take it live with `isPendingToActivePurchaseOrder`

A PENDING order is on the dashboard's For Review tab, not in the live order lists. Activate it with the order update (`PUT /sale-orders/:id` / `PUT /purchase-orders/:id`); if that call sends `lineItems`, send every stored line with its `resourceId`.

Optional `internalNotes` (max 3000 characters) is set on the created record, e.g. a tag to find an automated upload again. Optional `uploadMode: "MERGED"` splits one PDF into several documents; it is refused (`MAGIC_MERGED_UPLOAD_NOT_SUPPORTED`) for the four quote/order/request types.

**Three modes** (content type depends on `sourceType`):

#### FILE mode (multipart/form-data), most common

```
POST /api/v1/magic/createBusinessTransactionFromAttachment
Content-Type: multipart/form-data

Fields:
  - sourceFile: PDF or JPG file blob (NOT "file")
  - businessTransactionType: "INVOICE", "BILL", "CUSTOMER_CREDIT_NOTE", "SUPPLIER_CREDIT_NOTE", "SALE_QUOTE", "SALE_ORDER", "PURCHASE_REQUEST", or "PURCHASE_ORDER"
  - sourceType: "FILE"
  - internalNotes: optional, max 3000 characters
```

```json
/ Response (201):
{
  "data": {
    "businessTransactionType": "PURCHASE",
    "filename": "NB64458.pdf",
    "invalidFiles": [],
    "validFiles": [{
      "workflowResourceId": "f47ac10b-58cc-4372-a567-0e02b2c3d479",
      "subscriptionFBPath": "magic_transactions/{orgId}/purchase/{fileId}",
      "errorCode": null,
      "errorMessage": null,
      "fileDetails": {
        "fileId": "6e999313b8b53ccef0757394ee6c7e6a",
        "fileType": "PDF",
        "fileURL": "https://s3.ap-southeast-1.amazonaws.com/.../{resourceId}.PDF",
        "fileName": "NB64458.pdf"
      }
    }]
  }
}
```

#### URL mode (application/json), for remote files

```json
/ Request:
POST /api/v1/magic/createBusinessTransactionFromAttachment
Content-Type: application/json

{
  "businessTransactionType": "BILL",
  "sourceType": "URL",
  "sourceURL": "https://example.com/invoice.pdf"
}

/ Response: same shape as FILE mode
```

#### HTML mode (raw email/document body)

Use when you hold the invoice/bill as raw HTML (e.g. an email body) rather than a file. The backend renders the HTML to a PDF, then runs the same extraction pipeline.

```json
/ Request:
POST /api/v1/magic/createBusinessTransactionFromAttachment
Content-Type: application/json

{
  "businessTransactionType": "INVOICE",
  "sourceType": "HTML",
  "html": "<html>…invoice markup…</html>"
}

/ Response: same shape as FILE mode
```

`html` is the raw HTML string (max 5 MB); also works as a multipart field. No file or PDF conversion step is needed.

**What Jaz Magic extracts and autofills:**
- Line items (description, quantity, unit price, amounts)
- Contact name and details (matched against existing contacts)
- Chart of Accounts mapping (ML-based learning from past transactions)
- Tax amounts and profiles
- Document reference numbers, dates, currency

**Encrypted PDFs:** Magic cannot process password-protected PDFs. The CLI auto-detects and decrypts before upload:
- Embed password in filename: `receipt__pw__s3cRetP@ss.pdf` → decrypts with password `s3cRetP@ss`, uploads as `receipt.pdf`
- `__pw__` delimiter is case-insensitive; password is case-sensitive
- Requires `qpdf` installed (`brew install qpdf`)
- If no password in filename, CLI prompts interactively (or errors in `--json` mode with actionable rename instructions)

**Key gotchas:**
- `sourceFile` is the field name (NOT `file`), same pattern as bank statement endpoint
- `EXPENSE` returns 422: use one of the 8 valid types above
- Response maps types: `INVOICE` → `SALE`, `BILL` → `PURCHASE`, `CUSTOMER_CREDIT_NOTE` → `SALE_CREDIT_NOTE`, `SUPPLIER_CREDIT_NOTE` → `PURCHASE_CREDIT_NOTE`; the four quote/order/request types keep their names
- There is no attachment-id source: the sources are `sourceFile`, `sourceURL` and `html` only
- JSON body with `sourceType: "FILE"` always fails (400); MUST use multipart
- `workflowResourceId` in `validFiles[]` is for tracking via `POST /magic/workflows/search`
- `subscriptionFBPath` is the Firebase path for real-time status updates
- All three fields (the source, `sourceFile`/`sourceURL`/`html`, plus `businessTransactionType` and `sourceType`) are required; omitting any returns 422
- File types confirmed: PDF, JPG/JPEG, PNG, HEIC, XLS, XLSX, EML (max 10 MB). HTML mode (`sourceType: "HTML"`) takes the raw HTML body instead of a file (max 5 MB); an `.eml` file is still FILE mode, not HTML mode.

---

### POST /api/v1/magic/workflows/search

Search across magic BT extraction workflows and bank statement imports. Use this to track the status of uploads and retrieve the created draft BT resource ID.

```json
/ Request:
POST /api/v1/magic/workflows/search
Content-Type: application/json

{
  "filter": {
    "resourceId": { "eq": "f47ac10b-58cc-4372-a567-0e02b2c3d479" },
    "documentType": ["SALE", "PURCHASE"],
    "status": ["COMPLETED"],
    "fileName": { "contains": "invoice" },
    "createdAt": { "gte": "2025-01-01", "lte": "2025-12-31" }
  },
  "limit": 20,
  "offset": 0,
  "sort": { "sortBy": ["createdAt"], "order": "DESC" }
}

/ Response (200):
{
  "data": [{
    "resourceId": "f47ac10b-58cc-4372-a567-0e02b2c3d479",
    "documentType": "SALE",
    "status": "COMPLETED",
    "fileName": "invoice.pdf",
    "fileType": "PDF",
    "fileUrl": "https://s3...",
    "fileId": "6e999313...",
    "createdAt": "2025-01-15",
    "updatedAt": "2025-01-15",
    "businessTransactionDetails": {
      "businessTransactionResourceId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
      "ocrJobType": "SYNC",
      "workflowStatus": "TRANSACTION_CREATED"
    }
  }],
  "totalElements": 1,
  "totalPages": 1
}
```

**Filter fields:**
- `resourceId`: StringExpression (eq, contains), workflow ID from magic create response
- `documentType`: Array: SALE, PURCHASE, SALE_CREDIT_NOTE, PURCHASE_CREDIT_NOTE, SALE_QUOTE, SALE_ORDER, PURCHASE_REQUEST, PURCHASE_ORDER, BANK_STATEMENT
- `status`: Array (SUBMITTED, PROCESSING, COMPLETED, FAILED)
- `fileName`: StringExpression, original uploaded filename
- `fileType`: Array (PDF, PNG, JPEG, JPG, HEIC, CSV, XLS, XLSX, EML)
- `createdAt`: DateExpression (eq, gte, lte), workflow creation date

**Workflow for agents:**
1. Upload via `POST /magic/createBusinessTransactionFromAttachment` → get `workflowResourceId`
2. Search with `filter.resourceId.eq` → check `status`
3. When `COMPLETED` → read `businessTransactionDetails.businessTransactionResourceId`
4. Use the BT resource ID with `GET /invoices/:id`, `GET /bills/:id`, `GET /customer-credit-notes/:id`, `GET /supplier-credit-notes/:id`, or the matching quote/order/request GET


---

## 15. Bank Records

### POST /api/v1/magic/importBankStatementFromAttachment (multipart)

The only endpoint for creating bank records. Uses multipart form upload:

```
POST /api/v1/magic/importBankStatementFromAttachment
Content-Type: multipart/form-data

Fields:
  - sourceFile: CSV/OFX bank statement file (NOT "file")
  - accountResourceId: UUID of the bank account CoA entry (NOT "bankAccountResourceId")
  - businessTransactionType: "BANK_STATEMENT"
  - sourceType: "FILE" (valid values: URL, FILE)
```

Max 10 MB per file. There is no attachment-id source.

CSV format: `Date,Description,Debit,Credit` (maps to Date, Description, Cash-out, Cash-in).

Multipart import is the more reliable method. Use it when JSON POST returns errors.

---

## 16. Schedulers

### POST /api/v1/scheduled/invoices

```json
// Request:
{
  "repeat": "MONTHLY",
  "startDate": "2026-03-01",
  "endDate": "2026-12-01",
  "invoice": {
    "contactResourceId": "uuid",
    "saveAsDraft": false,
    "reference": "SCH-INV-001",
    "valueDate": "2026-03-01",
    "dueDate": "2026-03-31",
    "lineItems": [{
      "name": "Monthly retainer",
      "unitPrice": 3000.00,
      "quantity": 1,
      "accountResourceId": "uuid",
      "taxProfileResourceId": "uuid"
    }]
  }
}
```

### POST /api/v1/scheduled/bills

Same but with `"bill"` wrapper instead of `"invoice"`.

### POST /api/v1/scheduled/journals

```json
// Request (FLAT structure, NOT nested in "journal" wrapper):
{
  "reference": "SCHED-JNL-001",
  "valueDate": "2026-03-01",
  "saveAsDraft": false,
  "schedulerEntries": [
    { "accountResourceId": "uuid", "amount": 100, "type": "DEBIT", "description": "Monthly accrual" },
    { "accountResourceId": "uuid", "amount": 100, "type": "CREDIT", "description": "Monthly accrual" }
  ],
  "repeat": "MONTHLY",
  "startDate": "2026-03-01",
  "endDate": "2026-12-01"
}

// Response:
{ "data": { "resourceId": "uuid" } }
```

**CRITICAL**: Scheduled journals use FLAT structure with `schedulerEntries`, NOT a nested `journal` wrapper like scheduled invoices/bills use `invoice`/`bill` wrapper. `reference`, `valueDate`, `saveAsDraft` are at top level alongside `repeat`/`startDate`/`endDate`.

**CRITICAL notes from live testing**:
- Recurrence field is `repeat`, NOT `frequency` or `interval`. Using `frequency` or `interval` silently defaults to ONE_TIME.
- Valid `repeat` values: `"ONE_TIME"`, `"DAILY"`, `"WEEKLY"`, `"MONTHLY"`, `"YEARLY"` (`"QUARTERLY"` is rejected with 422)
- `saveAsDraft: false` is REQUIRED on the wrapped invoice/bill. Using `saveAsDraft: true` causes `INVALID_SALE_STATUS` (invoices) or `INVALID_PURCHASE_STATUS` (bills).
- Since `saveAsDraft: false`, every line item MUST have `accountResourceId`.
- Response uses `interval` field (not `repeat`): `{ "status": "ACTIVE", "interval": "MONTHLY", ... }`

**`PUT /scheduled/journals/{id}`** (measured 2026-09-24):
- Every update must send `startDate` (422 `INVALID_VALUE_OF_START_DATE` otherwise) and the full `schedulerEntries` (422 `JOURNAL_SCHEDULER_ENTRIES_MISSING_ERROR` otherwise), even for a status or reference change.
- `taxVatApplicable` and `taxInclusion` RESET when omitted. A taxed, tax-inclusive schedule updated with its own entries and no flags stored tax off, inclusion off, VAT 0; the same update with both flags kept its VAT. `GET /scheduled/journals/{id}` returns neither flag (nor line tax profiles), so the stored values cannot be read back: state both on every update of a taxed schedule. `update_scheduled_journal` sends `taxVatApplicable: true` when an entry carries a tax profile and refuses without `taxInclusion`.

---

## 16b. Subscriptions (Recurring Invoices with Auto-Proration)

Subscriptions auto-generate invoices on schedule with proration support. **Different from scheduled invoices**: subscriptions auto-prorate partial periods (generate credit notes for mid-period changes), but currency/tax/account are immutable after creation. Invoices only, no bills.

### POST /api/v1/scheduled/subscriptions

```json
// Request:
{
  "repeat": "MONTHLY",
  "startDate": "2026-04-01",
  "status": "ACTIVE",
  "proratedConfig": {
    "proratedAdjustmentLineText": "Prorated adjustment"
  },
  "invoice": {
    "contactResourceId": "uuid-customer",
    "reference": "SUB-001",
    "valueDate": "2026-04-01",
    "dueDate": "2026-04-30",
    "lineItems": [
      { "name": "Monthly Retainer", "unitPrice": 3000, "quantity": 1, "accountResourceId": "uuid-revenue" }
    ],
    "saveAsDraft": false
  }
}

// Response:
{ "data": { "resourceId": "uuid" } }
```

**CRITICAL notes**:
- `proratedConfig` is **REQUIRED** on create, update, and cancel. Omitting it causes 500 (server null pointer).
- `businessTransactionType` is NOT in the OAS; the API ignores it. Don't send it.
- Uses `repeat` + `invoice` wrapper, same structure as scheduled invoices (`POST /scheduled/invoices`).
- `repeat`: `"ONE_TIME"`, `"DAILY"`, `"WEEKLY"`, `"MONTHLY"`, `"YEARLY"` (`"QUARTERLY"` is rejected with 422).
- `saveAsDraft: false` is REQUIRED inside the `invoice` wrapper.
- Currency, tax, and account details are the SAME for all items and CANNOT be changed after creation.
- Mid-period cancellations or amount changes auto-generate prorated credit notes.

### PUT /api/v1/scheduled/cancel-subscriptions/:id

Cancel is **PUT** (not POST). Requires body fields; empty `{}` returns 422.

```json
// Request:
{
  "cancelDateType": "END_OF_CURRENT_PERIOD",
  "proratedAdjustmentLineText": "Prorated adjustment",
  "resourceId": "uuid-subscription"
}

// Response:
{ "data": { "resourceId": "uuid", "status": "SUCCEEDED" } }
```

`cancelDateType` values: `END_OF_CURRENT_PERIOD` (default), `END_OF_LAST_PERIOD`, `CUSTOM_DATE` (requires `endDate: "YYYY-MM-DD"`).

Note the different path pattern from CRUD: cancel is at `/scheduled/cancel-subscriptions/:id`, not `/scheduled/subscriptions/:id/cancel`. Must cancel before delete; cannot delete ACTIVE subscriptions.

### Other subscription endpoints

- `GET /api/v1/scheduled/subscriptions`: List all subscriptions
- `GET /api/v1/scheduled/subscriptions/:id`: Get subscription details
- `PUT /api/v1/scheduled/subscriptions/:id`: Update subscription. The `invoice` template is required on every update (create shape; without it every update is a 422 `GENERAL_ERROR`, even endDate-only). `startDate` and `repeat` are kept when omitted. `status` is NOT: an update without it made an INACTIVE subscription ACTIVE (measured 2026-09-24), so Clio restates the stored status when you omit it.
- `DELETE /api/v1/scheduled/subscriptions/:id`: Delete subscription (must be cancelled first)

---

## 17. Reports

### POST /api/v1/generate-reports/trial-balance

```json
// Request:
{ "startDate": "2025-11-10", "endDate": "2026-02-08" }
```

Both dates required.

### POST /api/v1/generate-reports/balance-sheet

```json
// Request:
{ "primarySnapshotDate": "2026-02-28" }
```

Uses `primarySnapshotDate`, NOT `endDate`. Optional: `secondarySnapshotDates` array for comparison periods.

### POST /api/v1/generate-reports/profit-and-loss

```json
// Request:
{ "primarySnapshotDate": "2026-02-28", "secondarySnapshotDate": "2026-01-01" }
```

Both `primarySnapshotDate` and `secondarySnapshotDate` required. NOT `startDate`/`endDate`.

### POST /api/v1/generate-reports/general-ledger

```json
// Request:
{ "startDate": "2026-01-01", "endDate": "2026-02-28", "groupBy": "ACCOUNT", "limit": 200, "offset": 0,
  "filter": { "account": { "resourceId": { "in": ["<accountResourceId>"] } } } }
```

`groupBy` is required. Valid values: `"ACCOUNT"`, `"CONTACT"`, `"TRANSACTION"`, `"RELATIONSHIP"`, `"CAPSULE"`. Uses `startDate`/`endDate` like trial balance.

**Paging**: `limit` 1-1000; **omit it and the whole report comes back in one response** (630 rows is ~628KB). `offset` is a 0-indexed **ROW** offset, not a page number: the next page is `offset + limit`. Max offset 65536; past that, narrow the dates. Rows sit under `data.searchGeneralLedgersReport.data`, with `totalElements` and `totalPages` beside them.

**Filter**: `filter.account.resourceId` (StringExpression, e.g. `{ "in": [...] }`, max 100) narrows to accounts; `filter.account.accountType`, `contactResourceId.name`, `businessTransactionType`, `businessTransactionReference`, `description`, `tags` and the amount expressions are also accepted.

### POST /api/v1/generate-reports/cashflow

```json
{ "primaryStartDate": "2026-01-01", "primaryEndDate": "2026-02-28" }
```

Uses `primaryStartDate`/`primaryEndDate`, NOT `primarySnapshotDate`.

### POST /api/v1/generate-reports/cash-balance

```json
{ "reportDate": "2026-02-28" }
```

Single date field `reportDate`.

### POST /api/v1/generate-reports/ar-report
### POST /api/v1/generate-reports/ap-report

```json
{ "endDate": "2026-02-28" }
```

Single date field `endDate`.

### POST /api/v1/generate-reports/ar-summary-report
### POST /api/v1/generate-reports/ap-summary-report

```json
{ "startDate": "2026-01-01", "endDate": "2026-02-28" }
```

Both `startDate` and `endDate` required.

### POST /api/v1/generate-reports/bank-balance-summary

```json
{ "primarySnapshotDate": "2026-02-28" }
```

### POST /api/v1/generate-reports/equity-movement

```json
{ "primarySnapshotStartDate": "2026-01-01", "primarySnapshotEndDate": "2026-02-28" }
```

Uses `primarySnapshotStartDate`/`primarySnapshotEndDate`, yet another pair of field names.

### Data Exports

Data exports use SIMPLER field names than generate-reports:

| Export | Fields |
|--------|--------|
| `/data-exports/trial-balance` | `startDate`, `endDate` |
| `/data-exports/profit-and-loss` | `startDate`, `endDate` |
| `/data-exports/general-ledger` | `startDate`, `endDate`, `groupBy: "ACCOUNT"` |
| `/data-exports/ar-report` | `endDate` |

**Note**: P&L export uses `startDate`/`endDate` (NOT `primarySnapshotDate`/`secondarySnapshotDate` like generate-reports).

---

## 18. Cashflow Transactions Search

### POST /api/v1/cashflow-transactions/search

Searches across ALL cashflow transactions (invoices, bills, credit notes, journals, cash entries, payments). This is the unified transaction ledger.

```json
// Request:
{
  "filter": {
    "businessTransactionType": { "eq": "SALE" },
    "valueDate": { "gte": "2026-01-01" }
  },
  "sort": { "sortBy": ["valueDate"], "order": "DESC" },
  "limit": 100
}

// Response (flat, same as all other search endpoints):
{
  "totalElements": 1228,
  "totalPages": 13,
  "data": [{
      "resourceId": "uuid",
      "direction": "PAYIN",
      "totalAmount": 2250.00,
      "balanceAmount": 0,
      "grossAmount": 2250.00,
      "feeAmount": 0,
      "valueDate": 1706227200000,
      "matchDate": 1706313600000,
      "businessTransactionType": "SALE",
      "businessTransactionReference": "INV-001",
      "businessTransactionStatus": "POSTED",
      "currencyCode": "SGD",
      "currencySymbol": "S$",
      "functionalCurrencyCode": "SGD",
      "crossCurrency": false,
      "contact": { "name": "Acme Corp", "resourceId": "uuid" },
      "account": { "name": "Accounts Receivable", "resourceId": "uuid" },
      "organizationAccountResourceId": "uuid",
      "tags": ["Department: Sales"]
    }]
}
```

**Response shape**: Standard flat `{ totalElements, totalPages, data: [...] }` (same as all search/list endpoints).

**CRITICAL response dates**: `valueDate` and `matchDate` are `int64` epoch milliseconds (e.g., `1706227200000`), NOT `YYYY-MM-DD` strings. Convert: `new Date(epochMs).toISOString().slice(0, 10)`.

**Valid `businessTransactionType` values**: `SALE`, `PURCHASE`, `SALE_CREDIT_NOTE`, `PURCHASE_CREDIT_NOTE`, `JOURNAL_MANUAL`, `JOURNAL_DIRECT_CASH_IN`, `JOURNAL_DIRECT_CASH_OUT`, `JOURNAL_CASH_TRANSFER`, `FIXED_ASSET`.

**Valid `direction` values**: `PAYIN`, `PAYOUT`.

For full filter/sort field reference, see `references/search-reference.md` section 10.

---

## 19. Bank Records Search

### POST /api/v1/bank-records/:accountResourceId/search

Searches bank statement entries for a specific bank account. The `accountResourceId` path parameter is the UUID of a bank-type CoA account; find it via `POST /chart-of-accounts/search` with `{ "filter": { "accountType": { "eq": "Bank Accounts" } } }`.

```json
// Request:
{
  "filter": {
    "status": { "eq": "UNRECONCILED" },
    "valueDate": { "gte": "2026-01-01" }
  },
  "sort": { "sortBy": ["valueDate"], "order": "DESC" },
  "limit": 100
}

// Response:
{
  "totalElements": 280,
  "totalPages": 3,
  "data": [{
    "resourceId": "uuid",
    "description": "Payment from Acme Corp",
    "netAmount": 2500.00,
    "valueDate": 1706227200000,
    "status": "UNRECONCILED",
    "extContactName": "Acme Corp",
    "extReference": "TXN-12345",
    "extAccountNumber": "****1234"
  }]
}
```

**Valid `status` values**: `RECONCILED`, `UNRECONCILED`, `ARCHIVED`, `POSSIBLE_DUPLICATE`.

For full filter/sort field reference, see `references/search-reference.md` section 11.

---

## 20. Bank Records: JSON POST (Alternative)

### POST /api/v1/bank-records/:accountResourceId

In addition to multipart import (Section 15), bank records can be created via JSON POST:

```json
// Request:
{
  "records": [{
    "description": "Payment from client",
    "payerOrPayee": "Acme Corp",
    "reference": "TXN-001",
    "amount": 2500.00,
    "transactionDate": "2026-02-10",
    "metadata": []
  }]
}

// Response:
{ "data": { "resourceIds": ["uuid1"] } }
```

**Fields**:
- `records` (required): Array of 1-100 records
- `amount` (required): Positive = cash-in, negative = cash-out
- `transactionDate` (required): `YYYY-MM-DD` format
- `description`, `payerOrPayee`, `reference`: Optional strings (max 65536 chars)
- `metadata`: Optional array of `{ index, name, value }` objects (max 100)

**When to use**: JSON POST is best for programmatic creation. Multipart import (`POST /magic/importBankStatementFromAttachment`) is best for CSV/OFX file uploads.

---

## Advanced Search (POST /*/search)

All resources support `POST /api/v1/{resource}/search` with filter syntax. **For per-endpoint filter/sort field lists, see `references/search-reference.md`.**

### Request Example
```json
POST /api/v1/invoices/search
{
  "filter": {
    "status": { "eq": "UNPAID" },
    "valueDate": { "between": ["2026-01-01", "2026-12-31"] }
  },
  "sort": {
    "sortBy": ["valueDate"],
    "order": "DESC"
  },
  "limit": 100,
  "offset": 0
}
```

### Filter Operators
| Type | Operators |
|------|----------|
| String | `eq`, `neq`, `contains`, `in` (max 100), `reg` (max 100), `likeIn` (max 100), `isNull` |
| Numeric | `eq`, `gt`, `gte`, `lt`, `lte`, `in` (max 100) |
| Date | `eq`, `gt`, `gte`, `lt`, `lte`, `between` (exactly 2 YYYY-MM-DD values) |
| DateTime | Same as Date but RFC3339 format (for `createdAt`/`updatedAt`) |
| Boolean | `eq` |
| Logical | `and`, `or`, `not` (nested objects), `andGroup`, `orGroup` (arrays, invoices/bills/journals only) |

### Pagination
- `limit`: max 1000 per page (default 100)
- `offset`: page number, 0-indexed (max 65536); a ROW offset on the exceptions listed under "Pagination (All List Endpoints)"
- `sort`: **REQUIRED when `offset` is present** (even `offset: 0`)
- Response includes `totalElements` and `totalPages`

---

## Catalogs (Experimental)

> Endpoint availability varies by organization. Use try/catch; if all requests fail, the endpoint may not be enabled.

### Create Catalog
POST /api/v1/catalogs
```json
{
  "name": "Premium Products",
  "itemResourceIds": ["uuid-1", "uuid-2"],
  "description": "Curated product catalog for VIP customers"
}
```

### Response
```json
{ "data": { "resourceId": "catalog-uuid" } }
```

---

## Deposits (no endpoint, by design)

> **There is no deposits entity.** No `/deposits` route exists at any spelling and none is
> planned. A deposit is a *flag on a Chart of Accounts account*, not a document. See
> `feature-glossary.md` → Deposits for the business model, `errors.md` → Deposits Errors for
> the 404.

**The flag**: a CoA account carries `depositContactType`, which is `CUSTOMER` (customer deposit /
advance received, a liability), `SUPPLIER` (supplier deposit / advance paid, an asset), or
`NULL` (an ordinary account).

**The movement**: once an account is flagged, a deposit is an *ordinary transaction posted
against that account*. Nothing about the call is deposit-specific. The transaction types that
land on a deposit account are the normal ones: `SALE`, `PURCHASE`, `PAYMENT_SALE`,
`PAYMENT_PURCHASE`, `JOURNAL_DIRECT_CASH_IN`, `JOURNAL_DIRECT_CASH_OUT`, `JOURNAL_MANUAL`.

| Movement | Call |
|----------|------|
| Top up (advance received or paid) | `POST /api/v1/journals`, or `POST /api/v1/cash-in-entries` / `POST /api/v1/cash-out-entries`; one leg on the flagged account |
| Draw down against an invoice | `POST /api/v1/invoices/:resourceId/payments` with `accountResourceId` = the flagged account **and `paymentMethod: "OTHER"`** |
| Draw down against a bill | `POST /api/v1/bills/:resourceId/payments` with `accountResourceId` = the flagged account **and `paymentMethod: "OTHER"`** |
| Read deposit movements | `POST /api/v1/cashflow-transactions/search` filtered on the flagged account's `organizationAccountResourceId` |

> **The payment method is load-bearing on a drawdown.** The platform gates the payment
> account's TYPE on the method: `BANK_TRANSFER`, `CASH` and `CHEQUE` require a Bank
> Accounts or Cash account and reject anything else with
> `INVALID_ACCOUNT_FOR_BUSINESS_TRANSACTION_FOUND`. A deposit account is a Liability or
> Asset by construction, so it is only reachable under another method; `OTHER` is the
> plain choice. The tools default `paymentMethod` to `BANK_TRANSFER`, so a drawdown that
> does not set it explicitly will 422. See SKILL.md Rule 80.

**What the API cannot do**: `depositContactType` is not on any chart-of-accounts request or
response model in this API, and the platform-backend mutation that sets it
(`configureDepositAccounts`) is not proxied. **Flagging an account as a deposit account is a
web-app action.** `POST /chart-of-accounts` cannot create one. Over the API you can only read
and post against an account someone already flagged.

---

## Fixed Assets (Experimental)

> Endpoint availability varies by organization. Use try/catch.

### Create Fixed Asset
POST /api/v1/fixed-assets
```json
{
  "name": "Office Laptop - MacBook Pro",
  "purchaseAmount": 3500.00,
  "purchaseDate": "2026-01-15",
  "depreciationStartDate": "2026-01-15",
  "purchaseAssetAccountResourceId": "fixed-asset-coa-uuid",
  "depreciationMethod": "STRAIGHT_LINE",
  "effectiveLife": 36,
  "depreciableValueResidualAmount": 0,
  "depreciationExpenseAccountResourceId": "depreciation-expense-coa-uuid",
  "accumulatedDepreciationAccountResourceId": "accumulated-depreciation-coa-uuid",
  "saveAsDraft": true
}
```

- `purchaseDate` and `depreciationStartDate`: YYYY-MM-DD (both required; omitting returns 422)
- `purchaseAmount`: Purchase cost (required)
- `purchaseAssetAccountResourceId`: Asset account (required)
- `depreciationMethod`: `"STRAIGHT_LINE"` or `"NO_DEPRECIATION"`
- `effectiveLife`: Integer (months)
- `category`: `"TANGIBLE"` or `"INTANGIBLE"`
- `saveAsDraft`: Defaults to `true`. Set `false` to activate; requires `purchaseBusinessTransactionType` (`PURCHASE`, `SALE`, `JOURNAL_MANUAL`, `JOURNAL_CASHFLOW`, `JOURNAL_DIRECT_CASH_IN`, `JOURNAL_DIRECT_CASH_OUT` or `JOURNAL_CASH_TRANSFER`) + `purchaseBusinessTransactionResourceId`
- Optional string fields (`purchaseBusinessTransactionResourceId`, `capsuleResourceId`) can be safely omitted for drafts

### Response
```json
{ "data": { "resourceId": "asset-uuid" } }
```

### Transfer Fixed Asset (Register Pre-Existing)
POST /api/v1/transfer-fixed-assets

Register an asset purchased before using Jaz, with accumulated depreciation.

```json
{
  "name": "Office Laptop",
  "reference": "FA-000093",
  "category": "TANGIBLE",
  "typeCode": "COMPUTER_AND_ELECTRONIC",
  "typeName": "Computers and Electronics",
  "purchaseAmount": 5000,
  "purchaseDate": "2025-01-15",
  "purchaseAssetAccountResourceId": "asset-coa-uuid",
  "depreciationMethod": "STRAIGHT_LINE",
  "depreciationStartDate": "2026-03-30",
  "effectiveLife": 36,
  "bookValueAccumulatedDepreciationAmount": 1500,
  "accumulatedDepreciationAccountResourceId": "accum-deprec-coa-uuid",
  "depreciationExpenseAccountResourceId": "deprec-expense-coa-uuid",
  "saveAsDraft": false
}
```

- `bookValueAccumulatedDepreciationAmount`: depreciation already incurred before registration (Book Value at Start = purchaseAmount - this value)
- No `purchaseBusinessTransactionType`/`purchaseBusinessTransactionResourceId` (unlike Create, no linked transaction)
- All other fields same as Create

---

## Inventory (read-only balances, no adjustment write path)

> **There is no stock-adjustment endpoint at any spelling.** `POST /inventory/adjustments`,
> `/inventory-adjustments`, `/inventory-items/:id/adjustments` and
> `/items/:id/inventory-adjustments` all 404; none is registered. See `errors.md` →
> Inventory Adjustments Errors.

The complete inventory surface is three routes plus the item create/list pair:

| Method | Path | Notes |
|--------|------|-------|
| POST | `/api/v1/inventory-items` | Create an inventory-tracked item |
| GET | `/api/v1/inventory-items` | List inventory-tracked items |
| GET | `/api/v1/inventory-item-balance/:resourceId` | Balance for one item |
| GET | `/api/v1/inventory-balances/:balanceStatus` | Balances across items by status: `ALL`, `AVAILABLE`, `FULLY_DRAWN` (SKILL.md Rule 97) |

Stock moves only as a side effect of a transaction that carries the item (invoice, bill,
credit note). There is no direct quantity write.

---

---

## Field Aliases (Create/Update Endpoints)

Middleware on create and update endpoints transparently maps alias field names to canonical names. Both forms are accepted; the alias is only applied if the canonical field is absent.

| Alias | Canonical | Endpoints |
|-------|-----------|-----------|
| `issueDate` | `valueDate` | Invoices, bills, credit notes, journals, cash entries, cash transfers, all scheduled create/update endpoints |
| `date` | `valueDate` | Same as above (including scheduled endpoints) |
| `paymentDate` | `valueDate` | Payments (invoice/bill payments) |
| `bankAccountResourceId` | `accountResourceId` | Payments |
| `paymentAmount` | `refundAmount` | Credit note refunds |
| `paymentMethod` | `refundMethod` | Credit note refunds |
| `name` | `tagName` | Tags (create, update) |
| `name` | `internalName` | Items (create) |
| `accountType` | `classificationType` | Chart of accounts (create, update, bulk-upsert) |
| `currencyCode` | `currency` | Chart of accounts bulk-upsert |

**Note**: Aliases apply only to POST/PUT request bodies. Search filter fields use their canonical names (e.g., `tagName` not `name` in `POST /tags/search`).

---

## Auto-Wrapping (NormalizeToArray)

Middleware on payment, credit, and refund endpoints automatically wraps a flat JSON object into an array. Both formats work:

| Endpoint | Array format (preferred) | Flat format (auto-wrapped) |
|----------|------------------------|---------------------------|
| `POST /invoices/:id/payments` | `{ "payments": [{...}] }` | `{ "paymentAmount": ..., ... }` → auto-wrapped to `{ "payments": [{...}] }` |
| `POST /bills/:id/payments` | `{ "payments": [{...}] }` | Same |
| `POST /invoices/:id/credits` | `{ "credits": [{...}] }` | Same |
| `POST /bills/:id/credits` | `{ "credits": [{...}] }` | Same |
| `POST /customer-credit-notes/:id/refunds` | `{ "refunds": [{...}] }` | Same |
| `POST /supplier-credit-notes/:id/refunds` | `{ "refunds": [{...}] }` | Same |

**Recommendation**: Always use the array format for clarity and consistency.

---

---

## 17. Quick Fix (Bulk Update)

24 endpoints for bulk-updating transactions and line items in a single API call: 10 entities with both routes, plus 4 order entities with a line-items route only.

### Pattern

```
POST /api/v1/quick-fix/{entity}
POST /api/v1/quick-fix/{entity}/line-items
```

### Entities (grouped by domain)

**ARAP**: `invoices`, `bills`, `customer-credit-notes`, `supplier-credit-notes`
**Accounting**: `journals`, `cash-entries`
**Schedulers**: `sale-schedules`, `purchase-schedules`, `subscription-schedules`, `journal-schedules`
**Orders (line items only)**: `sale-orders`, `sale-quotes`, `purchase-orders`, `purchase-requests`. There is no `POST /api/v1/quick-fix/{order-entity}` route.

### Transaction-Level Request

```json
POST /api/v1/quick-fix/bills
{
  "resourceIds": ["uuid1", "uuid2"],
  "attributes": {
    "valueDate": "2026-03-01",
    "dueDate": "2026-03-31",
    "tags": ["Q1-2026"],
    "contactResourceId": "uuid"
  }
}
```

### Line-Item-Level Request (ARAP, Accounting, Orders)

```json
POST /api/v1/quick-fix/invoices/line-items
{
  "lineItemResourceIds": ["li-uuid1", "li-uuid2"],
  "attributes": {
    "name": "Updated Item",
    "quantity": 2,
    "unitPrice": 150,
    "organizationAccountResourceId": "acct-uuid",
    "taxProfileResourceId": "tax-uuid"
  }
}
```

### Line-Item-Level Request (Schedulers)

```json
POST /api/v1/quick-fix/sale-schedules/line-items
{
  "schedulerUpdates": [
    {
      "schedulerResourceId": "sched-uuid",
      "lineItemUpdates": [
        { "arrayIndex": 0, "unitPrice": 200 }
      ]
    }
  ]
}
```

### Response (all 24 endpoints)

**HTTP status codes**: 200 = complete success (`failed` always `[]`). **207 Multi-Status** = partial or total failure with per-item detail (same body shape as 200). 422/500 = total failure, standard error shape (no per-item data). On 207, retry only `failed` resourceIds (`updated` ones are done). A `failed` entry's `resourceId` is empty when the failure could not be matched to a record.

```json
{
  "updated": ["uuid1", "uuid2"],
  "failed": [
    { "resourceId": "uuid3", "error": "Transaction is locked", "errorCode": "TRANSACTION_LOCKED" }
  ]
}
```

### Updatable Fields

Only included fields are changed; omitted fields are left unchanged.

**Transaction-level by entity**:
- **Invoices**: valueDate, dueDate, invoiceNotes, templateResourceId, contactResourceId, billFrom, billTo, currencySettings, taxCurrencySettings, tags, customFields, capsuleResourceId
- **Bills**: valueDate, dueDate, contactResourceId, currencySettings, taxCurrencySettings, tags, customFields, capsuleResourceId
- **Customer CNs**: valueDate, notes, templateResourceId, contactResourceId, creditFrom, creditTo, currencySettings, taxCurrencySettings, tags, customFields, capsuleResourceId
- **Supplier CNs**: valueDate, contactResourceId, currencySettings, taxCurrencySettings, tags, customFields, capsuleResourceId
- **Journals**: valueDate, contactResourceId, tags, internalNotes, capsuleResourceId
- **Cash entries**: organizationAccountResourceId, valueDate, contactResourceId, capsuleResourceId, tags, reference, `currencySetting` (SINGULAR: `{ rateFunctionalToSource, exchangeToken }`), taxCurrencySettings
- **Sale/subscription schedules**: endDate, interval, invoiceNotes, templateResourceId, contactResourceId, billFrom, billTo, tags, customFields, capsuleResourceId (+ currencySettings/taxCurrencySettings for sale only)
- **Purchase schedules**: endDate, interval, contactResourceId, currencySettings, taxCurrencySettings, tags, customFields, capsuleResourceId
- **Journal schedules**: startDate, endDate, interval, contactResourceId, tags, internalNotes, capsuleResourceId

**Line items, ARAP and orders (Pattern B)**: name, quantity, unit, unitPrice, discount, itemResourceId, organizationAccountResourceId, taxProfileResourceId, classifierConfig, withholdingTax (purchase side only: bills, supplier CNs, purchase orders, purchase requests).

**Quick fix line-item shapes differ from create/update** (every Pattern B route, orders included):

| Field | Quick fix shape | Create / update shape |
|-------|-----------------|-----------------------|
| `discount` | `{ "rateType": "PERCENTAGE" \| "FLAT", "rateValue": 10 }` (rateValue ≥ 0; a percentage is 0-100) | a number |
| `withholdingTax` | `{ "code", "rate", "rateType", "type", "description"? }`: code, rate, rateType and type required | `{ "code", "rate" }` |

**Line item `classifierConfig`** (every line-item route): each entry sets or removes ONE classifier; classifiers the request does not name are left as they are, and `[]` changes nothing. Set: `{ resourceId, type, printable, selectedClasses }` with at least one class. Remove: `{ resourceId, deleted: true }` (type, printable and selectedClasses not needed). Each selected class needs `className` plus `resourceId`, or `entityResourceId` instead when the classifier draws its options from customers, suppliers, contacts, employees or users.

**Line items (journal/cash-entry, Pattern B)**: organizationAccountResourceId, amount, description, taxProfileResourceId, classifierConfig.

**Line items (schedulers, Pattern C, arrayIndex)**: name, description, sku, unit, unitPrice, quantity, discount, taxProfileResourceId, organizationAccountResourceId, classifierConfig, itemResourceId, withholdingTax (purchase only).

**Line items (journal-schedules, Pattern D, lineItemResourceId)**: amount, description, organizationAccountResourceId, taxProfileResourceId, classifierConfig, itemResourceId, unit, quantity, pricePerUnit.

### Pattern D Example (Journal Schedule Line Items)

```json
POST /api/v1/quick-fix/journal-schedules/line-items
{
  "schedulerUpdates": [
    {
      "schedulerResourceId": "sched-uuid",
      "lineItemUpdates": [
        {
          "lineItemResourceId": "line-uuid",
          "amount": 500,
          "organizationAccountResourceId": "acct-uuid"
        }
      ]
    }
  ]
}
```

Note: journal-schedules use `lineItemResourceId` (UUID), NOT `arrayIndex`.

**Tags**: string array, max 50 items, max 50 chars each. On invoices, bills and both credit notes the tags, joined with `|`, total at most 1000 characters, else 422.

---

## 17a. Ledger Find & Fix

Find and fix (recode) records across invoices, bills, both credit notes, journals and cash entries with one filter: preview one change, then apply the stored preview once. SKILL.md rule 107a carries the rules an agent must follow; the conditions, shapes and codes are here.

| Filter condition | Operators |
|------------------|-----------|
| `types` (required) | `INVOICE`, `BILL`, `CUSTOMER_CREDIT_NOTE`, `SUPPLIER_CREDIT_NOTE`, `JOURNAL` (manual and cashflow journals only), `CASH_ENTRY` |
| `resourceIds` | Up to 500; line item ids at line level |
| `reference` | `eq`, `in` (up to 100 values, as for every `in`), `contains`, `startWith` |
| `valueDate` | `eq`, `gte`, `lte`, `between: [from, to]` |
| `contactResourceId` | `eq`, `in` |
| `organizationAccountResourceId` | `eq`, `in`; line level only |

A filter needs a condition besides `types`, or the preview answers 422. A condition with no operator is ignored. A key or operator the API does not take, at any level of the request, is a 400 `invalid_filter` whose message names it. At line level, `resourceIds` are line item ids, and reference, date and contact conditions match the line's document.

### POST /api/v1/ledger/find-fix/transactions/preview

Finds records and plans one change: `contactResourceId`, `valueDate` (at most a year ahead), `capsuleResourceId` or `tags` (`{ add, remove }`; the record's other tags stay). A cash entry that is not void is always listed as changing its capsule, since its current capsule is not shown.

```json
{
  "filter": { "types": ["INVOICE", "BILL"], "valueDate": { "between": ["2026-04-01", "2026-06-30"] } },
  "change": { "contactResourceId": "new-contact-uuid", "tags": { "add": ["Q2"] } }
}
```

### POST /api/v1/ledger/find-fix/line-items/preview

Finds and recodes line items: `organizationAccountResourceId` (not a bank, cash or control account) or `classifierConfig` (max 50 entries, unique by resourceId, at most 100 classes each; unlisted classifiers stay, and `{ "resourceId": "classifier-uuid", "deleted": true }` removes one, with type and printable filled in by the API; a set needs type, printable and at least one class). On a set entry, a classifier the organization lacks, or a class it lacks or has deleted, is 422 `CLASSIFIER_NOT_FOUND` / `CLASS_NOT_FOUND`. A removal is not checked: removing a classifier the organization lacks is `NO_CHANGE`, so stored bad IDs can be cleaned up. A selection drawn from a record list (`entityResourceId`) is not checked either.

```json
{
  "filter": { "types": ["JOURNAL", "CASH_ENTRY"], "organizationAccountResourceId": { "eq": "old-account-uuid" } },
  "change": { "organizationAccountResourceId": "new-account-uuid" }
}
```

Preview response (both levels). The contact change below reaches the invoice; on the bill the contact is blocked, so only its tags change. `after` holds only the fields that will change:

```json
{
  "previewId": "Q2rX8vK1mZ4p",
  "expiresAt": "2026-09-15T10:30:00Z",
  "level": "TRANSACTIONS",
  "counts": {
    "matched": 3, "eligible": 2, "excluded": 1,
    "byType": { "INVOICE": 2, "BILL": 1 },
    "excludedByCode": { "VOID": 1 },
    "blockedByCode": { "CONTACT_NOT_SUPPLIER": 1 }
  },
  "data": [
    {
      "type": "INVOICE", "resourceId": "invoice-uuid", "reference": "INV-12", "valueDate": "2026-04-01", "status": "ELIGIBLE",
      "before": { "contactResourceId": "old-contact-uuid", "tags": [] },
      "after": { "contactResourceId": "new-contact-uuid", "tags": ["Q2"] }
    },
    {
      "type": "BILL", "resourceId": "bill-uuid", "reference": "BILL-7", "valueDate": "2026-04-02", "status": "ELIGIBLE",
      "blocked": [{ "field": "contactResourceId", "code": "CONTACT_NOT_SUPPLIER" }],
      "before": { "contactResourceId": "old-contact-uuid", "tags": [] },
      "after": { "tags": ["Q2"] }
    }
  ]
}
```

No `previewId` means nothing is eligible and nothing was stored. A row is `EXCLUDED` only for these codes:

| Row `code` | Meaning |
|------------|---------|
| `NOT_FOUND` | A listed id matched no record of the listed types; transfer and trial-balance journals and cash transfers never match |
| `VOID` | The record, or the line's document, is void |
| `NO_CHANGE` | Every field the change sets already has that value |
| `BLOCKED` | Every field the change sets is blocked on this record |

A blocked field is left as it is while the record's other fields still change, and the row stays `ELIGIBLE`:

| `blocked[].code` | Field | Why |
|------------------|-------|-----|
| `CONTACT_NOT_CUSTOMER`, `CONTACT_NOT_SUPPLIER` | contact | The new contact is not the customer or supplier the document needs |
| `HAS_CREDIT_OFFSETS` | contact | Credits applied to the document would stay with the old contact |
| `FOREIGN_CURRENCY_VALUE_DATE` | date | A new date on a foreign currency record would take a new exchange rate |
| `VALUE_DATE_AFTER_DUE_DATE` | date | The new date is after the document's due date |
| `TOO_MANY_TAGS` | tags | The record would end with more than 50 tags |
| `TAGS_TOO_LONG` | tags | Invoices, bills and credit notes: the record's tags, joined with `\|`, would total more than 1000 characters |
| `CONTROL_LINE` | account | A journal line on a bank, cash or control account |

### POST /api/v1/ledger/find-fix/apply

```json
{ "previewId": "Q2rX8vK1mZ4p" }
```

Single use. Saves the preview-time values, so a field edited between preview and apply, tags included, is overwritten. Once it has taken the preview, the apply runs to the end even if the caller leaves, sends calls of about 100 records (a document's lines stay together, so one call can carry more), and starts no new call 3 minutes after the request arrived. Lock dates are checked when a change is saved, so a record in a locked period comes back in `failed`. 200 when every record changed, 207 otherwise:

```json
{
  "failed": [{ "type": "INVOICE", "resourceId": "invoice-uuid", "error": "…", "errorCode": "OUTCOME_UNKNOWN" }],
  "updated": [{ "type": "BILL", "resourceId": "bill-uuid" }]
}
```

| `failed[].errorCode` | Meaning |
|----------------------|---------|
| `OUTCOME_UNKNOWN` | The answer for this record was lost, so it may or may not have changed |
| `NOT_ATTEMPTED` | The apply reached its 3-minute limit before sending this record, so it did not change |
| `UPSTREAM_REJECTED` | The platform refused the update before this record changed |
| `UNSUPPORTED_RECORD_TYPE` | The stored preview names a record type this version cannot change |

Several applies can run at the same time. When two applies change records on one document at once, the one saved last can undo the other's changes, including fields and line items it did not set. Preview again to check the result.

| Status | `error_details.code` | Meaning |
|--------|----------------------|---------|
| 404 | `PREVIEW_NOT_FOUND` | Expired, already applied or being applied, or another caller's preview. Preview again, never resend. |
| 400 | none; `error_type` is `invalid_filter` | A key or operator the request may not carry; the message names it |
| 422 | `CONTACT_NOT_FOUND`, `CAPSULE_NOT_FOUND` | Transactions preview: the contact or capsule the change sets does not exist; `resourceId` names it |
| 422 | `ACCOUNT_NOT_FOUND`, `ACCOUNT_NOT_ALLOWED` | Line items preview: the account the change sets does not exist, is inactive, or is a bank, cash or control account; `resourceId` names it |
| 422 | `CLASSIFIER_NOT_FOUND`, `CLASS_NOT_FOUND` | Line items preview, set entries only: a classifier the change sets does not exist, or a class it selects is not a current class of that classifier; `resourceId` names the classifier or class. A removal and a record-list selection (`entityResourceId`) are not checked |
| 422 | `TOO_MANY_RECORDS` | Either preview: at least `count` records or line items match, more than `limit` |
| 422 | `TOO_MANY_CALLS` | Transactions preview: the change needs `count` separate updates, more than `limit`, because records whose resulting values differ need separate updates. A line items preview never answers it |
| 503 | `PREVIEW_STORE_UNAVAILABLE` | On apply: nothing applied, and the preview may be used up (preview again if a retry answers `PREVIEW_NOT_FOUND`). On preview: no previewId was issued; run the preview again |

Any other 5xx, or a timeout, on apply: the outcome is unknown. If the connection drops, apply keeps running for up to 4 minutes. Wait that long before previewing or applying again on the same records, then preview again or re-read them. Never apply this previewId again. A service restart can stop an apply; preview again to check.

---

## 18b. Transfer Trial Balance

### POST /api/v1/transfer-trial-balance

Create opening balance entries for an organization. Used during onboarding to transfer balances from a prior accounting system. Entries are always created as ACTIVE (no draft state). The reference is auto-generated by the server; do not send one.

```json
// Request:
{
  "valueDate": "2026-01-01",
  "journalEntries": [
    { "accountResourceId": "uuid-bank", "amount": 50000, "type": "DEBIT" },
    { "accountResourceId": "uuid-retained-earnings", "amount": 50000, "type": "CREDIT" }
  ]
}

// Response:
{ "data": { "resourceId": "uuid" } }
```

**Key behaviors**:
- Always ACTIVE; no `saveAsDraft` field (ignored if sent)
- Reference is auto-generated; do not include `reference` in the request body
- Uses `journalEntries` (NOT `lines`), same as regular journals
- Debit/credit must balance (same as regular journals)
- Per-line `exchangeRate` works as on `POST /journals` (line account currency to base, foreign-currency account lines only)
- Creates a non-editable transfer journal visible in the general ledger

---

## 19. Payment Record CRUD

### GET /api/v1/payments/{resourceId}

Get a single payment record by its payment resourceId (NOT cashflow transaction ID).

```json
// Response:
{
  "data": {
    "resourceId": "uuid-payment",
    "reference": "PAY-001",
    "paymentAmount": 2250.00,
    "transactionAmount": 2250.00,
    "valueDate": "2026-03-01",
    "paymentMethod": "BANK_TRANSFER",
    "status": "ACTIVE",
    "type": "SALE_PAYMENT",
    "accountResourceId": "uuid-bank",
    "crossCurrency": false,
    "currencyCode": "SGD",
    "feeAmount": 0
  }
}
```

### PUT /api/v1/payments/{resourceId}

Update an existing payment record. All fields optional; only included fields are changed.

```json
// Request:
{
  "paymentAmount": 2500.00,
  "reference": "PAY-001-REV",
  "valueDate": "2026-03-02",
  "paymentMethod": "BANK_TRANSFER",
  "accountResourceId": "uuid-bank",
  "currency": { "sourceCurrency": "USD", "exchangeRate": 0.74 },
  // Both fees are OBJECTS, not a number and not a boolean. A bare value is rejected.
  "transactionFee": {
    "feeAccountResourceId": "uuid-fee-expense",
    "feeType": "FLAT",
    "feeValue": 5.00,
    "feeTaxVatApplicable": false
  },
  "transactionFeeCollected": {
    "feeAccountResourceId": "uuid-fee-income",
    "feeType": "FLAT",
    "feeValue": 2.00,
    "feeTaxVatApplicable": false
  },
  // Cash-leg adjustment: overpayment or rounding. Bank leg only, never AR/AP.
  // On update, add and change APPLY; remove does not (0 rejected, null reads as
  // omitted). Rejected if the payment is reconciled or an account on its ledger
  // rows is lock-dated. See rule 160.
  "adjustment": {
    "adjustmentValue": -0.03,
    "adjustmentAccountResourceId": "uuid-rounding-account",
    "adjustmentDescription": "rounding difference"
  }
}

// Response: same shape as GET
{ "data": { "resourceId": "uuid", ... } }
```

**Where to find payment resourceIds**: GET an invoice/bill → `paymentRecords[].resourceId`. These are payment IDs. Do NOT use cashflow transaction IDs from `POST /cashflow-transactions/search`.

---

## 19b. Invoice/Bill Sub-Resource Endpoints

### GET /api/v1/invoices/{resourceId}/payments

```json
// Response ({data: [...]} envelope):
{
  "data": [
    {
      "resourceId": "uuid-payment",
      "paymentAmount": 2250.00,
      "transactionAmount": 2250.00,
      "valueDate": 1709251200000,
      "paymentMethod": "BANK_TRANSFER",
      "reference": "PAY-001"
    }
  ]
}
```

Same envelope for `GET /bills/{resourceId}/payments`.

**CRITICAL**: `GET /invoices/{resourceId}/credits` and `GET /bills/{resourceId}/credits` differ:
`{ "TotalElements": n, "data": [...] }` when credits are applied, but a BARE `[]` when none
are (read from the API source; the empty case measured raw 2026-09-24). The CLI and tools normalize both to `{data: [...]}`.

---

## 19c. Request Changes

Sends a **submitted** record back to its creator for edits: the record returns to draft, its
approval markers are cleared, and `message` is posted as the first comment on a new
collaboration thread. Records in any other state are skipped and reported in the response.

> **Wrapped.** MCP tools `request_document_changes` / `bulk_request_document_changes`, CLI
> `clio approvals request-changes` / `clio approvals bulk-request-changes`.

Nine entities, each with a single-record and a bulk form:

| Entity | Single | Bulk |
|--------|--------|------|
| Invoices | `POST /api/v1/invoices/:resourceId/request-changes` | `POST /api/v1/invoices/bulk-request-changes` |
| Bills | `POST /api/v1/bills/:resourceId/request-changes` | `POST /api/v1/bills/bulk-request-changes` |
| Customer credit notes | `POST /api/v1/customer-credit-notes/:resourceId/request-changes` | `POST /api/v1/customer-credit-notes/bulk-request-changes` |
| Supplier credit notes | `POST /api/v1/supplier-credit-notes/:resourceId/request-changes` | `POST /api/v1/supplier-credit-notes/bulk-request-changes` |
| Purchase orders | `POST /api/v1/purchase-orders/:resourceId/request-changes` | `POST /api/v1/purchase-orders/bulk-request-changes` |
| Purchase requests | `POST /api/v1/purchase-requests/:resourceId/request-changes` | `POST /api/v1/purchase-requests/bulk-request-changes` |
| Sale orders | `POST /api/v1/sale-orders/:resourceId/request-changes` | `POST /api/v1/sale-orders/bulk-request-changes` |
| Sale quotes | `POST /api/v1/sale-quotes/:resourceId/request-changes` | `POST /api/v1/sale-quotes/bulk-request-changes` |
| Claims | `POST /api/v1/claims/:resourceId/request-changes` | `POST /api/v1/claims/bulk/request-changes` |

**Claims breaks the bulk path pattern**: `/claims/bulk/request-changes`, not
`/claims/bulk-request-changes`. The other eight are all `bulk-request-changes`.

### Single: request body (200)
```json
{ "message": "Please attach the signed delivery note before resubmitting." }
```
`message` is required and `minLength: 1`; a blank value is rejected. It is the only place the
reason is recorded.

```json
// Response: 200 (per-record outcome; a skipped record does NOT fail the call)
{
  "data": {
    "records": [
      {
        "resourceId": "...",
        "isSuccess": true,
        "status": "DRAFT",
        "approvalStatus": "...",
        "collaborationThreadPath": "...",
        "errorCode": null,
        "failureReason": null
      }
    ]
  }
}
```
**Check `isSuccess` per record**: a record in the wrong state comes back with
`isSuccess: false` + `errorCode`/`failureReason`, inside a 200.

### Bulk: request body (202)
```json
{
  "message": "Please attach the signed delivery note before resubmitting.",
  "resourceIds": ["b7a2c3d4-e5f6-7890-abcd-ef1234567890"]
}
```
`resourceIds`: 1 to 500 per call. `message` applies to every record in the batch.

```json
// Response: 202 (async job handle, NOT the outcome)
{ "data": { "jobId": "...", "status": "...", "totalRecords": 12, "totalChunks": 1, "subscriptionFBPath": "..." } }
```
Poll the result with `POST /api/v1/background-jobs/search` (section 23); the 202 only means
the job was accepted.

---

## 19d. Approve

Approves a record awaiting approval. **Irreversible**: it posts the ledger (and for invoices and
bills also confirms a linked order and consumes the document reference). There is nothing to undo, and approving is one-shot
(a record not awaiting approval, including an already-approved one, is refused).
MCP tools `approve_documents` / `bulk_approve_documents`, CLI `clio approvals approve` /
`clio approvals bulk-approve`. Four entities: `invoices`, `bills`, `customer-credit-notes`,
`supplier-credit-notes` (claims approve through their own claims routes).

| Form | Route | Response |
|------|-------|----------|
| Single | `POST /api/v1/{entity}/:resourceId/approve` (no body) | 200, `{ "data": { "records": [ { resourceId, status, approvalStatus, isSuccess, failureReason, errorCode } ] } }` |
| Bulk | `POST /api/v1/{entity}/bulk-approve` | 202, job handle (same shape as 19c bulk); poll `POST /api/v1/background-jobs/search` |

- Bulk body `{ "resourceIds": [...] }`: 1-100 **unique** UUIDs. Over 100 or a duplicate is 422
  with nothing written.
- Single form: an id not found in your organization is 404.
- An id not found in your organization rejects the **whole** bulk batch with 422 and no job is
  dispatched: fix the list and retry.
- Bulk: records not awaiting approval are skipped and reported on the job tasks; only a foreign
  id fails the batch.
- Refusal channel differs: invoices/bills return 422 on a refused single approve; credit notes
  return 200 with `isSuccess: false` while `approvalStatus` still reads the document's state.
  Check `isSuccess`, never `approvalStatus`.
- On a timeout or 500, read the record back and check its approval status before retrying.

---

## 20. Nano-Classifier CRUD

### POST /api/v1/nano-classifiers: Create

```json
// Request:
{
  "type": "Region",
  "classes": ["North", "South", "East", "West"],
  "printable": false
}

// Response:
{ "data": { "resourceId": "uuid" } }
```

**CRITICAL**: `classes` is a `string[]` (NOT `classNames`, NOT `[{className}]`). `printable` is required, defaults to `false`.

### GET /api/v1/nano-classifiers/{resourceId}: Double-wrapped response

```json
// Response (DOUBLE-WRAPPED):
{
  "data": {
    "data": [
      {
        "resourceId": "uuid",
        "type": "Region",
        "printable": false,
        "classes": [
          { "className": "North", "resourceId": "uuid-class-1" },
          { "className": "South", "resourceId": "uuid-class-2" }
        ]
      }
    ],
    "totalElements": 1,
    "totalPages": 1
  }
}
```

**CRITICAL**: GET single is double-wrapped: `{data: {data: [...], totalElements, totalPages}}`. Extract `res.data.data[0]` to get the classifier. Classes in response are objects (`{className, resourceId}`), not strings.

---

## 21. Scheduler GET/PUT/DELETE

### GET /api/v1/scheduled/invoices/{resourceId}

```json
// Response (uses `interval`, NOT `repeat`):
{
  "data": {
    "resourceId": "uuid",
    "status": "ACTIVE",
    "interval": "MONTHLY",
    "startDate": "2026-03-01",
    "endDate": "2026-12-01",
    "nextScheduleDate": "2026-04-01",
    "businessTransactionType": "SALE"
  }
}
```

### PUT /api/v1/scheduled/invoices/{resourceId}

Accepts scheduling fields AND the full invoice template (same structure as POST):

```json
// Request:
{
  "repeat": "MONTHLY",
  "startDate": "2026-03-01",
  "endDate": "2027-03-01",
  "invoice": {
    "contactResourceId": "uuid",
    "saveAsDraft": false,
    "reference": "SCH-INV-001-UPDATED",
    "valueDate": "2026-03-01",
    "dueDate": "2026-03-31",
    "lineItems": [{ "name": "Quarterly retainer", "unitPrice": 9000, "quantity": 1, "accountResourceId": "uuid" }]
  }
}
```

Same pattern for `PUT /scheduled/bills/:id` (uses `bill` wrapper) and `PUT /scheduled/journals/:id` (flat structure with `schedulerEntries`, same as POST).

---

## 22. Contacts Bulk Upsert

### POST /api/v1/contacts/bulk-upsert

**ASYNC**: returns a `jobId`. Poll `/background-jobs/search` with `filter.resourceId.eq` until terminal status.

```json
// Request
{
  "contacts": [
    {
      "billingName": "Acme Corp",
      "customer": true,
      "emails": ["billing@acme.com"],
      "currencyCode": "SGD"
    },
    {
      "resourceId": "existing-uuid-here",
      "paymentTerms": 30
    }
  ]
}

// Response
{
  "data": {
    "jobId": "job-uuid-abc-123",
    "status": "QUEUED",
    "totalRecords": 2,
    "totalChunks": 0
  }
}
```

Poll: `POST /background-jobs/search` body: `{ "filter": { "resourceId": { "eq": "job-uuid-abc-123" } }, "limit": 1 }`

---

## 23. Background Jobs Search

### POST /api/v1/background-jobs/search

🚨 **CRITICAL**: Filter by `resourceId` (NOT `jobId`). `filter.jobId.eq` is silently ignored.

```json
// Request: look up a specific job
{
  "filter": { "resourceId": { "eq": "job-uuid-abc-123" } },
  "limit": 1
}

// Request: find all failed jobs from today
{
  "filter": {
    "status": { "in": ["FAILED", "PARTIAL_SUCCESS"] },
    "createdAt": { "gte": "2026-04-09" }
  },
  "limit": 20
}

// Response
{
  "data": [{
    "jobId": "job-uuid-abc-123",
    "status": "SUCCESS",
    "jobType": "UPSERT_CONTACTS",
    "totalRecords": 50,
    "processedCount": 50,
    "failedCount": 0,
    "startedAt": 1744200000000,
    "finishedAt": 1744200012000,
    "errorDetails": []
  }],
  "totalElements": 1,
  "totalPages": 1
}
```

Terminal statuses: `SUCCESS`, `FAILED`, `PARTIAL_SUCCESS`. On `PARTIAL_SUCCESS`, `errorDetails` contains per-record errors.

---

## 24. Export Records

### GET /api/v1/export-records/columns/:entityType

```json
// Response
{
  "data": {
    "columns": [
      { "path": "s.reference", "header": "Invoice Ref #", "type": "STRING", "isDefault": true },
      { "path": "s.total_amount", "header": "Total Amount", "type": "CURRENCY", "isDefault": true },
      { "path": "s.value_date", "header": "Invoice Date", "type": "DATE", "isDefault": true }
    ]
  }
}
```

### POST /api/v1/export-records/preview

```json
// Request
{
  "entityType": "INVOICE",
  "outputFormat": "XLSX",
  "query": "status:unpaid $500+"
}

// Response
{
  "data": {
    "totalRecords": 127,
    "filterDescription": "127 records | Status in: UNPAID | Amount >= 500",
    "resolvedColumns": [
      { "path": "s.reference", "header": "Invoice Ref #", "type": "STRING", "isDefault": true }
    ],
    "previewRows": [
      { "Invoice Ref #": "INV-001", "Customer": "Acme Corp", "Total Amount": "1500.00" }
    ],
    "warnings": null
  }
}
```

Note: `previewRows` keys are column **headers** (not paths). `filter` and `query` are mutually exclusive.

### POST /api/v1/export-records

```json
// Request
{
  "entityType": "INVOICE",
  "outputFormat": "XLSX",
  "filter": { "status": { "in": ["UNPAID"] } }
}

// Response
{
  "data": {
    "fileUrl": "https://s3.amazonaws.com/exports/Invoices.xlsx?X-Amz-Expires=300&...",
    "fileName": "Invoices.xlsx",
    "totalRecords": 127
  }
}
```

`fileUrl` is a pre-signed S3 URL expiring in ~5 minutes. Download immediately.

---

## 25. Jots (Judgment Journal)

### POST /api/v1/jots

Batch-record judgment entries (1-100 per call). Per-entry independent: acks come back in input order, and a bad entry never fails the batch, it acks with `error` + `errorCode` while siblings persist. Rate valve: 600 writes per organization per minute.

```json
// Request
{
  "entries": [
    {
      "kind": "CLASSIFICATION",
      "tier": "MEDIUM",
      "call": "Posted to 6420 Travel: recurring vendor pattern",
      "why": "Same vendor posted to 6420 in 11 prior bills",
      "refs": [{ "raw": "BILL:41f626a3-...:CREATE" }],
      "idempotencyKey": "close-2026-06-bill-41f626a3"
    }
  ]
}

// Response
{ "data": { "records": [{ "resourceId": "...", "replayed": false, "duplicateCount": 0 }] } }
```

`kind`: CLASSIFICATION, MATCH, SCOPE, ASSUMPTION, RISK, METHOD, RECOVERY, DEVIATION, NOTE (the neutral fallback for a judgment logged without a declared type; a missing or blank `kind` defaults to NOTE and is flagged, not rejected; never declare NOTE deliberately). `tier`: LOW, MEDIUM, HIGH, CRITICAL. `refs` entries are OBJECTS: the string grammar `TYPE:resourceId[#field][:RELATION]` travels in `raw` (an unparseable ref is stored with `parsed: false`, never bounced). Optional fields: `ruledOut`, `frame`, `confidence`, `citedRule`, `workflowLabel`, `agentLabel`. `idempotencyKey` makes retries safe: a replay returns the existing entry with `replayed: true`.

**Jot doctrine (fill fields consistently; the server re-scores tier from kind + refs, and declared-vs-computed agreement is a review signal):**

- **When**: log a judgment when you chose among real alternatives and a write followed, or when you deliberately decided NOT to write. Skip mechanical actions. Jot AFTER the write succeeds; carry the written record's resourceId in `refs`.
- **Tier anchors** (mirror the server's rules): CRITICAL = money leaves (`PAY` ref), data destroyed (`DELETE` ref), external send or period lock (`FINALIZE` ref), or a RECOVERY that still drove a write. HIGH = the withheld write (RECOVERY with no mutation ref; it pins via withheld-write, not tier), or RISK/MATCH backed by a write. LOW = a DEVIATION detached from any write. MEDIUM = everything else.
- **Kind boundaries**: where a value LANDS (account, tax code) = CLASSIFICATION; how it is COMPUTED = METHOD. Filling one missing fact = ASSUMPTION; drawing a set boundary = SCOPE (carry `frame`). A decided omission after failure = RECOVERY, never DEVIATION.
- **Refs relation** is load-bearing: state what the write did (CREATE/UPDATE/DELETE/FINALIZE/PAY/RECONCILE/TRIGGER); SUBJECT only for no-write entries. PAY/DELETE/FINALIZE pin the jot regardless of declared tier.
- **confidence**: HIGH = clear rule or precedent; MEDIUM = pattern inference; LOW = a guess a reviewer should check.
- **workflowLabel**: use a canonical job name when one fits (`month-end-close`, `quarter-end-close`, `year-end-close`, `bank-recon`, `gst-vat-filing`, `payment-run`, `credit-control`, `supplier-recon`, `audit-prep`, `fa-review`, `document-collection`, `statutory-filing`), else short kebab-case.
- **Style**: tight, factual, plain punctuation; one line per field; never repeat content across fields; a call without a `why` is half a record. `duplicateCount > 0` on the ack = already recorded; do not re-jot; search first on repeated workflows.

### POST /api/v1/jots/search

Plain-value filter (no expression envelopes): `ref` (TYPE:resourceId containment), `kind`, `tier`, `dispositionVerb`, `freetext` (case-insensitive substring over call and why), `createdFrom` / `createdTo` (epoch ms, inclusive). Sort is pinned server-side (CRITICAL and withheld-write entries first, then newest); no client sort. `includeStats: true` adds per-credential disposition counts alongside the page.

```json
// Request
{ "limit": 100, "offset": 0, "filter": { "kind": "CLASSIFICATION" }, "includeStats": true }
```

### POST /api/v1/jots/:resourceId/disposition

Append one review verb to a jot (append-only: annotates, never mutates).

```json
// Request
{ "verb": "FLAG", "note": "Re-check the account mapping next close" }
```

`verb`: FLAG, REJECT, ENDORSE. REJECT with `rollback:true` queues an async rollback in the platform; the per-ref outcomes land on the disposition's `rollbackOutcome` and are readable via jot search (recall).

---

## 26. Bank Rules CRUD

A bank rule has two halves: the **WHEN** (`searchFilter`, the condition deciding which statement lines it applies to) and the **THEN** (`configuration.reconcileWithDirectCashEntry`, the allocation). **A rule with no `searchFilter` is never suggested**: auto-reconciliation only considers rules whose stored condition is non-null, so it exists, lists, and can be applied by hand, but is never offered. See jaz-api rules 90a-90d for the full payload.

### GET /api/v1/bank-rules

Lists bank rules. Standard pagination (`limit`, `offset`).

### GET /api/v1/bank-rules/:resourceId

Returns one rule, including its `searchFilter`.

```json
// Response (data):
{
  "resourceId": "uuid",
  "name": "Grab rides",
  "actionType": "RECONCILE_WITH_DIRECT_CASH_ENTRY",
  "appliesToReconciliationAccount": { "code": "1001", "currencyCode": "SGD", "name": "Business Bank Account" },
  "searchFilter": {
    "version": 1,
    "raw": "description:grab",
    "parsed": { "description": { "contains": "grab" } }
  },
  "configuration": { "reconcileWithDirectCashEntry": { "...": "..." } }
}
```

`appliesToReconciliationAccount` comes back as an OBJECT on read but is sent as a bare UUID on write, so a rule cannot be round-tripped without substituting it.

### POST /api/v1/bank-rules

Creates a rule. `searchFilter` is optional on the wire and effectively required in practice.

```json
// Request:
{
  "name": "Grab rides",
  "appliesToReconciliationAccount": "<bank-account-uuid>",
  "searchFilter": {
    "version": 1,
    "raw": "description:grab",
    "parsed": { "description": { "contains": "grab" } }
  },
  "configuration": {
    "reconcileWithDirectCashEntry": {
      "amountAllocationType": "PERCENTAGE",
      "reference": "AUTO-{{bankReference}}",
      "percentageAllocation": [{ "organizationAccountResourceId": "<acct-uuid>", "amount": 100 }]
    }
  }
}
```

`searchFilter` rules: `version` must be `1`; `raw` must be PRESENT but **may be the empty string** (the app itself saves conditions that way); `parsed` must be a non-empty object. Condition fields are `description`, `extReference`, `extContactName`, `netAmount`, `valueDate`, plus the account's active custom Bank Fields. An unsupported field path does NOT error; the rule saves and then never fires.

### PUT /api/v1/bank-rules/:resourceId

Full replacement: send `resourceId`, `appliesToReconciliationAccount` and the whole `configuration` every time.

**`searchFilter` is the one exception to full replacement.** Omit the key and the stored condition is KEPT; send `null` to remove it; send an object to replace it. This matters because the obvious read-modify-write (GET, change the name, PUT) drops the condition unless you carry it across, which silently stops the rule ever being suggested again.

### DELETE /api/v1/bank-rules/:resourceId

Deletes a rule.

### POST /api/v1/bank-rules/search

Searches rules. Filter fields: `name`, `resourceId`, `actionType`, `appliesToReconciliationAccount`, `businessTransactionType`, `reference`, plus `and`/`or`.

```json
{ "filter": { "name": { "contains": "grab" } }, "sort": { "sortBy": ["name"], "order": "ASC" }, "limit": 100 }
```

**There is no filter on `searchFilter`**, so "which of my rules have no condition" cannot be asked server-side; list the rules and check the field client-side.


## 27. Unapplied Payments

Cash received (or paid) before anyone says what it pays for. Stored as a payment batch, so there is **no GET**: read it back through `POST /api/v1/batch-payments/search` with `"origin": ["UNAPPLIED"]` (a sibling of `filter`; the search defaults to `ALL`, which mixes ordinary batches in). Each row carries `hash`, `unappliedStatus` (`DRAFT` / `UNAPPLIED` / `APPLIED`), `consumedAmount` and `unappliedBalance`.

Rules that hold on every route below:
- **Every write after the create takes `hash`** (optimistic lock), from the search row or the previous write's response. A stale hash is refused (422 `INVALID_CHECK_SUM`). Writes that return the record return its new hash; delete and unbatch return only `{ resourceId }`.
- **`totalAmount` is the cash that arrived; attributions consume it** (`unappliedBalance = totalAmount - consumedAmount`). On an ordinary batch the total is the sum of its records instead.
- **Using up the balance converts the record into an ordinary batch payment, one-way** (attributions or attach). The response cannot say so (it has no `origin`): detect it from `unappliedBalance` reaching 0. Its `origin` becomes `BATCH`, so the `UNAPPLIED` search no longer returns it: a record missing from that search may have become a batch payment, so check the batch-payment search with `origin` `BATCH` or `ALL` before concluding it is gone.
- `paymentMethod` is the same 11-value enum as payments; lowercase or unknown values are 422.
- Not idempotent. Clio sends every one of these once and reports a lost answer as UNCONFIRMED with the read-back, never as success.

### POST /api/v1/unapplied-payments

```json
{ "businessTransactionType": "SALE", "organizationAccountResourceId": "<bank-uuid>", "paymentMethod": "BANK_TRANSFER",
  "amount": 500, "valueDate": "2026-09-23", "currencyCode": "SGD", "contactResourceId": "<contact-uuid>",
  "reference": "DEP-1", "saveAsDraft": true }
```

Required: `businessTransactionType` (`SALE` received / `PURCHASE` paid), `organizationAccountResourceId`, `paymentMethod`, `amount` (> 0), `valueDate`, `currencyCode`. `rateToFunctional` is required for a foreign currency. `contactResourceId` is optional. `saveAsDraft: true` posts nothing until activate. Optional: `functionalCurrencyCode`, `externalReference`, `notes`, `customFields`, `attachments`.

### PUT /api/v1/unapplied-payments/{resourceId}

`hash` plus any of `reference`, `externalReference`, `notes`, `paymentMethod`, `amount`, `organizationAccountResourceId`, `valueDate`, `rateToFunctional`, `customFields`, `attachments`, `children`. Changing `amount`, the account or the date releases an existing bank match. There is no `currencyCode`: the currency follows the account, and a move to an account in another currency needs `amount` restated in the new currency (or it is refused `CONTAINER_CURRENCY_CHANGE_INCOMPLETE`) and `rateToFunctional` when that currency is not the organization's own. The agent tool does not expose `children`; send them with `clio unapplied-payments update <id> --hash <hash> --input body.json`. `children` is a replacement list where a row LEFT OUT is KEPT: send `{ "resourceId": "<payment>", "deleted": true }` to remove one.

### DELETE /api/v1/unapplied-payments/{resourceId}

Body `{ "hash": "..." }` (a DELETE with a body). Deletes the record AND every payment record under it, so each document it settled is unpaid again, and reverses its unapplied remainder. **A reconciled record is not refused: its bank match is released and the delete proceeds**, leaving the bank record unreconciled. When `bankStatementEntryResourceId` is set, warn the user and confirm before deleting. Returns `{ resourceId }`.

### POST /api/v1/unapplied-payments/{resourceId}/activate

Body `{ "hash": "..." }`. Draft to active; the ledger entry posts here.

### POST /api/v1/unapplied-payments/{resourceId}/attributions

```json
{ "hash": "...", "attributions": [ { "businessTransactionResourceId": "<invoice-uuid>", "businessTransactionType": "SALE", "amount": 200 } ] }
```

1-500 entries; `businessTransactionType` is `SALE`, `PURCHASE`, `SALE_CREDIT_NOTE` or `PURCHASE_CREDIT_NOTE`. Each creates a payment record on that document. Optional per entry: `transactionAmount` (document currency), `transactionFee`, `transactionFeeCollected`. **The fee shape differs from a payment record's fee**: `{ feeValue, feeType: "AMOUNT" | "PERCENTAGE", feeOrganizationAccountResourceId, taxVatProfileResourceId?, feeTaxVatApplicable?, feeDescription? }` (not `FLAT`, not `feeAccountResourceId`).

### POST /api/v1/unapplied-payments/{resourceId}/payments

Body `{ "hash": "...", "paymentResourceId": "<payment-uuid>" }`. Groups an EXISTING standalone payment under this record: the payment keeps its document, the balance falls, the total stays.

### DELETE /api/v1/unapplied-payments/{resourceId}/children/{childResourceId}

Body `{ "hash": "..." }`. Removes one child; its cash returns to the balance. The child's `childOrigin` decides the rest: `ATTRIBUTED` (created here) is deleted, `ATTACHED` (existed before) survives as a standalone payment.

### POST /api/v1/unapplied-payments/{resourceId}/unbatch

Body `{ "hash": "..." }`. Deletes the record but RELEASES its payment records as standalone payments, each keeping its document, and reverses its unapplied remainder. Refused while reconciled: unmatch first. Returns `{ resourceId }`.

### POST /api/v1/reconciliations/unapplied-payment

```json
{ "bankStatementEntryResourceId": "<bse-uuid>", "unappliedPaymentDetails": { "paymentMethod": "BANK_TRANSFER", "contactResourceId": "<contact-uuid>" } }
```

Records an unapplied payment for the bank record and matches the two. Direction, account, currency, date and amount come from the bank record. `amount` is optional and, when sent, must EQUAL the bank record's amount (`TOTAL_RECONCILIATION_AMOUNT_MISMATCHED_WITH_STATEMENT_ENTRY_AMOUNT` otherwise); there is no partial match. `rateToFunctional` is required for a foreign-currency account. `amount` belongs INSIDE `unappliedPaymentDetails`: at the top level it is silently dropped. Returns `{ bankStatementEntryResourceId, status, reference, valueDate, unappliedPaymentResourceId }`.

## 28. Report Templates

The saved layout of a report, one default per report type, plus report packs. The layout fields, `edits`, and every measured quirk are in `references/report-templates.md`; this section is the wire.

Rules that hold on every route below:
- `templateConfiguration` is a **JSON-encoded string** in every request and response. Profit-and-loss and balance-sheet layouts come back with a server-owned `coaSnapshot` (a copy of the chart of accounts, rebuilt on every save; omit it when sending).
- Writes return only `{ "data": { "resourceId": "..." } }`. Read the template back to see what was stored.
- Names are unique per report type, ignoring case and treating space and `_` alike (422 `DUPLICATE_REPORT_TEMPLATE_EXISTS`).

### GET /api/v1/organization-report-template

Every template of the organization in one response, **under `reportTemplates`, not `data`**, and ignoring `limit`/`offset`. Rows: `resourceId`, `templateName`, `reportType`, `reportCategory`, `isDefault`, `templateConfiguration`, and on packs `reportPackType` and `packTemplates` (`[{ templateResourceId, templateName, templateOrder, reportType }]`).

### GET /api/v1/organization-report-template/{resourceId}

`{ "data": { ...one row... } }`. 404 `Report template not found` for an unknown id. A template read in the same second it was created can 404 once.

### POST /api/v1/organization-report-template/search

`{ filter, sort }`, answers a bare array. Honours `isDefault`, `reportCategory`, `resourceId`, `reportType` (plain string), `reportTypes` (plain array) and `templateName` (eq, contains); refuses `and` / `or` (400) and sorting by `templateName` (422). Measured 2026-10-01. Clio filters the full list instead, which supports all of them.

### GET /api/v1/organization-report-template/default-configuration

`?reportType=PROFIT_AND_LOSS&framework=IFRS_18` returns `{ "data": { "templateConfiguration": "..." } }`, the layout a new template starts from, in the organization's own currency. The 12 single-report types only (not `REPORT_PACK`). `framework` is accepted for `PROFIT_AND_LOSS` and `CASHFLOW` only, and **only `IAS_1` and `IFRS_18` have a layout**: the other three answer 422 `NO_TEMPLATE_FOUND`. Takes 0.1-5 s (it builds a report preview).

### POST /api/v1/organization-report-template

```json
{ "templateName": "Board P&L", "reportType": "PROFIT_AND_LOSS", "framework": "IAS_1", "templateConfiguration": "{...}" }
```

- **Required:** `templateName` (max 255) and `reportType`.
- **Omit `templateConfiguration`** to start from the default layout. Any well-formed JSON is accepted as a layout, and an unusable one renders as the default.
- **`framework`** is for P&L, balance sheet, cashflow and equity movement only; it defaults to `IAS_1` and cannot change later.
- **`REPORT_PACK` requires both** `templateConfiguration` and `packTemplates: [{ templateResourceId, templateOrder }]`. They are refused on any other type.
  - Keep `packTemplates` and the layout's `TEMPLATE` components in step, or the missing report renders as an error page.
  - Refusals: `TEMPLATE_NOT_FOUND` for an unknown member, `DUPLICATE_TEMPLATE_ORDER` for a repeated position.
- **Not made the default**, except that the first template of a type (in practice, the first pack) becomes its default.
- Returns 201.

### PUT /api/v1/organization-report-template/{resourceId}

Any of `templateName`, `templateConfiguration`, `packTemplates`.
- `templateConfiguration` and `packTemplates` each **replace** the stored value in full.
- Report type and framework cannot change; other fields are ignored without an error.
- `packTemplates` on a non-pack is 422.

### POST /api/v1/organization-report-template/{resourceId}/set-default

No body. Makes the template its report type's default and clears the previous one. **Every export and dashboard view of that report then uses it.** 404 for an unknown id.

### DELETE /api/v1/organization-report-template/{resourceId}

Removes the template from every pack that includes it.
- **Refused:** the default of a report type (422 `CANNOT_DELETE_REPORT_TEMPLATE`, "Set another template as the default first"), and the only template of a type.
- **Not refused:** a pack's last report whenever the pack has any other component, which leaves the pack with no reports. Clio refuses that one itself.
- Packs can always be deleted. Deleting the default pack hands the default to the newest remaining pack.

### Rendering with a template

`POST /api/v1/data-exports/{type}` takes `templateResourceId` (or `templateName`) for these types, each rendering one report type:

| Export type | Report type |
|---|---|
| `profit-and-loss` | `PROFIT_AND_LOSS` |
| `balance-sheet` | `BALANCE_SHEET` |
| `cashflow` | `CASHFLOW` |
| `equity-movement` | `EQUITY_MOVEMENT` |
| `trial-balance` | `TRIAL_BALANCE` |
| `general-ledger` | `GENERAL_LEDGER` |
| `tax-ledger` | `VAT_LEDGER` |
| `cash-balance` | `CASH_BALANCE` |
| `ar-report` | `AGED_RECEIVABLES_SUMMARY` |
| `ar-details-report` | `AGED_RECEIVABLES_DETAILS` |
| `ap-report` | `AGED_PAYABLES_SUMMARY` |
| `ap-details-report` | `AGED_PAYABLES_DETAILS` |

- **A template that is unknown, or of another report type, answers HTTP 200 with an EMPTY body.** It is not an error status, and there is no file.
- Omit the template to render the organization's default.
- The `generate-reports/templated-*` endpoints take `reportTemplateResourceId` and return the report as JSON; a template of the wrong type answers 404 `template_not_found`.

---

*Last updated: 2026-09-24 (added Report Templates, section 28). Previous: 2026-09-23 (added Unapplied Payments, section 27). Previous: 2026-09-13 (added Bank Rules CRUD, section 26). Previous: 2026-07-11 (added Jots judgment journal, section 25). Previous: 2026-04-09, added Contacts bulk-upsert (22), Background Jobs search (23), Export Records (24). 2026-03-13: Payment record CRUD, nano-classifier, scheduler GET/PUT/DELETE.*
