# Bills (Accounts Payable)

Create vendors, record bills, approve them, pay them, and pull an AP aging
report. A bill that isn't a draft posts a balanced journal entry the moment it
is created (Dr expense/asset, Cr Accounts Payable), so the AP 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).

AP is the mirror image of AR. If you have read
[/docs/invoices.md](/docs/invoices.md), the shapes here will be familiar — but
**bills have one extra gate that invoices do not: they must be approved before
they can be paid.**

---

## The object model

### Vendor

A vendor is a **party** with `party_type: "supplier"`. Same table as customers;
`"both"` means the party is on both sides. Full field list is in
[/docs/invoices.md](/docs/invoices.md#customer).

Note the API asymmetry: the endpoint is `/vendors`, but the value stored and
filtered on is `supplier`.

### Bill

| Field | Type | Notes |
|---|---|---|
| `id` | integer | |
| `vendor_id` | integer | FK to party |
| `number` | string | Empty on create → auto-numbered (`BILL-0001`, …). Unique per **(tenant, vendor, issue_date, number)** — the same vendor can reuse `INV-001` in a different year. |
| `issue_date` / `due_date` | `YYYY-MM-DD` | |
| `status` | enum | see below |
| `currency` | string | Default `"USD"` |
| `subtotal` / `tax_total` / `total` / `amount_paid` | decimal string | e.g. `"90.00"` |
| `journal_entry_id` | integer \| null | The GL entry this bill posted |
| `memo` | string \| null | |
| `created_by` | integer \| null | User id — used for segregation-of-duties checks |
| `is_approved` / `approved_by` / `approved_at` | bool / string / timestamp | **Payment gate** |
| `receipt_file` | string \| null | Linked source document |
| `version` | integer | Optimistic lock. `PATCH` requires it. |

### Bill status enum

The same `DocStatus` enum invoices use:

| 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` | Present in the enum; the write-off path is AR-only in practice |

### Bill line (`DocLine`)

Same schema as invoice lines. On a bill, set **`expense_account_id`** (the
account the line debits); `revenue_account_id` is the AR-side field and is
ignored here.

| Field | Type | Notes |
|---|---|---|
| `description` | string | **Required** |
| `unit_price` | number or numeric string | **Required** |
| `quantity` | number or numeric string | Default `1` |
| `expense_account_id` | integer \| null | Expense or asset account this line debits |
| `tax_rate_id` | integer \| null | Input tax is frozen at creation, so editing the rate later never rewrites history |

---

## Vendors

### `POST /vendors`

```bash
curl -X POST https://api.accountingorbit.com/api/v1/vendors \
  -H "Authorization: Bearer $ORBIT_KEY" -H "Content-Type: application/json" \
  -d '{"name":"Office Depot","email":"ar@officedepot.test","terms_days":30}'
```

```json
{
  "id": 53,
  "tenant_id": 457,
  "party_type": "supplier",
  "name": "Office Depot",
  "email": "ar@officedepot.test",
  "default_currency": "USD",
  "credit_period_days": 30,
  "is_active": true,
  "is_contractor": false,
  "w9_received": false,
  "w9_requested_at": null,
  "ytd_1099_amount": "0.00",
  "tax_id": null,
  "payable_account_id": null,
  "receivable_account_id": null,
  "created_at": "2026-08-11T00:23:07.181781",
  "updated_at": "2026-08-11T00:23:07.181791"
}
```

Only `name`, `email` and `terms_days` are accepted here. To set `tax_id`,
`is_contractor` or an address, create with `POST /parties`
(`{"party_type": "supplier", ...}`) or update with `PUT /parties/{id}`.

### `GET /vendors`

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

```json
{"items": [ { "id": 53, "name": "Office Depot", "...": "..." } ],
 "total": 1, "offset": 0, "limit": 200, "has_more": false}
```

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

---

## Bills

### `POST /bills`

```bash
curl -X POST https://api.accountingorbit.com/api/v1/bills \
  -H "Authorization: Bearer $ORBIT_KEY" -H "Content-Type: application/json" \
  -d '{
        "vendor_id": 53,
        "issue_date": "2026-08-10",
        "due_date": "2026-09-09",
        "currency": "USD",
        "memo": "Office supplies",
        "lines": [
          {"description": "Printer paper", "quantity": "2",
           "unit_price": "45.00", "expense_account_id": 23297}
        ]
      }'
