# Invoices (Accounts Receivable)

Create customers, issue invoices, email them, record payments, refund them, and
pull an aging report. Every invoice that isn't a draft posts a balanced journal
entry the moment it is created — the AR subledger and the general ledger never
drift apart.

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

---

## The object model

### Customer

A customer is a **party** with `party_type: "customer"`. The same table backs
vendors ([/docs/bills.md](/docs/bills.md)) with `party_type: "supplier"`;
`"both"` means the party is on both sides.

| Field | Type | Notes |
|---|---|---|
| `id` | integer | |
| `name` | string | Required |
| `party_type` | string | `customer` \| `supplier` \| `both` |
| `email` | string \| null | Used as the default recipient for `/invoices/{id}/send` |
| `phone`, `address_line1`, `address_line2`, `city`, `state`, `country`, `postal_code` | string \| null | |
| `tax_id` | string \| null | |
| `default_currency` | string | Default `"USD"` |
| `credit_limit` | string \| null | |
| `credit_period_days` | integer | Default `30` |
| `is_active` | boolean | |
| `receivable_account_id` / `payable_account_id` | integer \| null | Per-party control account override |
| `is_contractor`, `w9_received`, `w9_requested_at`, `ytd_1099_amount` | — | 1099 fields — see [/docs/bills.md](/docs/bills.md) |

### Invoice

| Field | Type | Notes |
|---|---|---|
| `id` | integer | |
| `customer_id` | integer | FK to party |
| `number` | string | Unique per tenant. Empty on create → auto-numbered (`SINV-0001`, …) |
| `issue_date` / `due_date` | `YYYY-MM-DD` | `due_date` defaults from the customer's terms |
| `status` | enum | see below |
| `currency` | string | Default `"USD"` |
| `subtotal` / `tax_total` / `total` / `amount_paid` | decimal string | e.g. `"150.00"` |
| `journal_entry_id` | integer \| null | The GL entry this invoice posted |
| `memo` | string \| null | |
| `created_by` | integer \| null | User id — used for segregation-of-duties checks |
| `sent_at` / `sent_to` / `viewed_at` | timestamp / string / timestamp | Set by `/send` and the tracking pixel |
| `is_approved` / `approved_by` / `approved_at` | bool / string / timestamp | |
| `is_estimate` / `estimate_id` | bool / integer | Dormant estimate fields |
| `receipt_file` | string \| null | Linked source document |
| `version` | integer | Optimistic lock. `PATCH` requires it. |

### Invoice status enum

The real values, from the `DocStatus` enum shared by invoices, bills and credit
memos:

| Value | Meaning |
|---|---|
| `draft` | Created with `"draft": true` — nothing posted to the GL yet |
| `open` | Posted, awaiting payment |
| `partial` | Partially paid |
| `paid` | Fully settled |
| `void` | Reversed |
| `written_off` | Bad debt; remainder forgiven (set by a batch payment carrying `write_off`) |

### Invoice line (`DocLine`)

| Field | Type | Notes |
|---|---|---|
| `description` | string | **Required** |
| `unit_price` | number or numeric string | **Required** |
| `quantity` | number or numeric string | Default `1` |
| `revenue_account_id` | integer \| null | The account this line credits |
| `expense_account_id` | integer \| null | Ignored on invoices — bills use it |
| `tax_rate_id` | integer \| null | Tax charged is frozen at creation, so editing the rate later never rewrites history |

---

## Customers

### `POST /customers`

```bash
curl -X POST https://api.accountingorbit.com/api/v1/customers \
  -H "Authorization: Bearer $ORBIT_KEY" -H "Content-Type: application/json" \
  -d '{"name":"Acme Corp","email":"ap@acme.test","terms_days":30}'
```

```json
{
  "id": 52,
  "tenant_id": 457,
  "party_type": "customer",
  "name": "Acme Corp",
  "email": "ap@acme.test",
  "display_name": null,
  "phone": null,
  "address_line1": null, "address_line2": null,
  "city": null, "state": null, "country": null, "postal_code": null,
  "tax_id": null,
  "default_currency": "USD",
  "credit_limit": null,
  "credit_period_days": 30,
  "is_active": true,
  "is_client": false,
  "is_contractor": false,
  "w9_received": false,
  "w9_requested_at": null,
  "ytd_1099_amount": "0.00",
  "default_rate": null,
  "receivable_account_id": null,
  "payable_account_id": null,
  "created_at": "2026-08-11T00:22:34.392554",
  "updated_at": "2026-08-11T00:22:34.392567"
}
```

