---
title: "Update Ar Invoice"
method: PATCH
path: "/companies/{company_id}/ar/invoices/{invoice_id}"
tags: ["ar-invoices"]
---

# Update Ar Invoice

`PATCH /companies/{company_id}/ar/invoices/{invoice_id}`

## Request

- Base URL: `https://app.zeno-works.ch` (the document declares no server; this is the origin it was published from)
- URL: `https://app.zeno-works.ch/companies/{company_id}/ar/invoices/{invoice_id}`
- Auth: none declared

Partial update of a draft — an omitted field is left unchanged.

ZCT-512: this route is declared PATCH but bound the *create* schema and dumped it
whole, so every field the caller omitted was written at its Pydantic default. A
lines-only edit silently cleared the customer; a header-only edit deleted every
line; an omitted ``currency`` re-denominated a foreign-currency draft to CHF; and
an omitted ``doc_type``/``credited_invoice_id`` turned a credit note into an
invoice and cut its link to the document it credits. Nothing in the response
distinguished the fields it had changed from the ones it had wiped.

Omitting ``lines`` now leaves the stored lines alone; sending ``[]`` still deletes
them all. An explicit ``null`` still clears a nullable header field.

## Path parameters

- `company_id` string, required
- `invoice_id` string, required

## Headers

- `authorization` string, nullable

## Cookies

- `access_token` string, nullable

## Request body