```

```json
{
  "id": 19,
  "tenant_id": 457,
  "vendor_id": 53,
  "number": "BILL-0001",
  "issue_date": "2026-08-10",
  "due_date": "2026-09-09",
  "status": "open",
  "currency": "USD",
  "subtotal": "90.00",
  "tax_total": "0.00",
  "total": "90.00",
  "amount_paid": "0.00",
  "journal_entry_id": 576,
  "memo": "Office supplies",
  "created_by": 454,
  "is_approved": false,
  "approved_by": null,
  "approved_at": null,
  "receipt_file": null,
  "version": 1,
  "created_at": "2026-08-11T00:23:07.215991"
}
```

Required: `vendor_id`, `issue_date`, `lines`. Omit `number` to auto-number.
`"draft": true` creates a `draft` bill that posts nothing; post it later with
`POST /bills/{id}/post`.

Creation is idempotent on the bill number — the GL entry carries the key
`create-bill-{number}`.

### `GET /bills`

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

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

Newest first. Default `limit` is 100 (invoices default to 200). There is no
per-bill `GET /bills/{id}`.

### `POST /bills/{id}/approve` — required before payment

```bash
curl -X POST https://api.accountingorbit.com/api/v1/bills/19/approve \
  -H "Authorization: Bearer $ORBIT_KEY"
```

```json
{
  "id": 19, "number": "BILL-0001", "status": "open",
  "is_approved": true, "approved_by": "454",
  "approved_at": "2026-08-11T00:23:15.019973",
  "total": "90.00", "amount_paid": "0.00", "version": 1,
  "...": "..."
}
```

If your tenant has a duty policy configured for `approve_entry`, the approver
must not be the bill's `created_by` user, and the call is refused.

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

Only `issue_date`, `due_date`, `memo`; only while `draft` or `open`.
`expected_version` is **required** — same optimistic-lock rules as invoices.

```bash
curl -X PATCH https://api.accountingorbit.com/api/v1/bills/19 \
  -H "Authorization: Bearer $ORBIT_KEY" -H "Content-Type: application/json" \
  -d '{"due_date":"2026-09-15","expected_version":1}'
```

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

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

`on` is the reversing entry's date, defaulting to the original entry date
(rejected if that period is closed). Void posts a reversal; nothing is deleted.
The void is written to the audit log **before** the operation, so an attempt is
recorded even when the reversal itself fails.

---

## Paying bills

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

Approve first. Paying an unapproved bill:

```json
{"detail": {"message": "bill BILL-0001 is not approved — approve before payment",
            "code": "UNKNOWN"}}
```
→ `422`. Note `detail` is an **object** here, not a string.

Approved:

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

```json
{
  "id": 20,
  "tenant_id": 457,
  "direction": "made",
  "party_kind": "vendor",
  "party_id": 53,
  "invoice_id": null,
  "bill_id": 19,
  "payment_date": "2026-08-10",
  "amount": "90.00",
  "cash_account_id": 23267,
  "journal_entry_id": 577,
  "idempotency_key": "pay-bill-19-attempt-1",
  "version": 1,
  "created_at": "2026-08-11T00:23:15.036004"
}
```

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

**Always send `idempotency_key`.** Replaying the exact same call returns the
identical payment — same `id: 20`, same `journal_entry_id: 577` — and posts
nothing new. Omit the key and a retry creates a second payment and a second GL
entry.

Paying settles the bill: `amount_paid` rises, `status` moves
`open → partial → paid`.

If a duty policy is configured for `pay_bill`, the payer must not be the bill's
creator.

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

Same endpoint as AR. Reverses the payment's journal entry and returns the bill
to `partial`/`open`.

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

### Payment runs

For batched vendor payments there is a separate small surface:
`GET/POST /payment-runs`, `POST /payment-runs/{id}/execute`. It is not part of
the minimal AP path documented here.

---

## Aging

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

`as_of` is **required**.

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

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

Buckets are keyed by days overdue: `current`, `1_30`, `31_60`, `61_90`,
`over_90`. Identical shape to `GET /ar-aging`.

---

## 1099 tracking

Orbit tracks contractor payments for 1099 prep. Read this section carefully —
**the 1099 total is not driven by bills.**

### The fields, on the party

| Field | Type | Meaning |
|---|---|---|
| `is_contractor` | boolean | Marks the party as a 1099 contractor |
| `ytd_1099_amount` | decimal string | Year-to-date reportable amount |
| `w9_received` | boolean | W-9 on file |
| `w9_requested_at` | timestamp \| null | When the W-9 was requested |
| `tax_id` | string \| null | TIN, if you store one |
| `default_rate` | decimal \| null | Contractor's hourly rate |

Set these with `PUT /parties/{id}` (`is_contractor`, `tax_id`) — `POST /vendors`
does not accept them.

### What actually increments `ytd_1099_amount`

Approving a **contractor submission**, not paying a bill:

```
POST /contractor/submissions                 → record hours at a rate
POST /contractor/submissions/{id}/approve    → status "approved";
                                               if the party is_contractor,
                                               ytd_1099_amount += hours × rate