Only `name`, `email` and `terms_days` are accepted on this endpoint. For the
full address / tax-id / credit-limit set, use `POST /parties` with
`{"party_type": "customer", ...}`, or update afterwards with
`PUT /parties/{id}`.

### `GET /customers`

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

```json
{"items": [ { "id": 52, "name": "Acme Corp", "...": "..." } ],
 "total": 1, "offset": 0, "limit": 200, "has_more": false}
```

Returns parties whose `party_type` is `customer` or `both`, ordered by name.

Related party endpoints: `GET /parties/{id}`, `PUT /parties/{id}`,
`DELETE /parties/{id}`, `GET /parties/{id}/duplicates`,
`POST /parties/{id}/merge`, `GET /parties/stats`.

---

## Invoices

### `POST /invoices`

```bash
curl -X POST https://api.accountingorbit.com/api/v1/invoices \
  -H "Authorization: Bearer $ORBIT_KEY" -H "Content-Type: application/json" \
  -d '{
        "customer_id": 52,
        "issue_date": "2026-08-10",
        "due_date": "2026-09-09",
        "currency": "USD",
        "memo": "August services",
        "lines": [
          {"description": "Consulting — August", "quantity": "1",
           "unit_price": "150.00", "revenue_account_id": 23293}
        ]
      }'
```

```json
{
  "id": 36,
  "tenant_id": 457,
  "customer_id": 52,
  "number": "SINV-0001",
  "issue_date": "2026-08-10",
  "due_date": "2026-09-09",
  "status": "open",
  "currency": "USD",
  "subtotal": "150.00",
  "tax_total": "0.00",
  "total": "150.00",
  "amount_paid": "0.00",
  "journal_entry_id": 574,
  "memo": "August services",
  "created_by": 454,
  "sent_at": null, "sent_to": null, "viewed_at": null,
  "is_approved": false, "approved_by": null, "approved_at": null,
  "is_estimate": false, "estimate_id": null,
  "receipt_file": null,
  "version": 1,
  "created_at": "2026-08-11T00:22:50.613004"
}
```

Required: `customer_id`, `issue_date`, `lines` (at least one).
Omit `number` to auto-number. Pass `"draft": true` to create a `draft` invoice
that posts nothing; post it later with `POST /invoices/{id}/post`.

Creation is idempotent on the invoice number — the GL entry carries the key
`create-inv-{number}`, so a retried create with the same explicit number will
not post twice.

### `GET /invoices`

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

```json
{"items": [ { "id": 36, "number": "SINV-0001", "status": "open", "...": "..." } ],
 "total": 1, "offset": 0, "limit": 200, "has_more": false}
```

Newest first. There is no per-invoice `GET /invoices/{id}` — read it from the
list, or from `/reports/export` (below) for a flat CSV row.

### `POST /invoices/{id}/post`

Promotes a `draft` invoice to `open` and posts it to the GL. Returns the
invoice.

### `PATCH /invoices/{id}` — edit dates and memo

Only `issue_date`, `due_date` and `memo` are editable, only while the invoice is
`draft` or `open`, and only before it has been sent.

```bash
curl -X PATCH https://api.accountingorbit.com/api/v1/invoices/36 \
  -H "Authorization: Bearer $ORBIT_KEY" -H "Content-Type: application/json" \
  -d '{"memo":"August services (rev)","expected_version":1}'
```

`expected_version` is **required**. Omit it → `409 "expected_version is required
— include the version from the last GET response"`. Stale value →
`409 "Version conflict: expected v1, current is v2. Refresh and retry."`
Every successful patch bumps `version`.

### `POST /invoices/{id}/approve`

Sets `is_approved`, `approved_by`, `approved_at`. Returns the invoice.
Unlike bills, invoice payment does **not** require approval first.

### `POST /invoices/{id}/send` — email the PDF

```bash
curl -X POST https://api.accountingorbit.com/api/v1/invoices/36/send \
  -H "Authorization: Bearer $ORBIT_KEY" -H "Content-Type: application/json" \
  -d '{"to_email":"ap@acme.test"}'
```

```json
{"status": "sent", "to": "ap@acme.test", "sent_at": "2026-08-11T00:23:01.114820"}
```

`to_email` is optional — it defaults to the customer's `email`. No email
anywhere → `422 "customer has no email — pass to_email"`. Sending a `void`
invoice → `422 "cannot send a void invoice"`.

The email carries the invoice PDF as an attachment and a 1×1 tracking pixel;
the first open sets `viewed_at` on the invoice. Delivery is queued, not
synchronous — a `200` means accepted for sending.

### `GET /invoices/{id}/pdf`