- ArInvoiceUpdate: Partial update of a draft — an omitted field is left unchanged (ZCT-512). Standalone rather than ``ArInvoiceCreate`` with everything relaxed, for the reason ``ArIssuerProfileUpdate`` is standalone (ZCT-265): inheriting and re-declaring would reorder ``ArInvoiceCreate``'s properties in the emitted OpenAPI and churn both frontends' generated clients for nothing. Until this class existed it was literally ``class ArInvoiceUpdate(ArInvoiceCreate): pass``, and the route dumped it with **no** ``exclude_unset`` — so Pydantic emitted every unset field at its *default* and the PATCH was a wholesale replace. The reported casualty was ``counterparty_id`` (a lines-only edit silently cleared the customer), but the real list is every field the body did not carry, and three of them are worse than that one: * ``lines`` defaulted to ``[]`` — a header-only PATCH **deleted every line**; * ``currency`` defaulted to ``"CHF"`` — a foreign-currency draft was silently re-denominated, keeping its figures; * ``doc_type`` defaulted to ``invoice`` and ``credited_invoice_id`` to ``None`` — so a partial PATCH turned a **credit note into an invoice** and cut its link to the document it credits. ``lines`` is ``None`` = "leave the stored lines alone"; an explicit ``[]`` still means "delete them all" (``pydantic``'s ``model_fields_set`` separates the two, so no absent-vs-null wire marker is needed) and the app editor's delete path — which sends the array minus the removed row — is unchanged. Everything else mirrors ``ArInvoiceBase`` field for field, optional. ``issue_date`` is optional **here only**: it stays required on ``ArInvoiceCreate``, because a draft must be born with one.
  - `doc_type` 'invoice' | 'credit_note'
  - `counterparty_id` string, nullable
  - `issuer_profile_id` string, nullable
  - `issue_date` string, date, nullable
  - `due_date` string, date, nullable
  - `service_period_start` string, date, nullable
  - `service_period_end` string, date, nullable
  - `currency` string, nullable
  - `language` 'de' | 'en' | 'fr' | 'it', nullable
  - `payment_terms_id` string, nullable
  - `invoice_discount_kind` 'none' | 'percent' | 'amount'
  - `invoice_discount_value` union
    - number
    - string
  - `footer_text` string, nullable
  - `legal_text` string, nullable
  - `notes` string, nullable
  - `credited_invoice_id` string, nullable
  - `lines` ArInvoiceLineIn[], nullable
    - `line_type` 'product' | 'service' | 'text' | 'section' | 'discount'
    - `sort_order` integer, nullable
    - `group_label` string, nullable
    - `product_id` string, nullable
    - `item_code` string, nullable
    - `description` string, nullable
    - `quantity` union
      - number
      - string
    - `unit_price` union
      - number
      - string
    - `unit_label` string, nullable
    - `account_code` string, nullable
    - `vat_code_id` string, nullable
    - `line_discount_kind` 'none' | 'percent' | 'amount'
    - `line_discount_value` union
      - number
      - string
    - `service_period_start` string, date, nullable
    - `service_period_end` string, date, nullable

## Response `200`

Successful Response

- ArInvoiceDetailOut
  - `doc_type` 'invoice' | 'credit_note'
  - `counterparty_id` string, nullable
  - `issuer_profile_id` string, nullable
  - `issue_date` string, date, required
  - `due_date` string, date, nullable
  - `service_period_start` string, date, nullable
  - `service_period_end` string, date, nullable
  - `currency` string
  - `language` string, required
  - `payment_terms_id` string, nullable
  - `invoice_discount_kind` 'none' | 'percent' | 'amount'
  - `invoice_discount_value` string
  - `footer_text` string, nullable
  - `legal_text` string, nullable
  - `notes` string, nullable
  - `credited_invoice_id` string, nullable
  - `id` string, required
  - `status` 'draft' | 'issued' | 'cancelled', required: Editorial lifecycle owned by ``ar_invoice`` (paid/overdue are derived on read from the linked ``gl_open_item``, not stored here).
  - `origin` 'human' | 'system' | 'ai' | 'api' | 'mcp' | 'chat': What KIND of actor posted a journal entry — the attribution flag. Two axes in one vocabulary, because the column answers one question — *can a reader take this as a person's own judgement?* **Authorship** — the value says who composed the booking: * ``ai`` = the AI-accountant composed it (an autonomous post, or a one-click accept where the content is still the accountant's proposal); * ``system`` = a mechanical/background ``SystemPrincipal`` post (migrations, close automation, deterministic seeds). **Channel** — the value says which surface a person's own action arrived through, because "a ``UserPrincipal`` acted" is NOT the same claim as "a person decided". All four are derived from the request edge by :func:`~invoiage_db.repos.gl_posting._resolve_origin`: * ``human`` = an interactive browser session (cookie/JWT auth). The only value that asserts a person was looking at the screen; * ``mcp`` = an API-key request carrying the mcp shell's ``X-Zeno-Surface`` marker — a connected agent, which MAY be relaying a person and may not; * ``api`` = any other API-key request (a script, an integration, an agent calling the REST api directly). Not provably a person either; * ``chat`` = the built-in CFO chat relaying a person's approval in-app. Its *authored* bookings stamp ``ai``; ``chat`` is only for the relay. Widening this vocabulary is a **one-way door on rollback**: the PG type is a native enum converted at load (``pg_enums._pg_enum``), so an api whose Python enum lacks a value raises ``LookupError`` reading any row that carries it. See the ``0112`` migration.
  - `document_no` string, nullable
  - `commercial_document_id` string, nullable
  - `snapshot_document_id` string, nullable
  - `net_total` string, required
  - `vat_total` string, required
  - `gross_total` string, required
  - `payment_status` string, nullable
  - `open_amount` string, nullable
  - `is_overdue` boolean
  - `posting` ItemPostingOut: The ledger projection of one ingested document — the posted/not marker. Present only on the surfaces that project it (the item lists and the item detail read, ZCT-848); everywhere else ``ItemOut.posting`` stays ``None``, which means "not computed here", NOT ``unposted``. Never branch on a missing value. It answers "is it in the journal?", not "may I code it?": a coding row posted without an entry, or an expense-receipt routing row, reads ``unposted`` here while coding the item is still refused as already booked (``gl_autopost.coding_blocker``). ``accounts`` is filled only for :attr:`ItemPostingStatus.posted` and holds **every** operating leg, largest first — a document split across three expense accounts has no single answer, and silently showing the first line would be a lie. It may legitimately be empty (an entry with only control/VAT legs).
    - `status` 'posted' | 'reversed' | 'held' | 'unposted', required: Where an ingested document stands in the general ledger (ZET-98/103/108). Deliberately NOT a boolean: a boolean collapses four states a treuhänder has to tell apart, and "not posted" alone cannot say *why*. - ``posted`` — a coding decision for this document points at a journal entry whose ``status`` is ``posted``. This is the only value that means "it is in the ledger"; :attr:`ItemPostingOut.accounts` says where. - ``reversed`` — the document was booked and that entry was reversed. The ledger no longer carries it, so it is **not** posted (ZET-95), but it is a materially different state from "never touched": it needs re-booking. - ``held`` — the AI accountant produced a coding proposal that did not clear the autonomy policy and is waiting for a human. Nothing is in the ledger. - ``unposted`` — everything else: no coding decision at all, a shadow-mode decision (``assist``: it *would* have posted but did not), or a decision of a kind that books nothing.
    - `journal_entry_id` string, nullable
    - `accounts` ItemPostingAccountOut[]
      - `account_id` string, required
      - `code` string, required
      - `name` string, required
      - `amount` string, required
    - `coding_pending` boolean
    - `filed_with` ItemFiledWithOut: The booking a re-ingested COPY of an invoice was filed with (ZEN-1, ZCT-945). The coder's dunning linkage (``ap_dunning.try_link_ap_document``) recognises a second upload of an invoice that is already booked and files it against that booking's open item instead of booking it twice. It writes no decision, so the copy's own projection is ``unposted`` and the lists used to read "Not posted" — to the reader the AI looked as if it had stopped posting. This names the booking the copy belongs to. Set only where "already booked" is TRUE (:func:`services.item_posting.posting_map`): an advisory, non-reminder, non-collision link whose holder entry is posted, and — for the weaker payment-reference tier — only for a copy dated like the booked invoice. ``gross`` / ``currency`` are the BOOKED document's; ``amount_differs`` says the copy prints another total or currency, so the reader checks the booking rather than trusting the badge (the linker matches amounts within a band, not exactly).
      - `journal_entry_id` string, required
      - `entry_no` string, nullable
      - `entry_date` string, date, required
      - `match_tier` string, required
      - `gross` string, required
      - `currency` string, required
      - `amount_differs` boolean
  - `sent_at` string, date-time, nullable
  - `last_sent_to` string, nullable
  - `issued_at` string, date-time, nullable
  - `created_at` string, date-time, required
  - `updated_at` string, date-time, required
  - `lines` ArInvoiceLineOut[]
    - `line_type` 'product' | 'service' | 'text' | 'section' | 'discount'
    - `sort_order` integer, required
    - `group_label` string, nullable
    - `product_id` string, nullable
    - `item_code` string, nullable
    - `description` string, nullable
    - `quantity` string, nullable
    - `unit_price` string, nullable
    - `unit_label` string, nullable
    - `account_code` string, nullable
    - `vat_code_id` string, nullable
    - `line_discount_kind` 'none' | 'percent' | 'amount'
    - `line_discount_value` string
    - `service_period_start` string, date, nullable
    - `service_period_end` string, date, nullable
    - `id` string, required
    - `net_amount` string, required
    - `vat_amount` string, required
    - `gross_amount` string, required

## Other responses

- `422`: Validation Error

---

[API](https://skmtc.dev/zeno-works/apis/zeno-api.md) · [All operations](https://skmtc.dev/zeno-works/apis/zeno-api/llms.txt) · [OpenAPI document](https://skmtc.dev/zeno-works/apis/zeno-api/revisions/1eb93dde146c?raw)