GET  /contractor/submissions?contractor_id&status&offset&limit
```

Paying a bill to a vendor flagged `is_contractor` does **not** move
`ytd_1099_amount`. If you pay contractors through bills, you must maintain the
YTD figure yourself via the party record.

### `GET /contractor/1099-prep?year=YYYY`

Lists contractors with `ytd_1099_amount >= 600`. `year` defaults to the
tenant's current year.

```json
{
  "year": 2026,
  "generated_at": "2026-08-11 00:23:20.114820+00:00",
  "threshold": "600.00",
  "contractors": [
    {"id": 61, "name": "Jane Contractor", "ytd_amount": "4200.00",
     "w9_received": true, "w9_requested_at": "2026-01-15 09:00:00"}
  ],
  "total_contractors": 1,
  "december_checklist": [
    "Contractor list reviewed for accuracy",
    "All W-9s collected",
    "YTD amounts confirmed against ledger",
    "Prep Report reviewed — ready for partner CSV export"
  ]
}
```

**This is a prep report, not a filing.** It contains no TINs and there is no
e-file endpoint. Orbit does not transmit 1099s.

**It is also plan-gated.** On a plan without the feature, every
`/contractor/*` route returns:

```json
{"detail": {"message": "This feature is not enabled on your plan.",
            "code": "FEATURE_LOCKED", "flag": "time_tracking_enabled"}}
```
→ `403`. Handle this before building on the endpoint.

---

## Failure cases specific to AP

| Status | Body | Cause |
|---|---|---|
| 400 | `{"detail": "cannot edit paid bill — void and recreate"}` | `PATCH` on a bill that is not `draft`/`open` |
| 400 | `{"detail": "payment has no linked journal entry"}` | Reversing a payment that never posted |
| 403 | `{"detail": {"message": "This feature is not enabled on your plan.", "code": "FEATURE_LOCKED", "flag": "time_tracking_enabled"}}` | Contractor / 1099 routes on an ineligible plan |
| 403 | `{"detail": "contractor does not belong to this tenant"}` | Approving a submission against another tenant's party |
| 404 | `{"detail": "bill not found"}` | No such bill **or it belongs to another tenant** |
| 404 | `{"detail": "submission not found"}` | |
| 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": {"message": "bill BILL-0001 is not approved — approve before payment", "code": "UNKNOWN"}}` | Pay before approve |
| 422 | `{"detail": {"message": "...", "code": "<LEDGER_CODE>"}}` | Ledger refused the posting — closed period, inactive account, unbalanced, duplicate `(vendor, date, number)` |
| 422 | list of field errors | Body failed schema validation — see [/docs/conventions.md](/docs/conventions.md) |

---

## Typical integration sequence

1. `POST /vendors` with `{"name", "email"}`. Keep `id`.
   (Skip if it exists — find it with `GET /vendors`.)
   If this vendor is a 1099 contractor, follow up with
   `PUT /parties/{id}` `{"is_contractor": true, "tax_id": "..."}`.
2. `POST /bills` with `vendor_id`, `issue_date`, and at least one line carrying
   `description`, `unit_price` and `expense_account_id`. Keep `id`, `number`,
   `total`, `version`. Status is `open`; the GL entry in `journal_entry_id` is
   already posted.
3. `POST /bills/{id}/approve`. **This is not optional** — payment refuses
   without it.
4. `POST /bills/{id}/pay` with `amount`, `payment_date`, and an
   `idempotency_key` you generate and can replay. Re-check `status`:
   `partial` means more is owed.
5. Mistake? `POST /bills/{id}/void` (never owed) or
   `POST /payments/{id}/reverse` (payment bounced) — never delete.
6. Reconcile: `GET /ap-aging?as_of=today` for what you owe, and
   `GET /statements/trial-balance?as_of=today` to confirm AP ties to the GL —
   see [/docs/reports.md](/docs/reports.md).

Accounts receivable is the mirror image: [/docs/invoices.md](/docs/invoices.md).
To create bills from uploaded vendor documents instead of by hand, see
[/docs/receipts.md](/docs/receipts.md).