Returns `application/pdf` with
`Content-Disposition: inline; filename="invoice-SINV-0001.pdf"`. Same document
the customer receives.

### `POST /invoices/{id}/void`

```bash
curl -X POST https://api.accountingorbit.com/api/v1/invoices/36/void \
  -H "Authorization: Bearer $ORBIT_KEY" -H "Content-Type: application/json" \
  -d '{"on":"2026-08-31"}'
```

`on` is the date of the **reversing** entry and defaults to the original entry
date. If that period is closed the ledger refuses it — pass an open date. Void
never deletes: the original entry stays and a reversal is posted against it.

---

## Payments

### `POST /invoices/{id}/pay`

```bash
curl -X POST https://api.accountingorbit.com/api/v1/invoices/36/pay \
  -H "Authorization: Bearer $ORBIT_KEY" -H "Content-Type: application/json" \
  -d '{"amount":"150.00","payment_date":"2026-08-10",
       "cash_account_code":"1000","idempotency_key":"pay-inv-36-attempt-1"}'
```

```json
{
  "id": 19,
  "tenant_id": 457,
  "direction": "received",
  "party_kind": "customer",
  "party_id": 52,
  "invoice_id": 36,
  "bill_id": null,
  "payment_date": "2026-08-10",
  "amount": "150.00",
  "cash_account_id": 23267,
  "journal_entry_id": 575,
  "idempotency_key": "pay-inv-36-attempt-1",
  "version": 1,
  "created_at": "2026-08-11T00:22:50.683887"
}
```

Required: `amount`, `payment_date`. `cash_account_code` defaults to `"1000"`.

**Always send `idempotency_key`.** It is optional in the schema and the API
tells you why not to omit it: without one, a retry creates a second payment. A
replayed request with the same key returns the *original* payment object —
same `id`, same `journal_entry_id` — and posts nothing new.

Paying settles the invoice: `amount_paid` rises and `status` moves
`open → partial → paid`.

### `POST /invoices/batch-pay`

```bash
curl -X POST https://api.accountingorbit.com/api/v1/invoices/batch-pay \
  -H "Authorization: Bearer $ORBIT_KEY" -H "Content-Type: application/json" \
  -d '{"payment_date":"2026-08-10","cash_account_code":"1000",
       "lines":[{"invoice_id":36,"amount":"140.00","write_off":"10.00"},
                {"invoice_id":37,"amount":"200.00"}]}'
```

```json
{"results": [{"invoice_id": 36, "status": "paid", "payment_id": 19},
             {"invoice_id": 37, "status": "failed",
              "error": "...", "code": "..."}],
 "total": 2}
```

Per-line, never all-or-nothing: read every `results[]` entry. `status` is
`paid`, `partial` or `failed`.

`write_off` posts a bad-debt entry (Dr `6800` Bad Debt Expense, Cr `1100` AR)
for the smaller of the write-off and the outstanding balance, and reduces the
subledger outstanding so aging matches the GL. When paid + written off covers
the total, the invoice becomes `written_off`. If accounts `6800` or `1100` do
not exist the line comes back with
`status: "partial"` and an `error` saying the write-off was skipped.

### `POST /payments/{payment_id}/reverse`

Reverses a bounced/stopped payment: reverses the journal entry and returns the
invoice (or bill) to `partial`/`open`.

```json
{"payment_id": 19, "reversed": true, "invoice_id": 36, "bill_id": null}
```

A payment with no linked journal entry → `400 "payment has no linked journal entry"`.

### `POST /invoices/{id}/refund`

Issues a mirroring credit memo and pays it out in cash in one step.
Body: `payment_date` (required), `amount` (null → full amount paid), `number`
(blank → auto), `reason`, `cash_account_code` (default `"1000"`).

---

## Credit memos

A credit memo is the mirror of an invoice — it reduces what a customer owes.
It can be *applied* to an open invoice (subledger reclassification, no new GL
entry) or *refunded* in cash.

| Endpoint | Body | Notes |
|---|---|---|
| `POST /credit-memos` | `{customer_id, issue_date, lines[], number?, currency?, memo?, reason?, invoice_id?}` | `number` blank → auto |
| `GET /credit-memos` | `?limit&offset` | Returns a bare array, **not** a paginated envelope |
| `GET /credit-memos/{id}` | — | `404 "credit memo not found"` |
| `POST /credit-memos/{id}/apply` | `{invoice_id, amount?, idempotency_key?}` | `amount` null → apply the max that fits |
| `POST /credit-memos/{id}/unapply` | `{invoice_id, amount}` | Reverses a prior application |
| `POST /credit-memos/{id}/refund` | `{amount, payment_date, cash_account_code?}` | Pays the credit out in cash |
| `POST /credit-memos/{id}/void` | `{on?}` | Reversing entry date |

Credit memos use the same `DocStatus` enum; `open`, `partial`, `paid`
(fully used) and `void` are the values you will see. `amount_applied` tracks
how much of `total` has been consumed; `total - amount_applied` is what remains.

Apply is replay-protected per tenant by `idempotency_key`.

---

## Aging and statements

### `GET /ar-aging?as_of=YYYY-MM-DD`

`as_of` is **required**.

```bash
curl -G https://api.accountingorbit.com/api/v1/ar-aging \
  -H "Authorization: Bearer $ORBIT_KEY" -d as_of=2026-08-10
```

```json
{
  "as_of": "2026-08-10",
  "buckets": {"current": 0.0, "1_30": 0.0, "31_60": 0.0, "61_90": 0.0, "over_90": 0.0},
  "total": 0.0,
  "credits": 0,
  "detail": []
}
```

With something outstanding, `detail[]` carries one row per open document:
`{"number", "due_date", "outstanding", "days_overdue", "bucket"}`.

`GET /collections/aging?as_of=...` is a second, collections-oriented aging view,
and `GET /collections/dunning/{invoice_id}` returns dunning-letter text.

### Customer statements

| Endpoint | Returns |
|---|---|
| `GET /statements/customer/{party_id}?as_of=` | PDF, plus an `X-Aging-Total` response header. `422 "party is not a customer"` if the party is a vendor only. |
| `GET /parties/{party_id}/statement-pdf?as_of=` | The same PDF without the aging header |

---

## Failure cases specific to AR

| Status | Body | Cause |
|---|---|---|
| 400 | `{"detail": "cannot edit paid invoice — void and recreate"}` | `PATCH` on an invoice that is not `draft`/`open` |
| 400 | `{"detail": "cannot edit sent invoice"}` | `PATCH` after `/send` |
| 400 | `{"detail": "payment has no linked journal entry"}` | Reversing a payment that never posted |
| 404 | `{"detail": "invoice not found"}` | No such invoice **or it belongs to another tenant** — the two are indistinguishable by design |
| 404 | `{"detail": "party not found"}` / `{"detail": "credit memo not found"}` | Same |
| 409 | `{"detail": "expected_version is required — include the version from the last GET response"}` | `PATCH` without the lock |
| 409 | `{"detail": "Version conflict: expected v1, current is v2. Refresh and retry."}` | Concurrent edit |
| 422 | `{"detail": "customer has no email — pass to_email"}` | `/send` with no recipient |
| 422 | `{"detail": "cannot send a void invoice"}` | `/send` on a voided invoice |
| 422 | `{"detail": {"message": "...", "code": "<LEDGER_CODE>"}}` | The ledger refused the posting — closed period, inactive account, unbalanced lines, duplicate number. **`detail` is an object here, not a string.** |
| 422 | list of field errors | Request body failed schema validation — see [/docs/conventions.md](/docs/conventions.md) |

Your tenant may also enforce **segregation of duties**: if a duty policy is
configured for `pay_invoice`, the user who created an invoice cannot be the one
who pays it, and the call is refused.

---

## Typical integration sequence

1. `POST /customers` with `{"name", "email"}`. Keep `id`.
   (Skip if the customer already exists — find it with `GET /customers`.)
2. `POST /invoices` with `customer_id`, `issue_date`, and at least one line
   carrying `description`, `unit_price` and `revenue_account_id`.
   Keep `id`, `number`, `total` and `version`. Status is now `open` and the GL
   entry in `journal_entry_id` is already posted.
   Use `"draft": true` if you need a review step; post later with
   `POST /invoices/{id}/post`.
3. Optional: `POST /invoices/{id}/send` to email the PDF. Poll `viewed_at`
   via `GET /invoices` if you care about opens.
4. When money arrives: `POST /invoices/{id}/pay` with `amount`,
   `payment_date`, and an `idempotency_key` you generate and can replay.
   Check `status` on the invoice afterwards — `partial` means more is owed.
5. Mistake? `POST /invoices/{id}/void` (never issued) or
   `POST /invoices/{id}/refund` (money already taken) — never delete.
6. Reconcile: `GET /ar-aging?as_of=today` for what's outstanding, and
   `GET /statements/trial-balance?as_of=today` to confirm AR ties to the GL —
   see [/docs/reports.md](/docs/reports.md).

Accounts payable is the mirror image: [/docs/bills.md](/docs/bills.md).
