# Working with the Data Model — ERP

Use ERP to work with customers, agree what they are buying, record orders, arrange purchasing, raise invoices and keep track of payments. It brings the commercial records together so you can follow a sale from the first conversation to its account balance, then compare the printing job’s actual costs with the price agreed.

**Use IaC for infrastructure and provisioning. Use the API for runtime data.** Customer records, estimates, orders, invoices and payments are runtime data. Provision users, teams, permissions, integrations, business units, cost centres, suppliers, products, tax codes, price brackets, cost categories, cost rates and operation cost mappings through IaC. You can read configured business units and cost centres when working with commercial records.

This guide brings CRM, all commercial operations and costing together across the `commerce` and `costing` topics. Some also belong to production or logistics: creating jobs from an order and checking fulfilment are part of the commercial handover. Shared discovery and help operations are listed at the end so you can find them without confusing them with the steps of a sale.

## Choose where to start

| What you need to do | Start here |
| --- | --- |
| Register a customer, contact or address | [CRM](#crm-working-with-customers-contacts-and-addresses) |
| Record an agreed purchase from a customer | [Orders](#orders-record-what-the-customer-is-buying) |
| Check the configured costs before quoting | [Cost configuration](#read-configured-costs-and-check-quoting-readiness) |
| Record and review costs incurred on a job | [Actual job costs](#record-and-review-actual-job-costs) |
| Prepare a price before the customer commits | [Estimates](#estimates-prepare-and-revise-an-offer) |
| Turn an order into work to produce | [Production handover](#from-a-sale-to-production) |
| Bill a customer and record payment | [Invoices and payments](#invoices-credit-notes-and-payments) |
| Buy materials or services from a supplier | [Purchasing](#suppliers-and-purchase-orders) |
| Maintain products, prices and tax codes | [Products, pricing and tax](#products-pricing-and-tax) |
| Read configured business units and cost centres | [Business structure](#business-units-and-cost-centres) |
| Check whether an order can be completed | [Fulfilment](#order-fulfilment) |
| Record a conversation or operational instruction | [Notes](#notes-on-commercial-records) |

The [ERP API reference](/docs/data-model/erp-reference) lists arguments and return types for every operation, followed by record fields, inputs and enums. The workflow sections below explain when to use them.

## Read and change records safely

A query reads information. A mutation changes it. Send the operation and its variables separately to your environment's GraphQL API using an authenticated account. The permissions for each resource still apply, including when you request related records inside another result.

The examples use illustrative names, dates, amounts and identifiers. Replace identifiers with those returned by your own requests. The examples are recipes, not one script: creating an order directly and converting an estimate are alternative ways to obtain an order.

Check top-level GraphQL `errors` and any `errors { path message }` returned inside the mutation result. Continue only when the expected record is present and the errors are empty. Keep the returned IDs for later requests. A successful HTTP response alone does not confirm that the requested change succeeded.

**Start with `create…` for a new record and `update…` for changes to an existing record.** These are the recommended operations for everyday work: the request makes your intention clear, and an update lets you supply the fields you want to change. Check each input’s treatment of omitted fields, nulls and nested values.

Treat `upsert…` as an advanced option. Use it when you deliberately need its create-or-update behaviour and understand how it identifies records, which fields it requires and whether it replaces child collections. It is not a general shortcut for a partial update or a promise to match an external reference.

Some resources expose only an upsert operation. Where that is the available route, this guide explains how to use it; do not invent a create or update operation that the API does not provide.

Separate mutations are separate requests. Use a combined operation when its behaviour matches the task, such as `createOrderGraph` for an order and its lines or `recordPayment` for a payment and the associated balances. If a response is lost, read the relevant records before retrying a creation or payment; a reference string is not a general promise of retry safety.

Money values belong with their currency. Check line amounts, document totals, discounts and tax together. A required amount in an input must be supplied; do not assume that storing a line recalculates every related document. The amounts below simplify the arithmetic and do not prescribe a tax treatment.

## CRM — working with customers, contacts and addresses

Use the CRM API to keep customer information current as your business works with people: register a new customer, add the person you speak to, record where goods should go and update details when they change.

**Use IaC for infrastructure and provisioning. Use the API for runtime data.** Customer records, contact details and delivery addresses are operational data. The users, teams, permissions and integrations that support this work belong in your IaC configuration.

This chapter follows one customer, Northstar Packaging, from an initial lookup to a customer record with a contact and shipping address. It then shows how to read the information together, make a partial update and browse active customers. A query reads information; a mutation changes it.

### Understand the records

| Record | GraphQL type | What it represents |
| --- | --- | --- |
| Customer | `CustomerState` | A business or individual you work with |
| Contact | `ContactState` | A person associated with a customer |
| Address | `AddressState` | An address used for billing, shipping or both |

Each record has its own identifier. A contact's `customerId` connects the person to a customer. For a customer's address, supply `customerId`; you can also supply `contactId` when it is associated with a particular person.

A contact is a business contact, not a CoCoCo user account. Creating one does not provision login access.

`id` is the identifier assigned by CoCoCo. `externalId` is an optional reference you supply, such as a customer number from another business system. Keep both when integrating systems: use `externalId` to find the corresponding record and the returned `id` to read or update it.

### Before you begin

Send the examples to your environment's GraphQL API using an authenticated account. Requests operate in your authenticated tenant. Supply variables separately from the operation.

| Task | Required permission |
| --- | --- |
| Read a customer by ID or external ID | `customer:read` |
| Browse customers | `customer:list` |
| Create or update a customer | `customer:write` |
| Read a contact by ID or lookup | `contact:read` |
| Browse contacts, including `CustomerState.contacts` | `contact:list` |
| Create or update a contact | `contact:write` |
| Read an address by ID or external ID | `address:read` |
| Browse addresses, including `CustomerState.addresses` | `address:list` |
| Create or update an address | `address:write` |

The examples use illustrative identifiers and responses. Replace them with the values returned by your own calls. Run each step only after the previous step has succeeded; the separate mutations are separate requests, not one transaction.

### Find a customer before creating one

Suppose Northstar Packaging is known as `CRM-1042` in your customer system. Check whether that reference already exists in CoCoCo:

```graphql example=crm.find-customer part=operation
query FindCustomer($externalId: String!) {
  getCustomerBy(externalId: $externalId) {
    id
    externalId
    name
    email
    isActive
  }
}
```

Variables:

```json example=crm.find-customer part=variables
{
  "externalId": "CRM-1042"
}
```

If the customer exists, an illustrative response is:

```json example=crm.find-customer part=illustrative-response
{
  "data": {
    "getCustomerBy": {
      "id": "cus_01h455vb4pex5vsknk084sn02q",
      "externalId": "CRM-1042",
      "name": "Northstar Packaging",
      "email": "orders@northstar.example",
      "isActive": true
    }
  }
}
```

Keep the returned `id` and continue with that customer. A `null` result with no GraphQL errors means no customer matched the external ID in this tenant. Omitting the selector also returns `null`, so always supply the reference you intend to find.

If you do not have an external ID, use `listCustomers` with a name or email filter, then inspect the matches. A name search can return several records; do not treat the first match as an exact identity lookup.

### Register a new customer

Use this step when the lookup found no existing customer:

```graphql example=crm.register-customer part=operation
mutation RegisterCustomer($input: CreateCustomerInput!) {
  createCustomer(input: $input) {
    customer { id externalId name email isActive }
    errors { path message }
  }
}
```

Variables:

```json example=crm.register-customer part=variables
{
  "input": {
    "name": "Northstar Packaging",
    "externalId": "CRM-1042",
    "email": "orders@northstar.example",
    "isActive": true
  }
}
```

A successful result contains the new `customer`, including its assigned `id`, and `errors: []`. Only `name` is required. A new customer is active by default when `isActive` is omitted.

A supplied external ID must be unique among customers in the tenant. If creation reports that it already exists, look up that reference again and inspect the record. Creating a customer does not automatically create a contact or an address.

Check both the top-level GraphQL `errors` and the mutation's `errors` list. An HTTP response alone does not establish success. Continue only when there are no errors and the expected customer is present.

### Add a customer contact

Add Maya Chen as the person who coordinates orders for Northstar Packaging:

```graphql example=crm.add-customer-contact part=operation
mutation AddCustomerContact($input: CreateContactInput!) {
  createContact(input: $input) {
    contact { id customerId firstName lastName email jobTitle isPrimary }
    errors { path message }
  }
}
```

Variables:

```json example=crm.add-customer-contact part=variables
{
  "input": {
    "customerId": "cus_01h455vb4pex5vsknk084sn02q",
    "firstName": "Maya",
    "lastName": "Chen",
    "email": "maya.chen@northstar.example",
    "jobTitle": "Operations Manager",
    "isPrimary": true
  }
}
```

Illustrative response:

```json example=crm.add-customer-contact part=illustrative-response
{
  "data": {
    "createContact": {
      "contact": {
        "id": "con_01h455vb4pex5vsknk084sn02r",
        "customerId": "cus_01h455vb4pex5vsknk084sn02q",
        "firstName": "Maya",
        "lastName": "Chen",
        "email": "maya.chen@northstar.example",
        "jobTitle": "Operations Manager",
        "isPrimary": true
      },
      "errors": []
    }
  }
}
```

The customer must already exist in your tenant. Keep the contact ID if you need to update Maya's details or associate an address with her.

`isPrimary` records whether this is a primary contact. Setting it to `true` does not automatically unset the flag on another contact. When changing the designated primary contact, read the current contacts and update the relevant flags explicitly. The default for a new contact is `false`.

### Record a shipping address

Create an address associated with the customer and Maya:

```graphql example=crm.add-shipping-address part=operation
mutation AddShippingAddress($input: CreateAddressInput!) {
  createAddress(input: $input) {
    address {
      id customerId contactId addressType
      street1 city postalCode country timezone isDefault
    }
    errors { path message }
  }
}
```

Variables:

```json example=crm.add-shipping-address part=variables
{
  "input": {
    "customerId": "cus_01h455vb4pex5vsknk084sn02q",
    "contactId": "con_01h455vb4pex5vsknk084sn02r",
    "addressType": "SHIPPING",
    "street1": "18 Example Street",
    "city": "Berlin",
    "postalCode": "10115",
    "country": "DE",
    "timezone": "Europe/Berlin",
    "isDefault": true
  }
}
```

A successful result returns the saved `address` with its assigned `AddressID` and an empty `errors` list. Read back the customer and contact IDs as well as the address details to confirm the association.

`addressType` is `BILLING`, `SHIPPING` or `BOTH`. Use a two-letter country code, such as `DE`. `timezone` is an optional IANA timezone name; `null` in a returned address means the timezone is unknown.

Choose a contact belonging to the intended customer. `isDefault` records the default flag for this address; setting it does not automatically clear other addresses' flags. A new address defaults to `false` when you omit it.

### Read the customer, contacts and addresses together

Once the records exist, retrieve the customer and the related details in one query:

```graphql example=crm.read-customer-overview part=operation
query ReadCustomerOverview($id: CustomerID!) {
  getCustomer(id: $id) {
    id name email phone notes isActive
    contacts {
      id firstName lastName email phone jobTitle isPrimary
    }
    addresses {
      id contactId addressType street1 street2
      city state postalCode country timezone isDefault
    }
  }
}
```

Variables:

```json example=crm.read-customer-overview part=variables
{
  "id": "cus_01h455vb4pex5vsknk084sn02q"
}
```

The result contains `getCustomer.contacts` and `getCustomer.addresses` as lists. Maya should appear in the contact list, and the shipping address should carry her contact ID.

This selection requires `customer:read`, `contact:list` and `address:list`. Permission to read the customer does not by itself grant access to the related lists. If you only need the customer's details, leave the relationship fields out of the selection.

For separate, paginated lists, use `listContacts` or `listAddresses` with `filter: {customerId: {eq: ...}}`. The nested lists above do not accept pagination arguments.

### Update details after a customer conversation

Suppose Northstar asks your team to use a new order email address and to call before delivery. Update those details without resending the rest of the customer record:

```graphql example=crm.update-customer-details part=operation
mutation UpdateCustomerDetails($id: CustomerID!, $input: UpdateCustomerInput!) {
  updateCustomer(id: $id, input: $input) {
    customer { id email notes updatedAt }
    errors { path message }
  }
}
```

Variables:

```json example=crm.update-customer-details part=variables
{
  "id": "cus_01h455vb4pex5vsknk084sn02q",
  "input": {
    "email": "purchasing@northstar.example",
    "notes": "Call the receiving contact before delivery."
  }
}
```

Confirm that the returned customer contains the new email and note. Omitted business fields retain their current values. The update refreshes `updatedAt` unless you supply an explicit timestamp override.

`notes` is one general text field. Supplying it replaces that text; it does not append a dated conversation entry. Read the existing note first when you want to preserve information already recorded there.

For `updateCustomer`, `updateContact` and `updateAddress`, an explicit `null` is treated like an omitted business field: it leaves the current value unchanged. Do not send `null` expecting to clear an optional value. `false`, however, is a supplied Boolean value and changes a flag to false.

Use `updateContact(id:, input:)` for Maya's personal email or phone number. Changing the customer's email does not change the contact's email. Similarly, use `updateAddress` to change the delivery location.

### Browse active customers

Find active customers whose names contain “Northstar”:

```graphql example=crm.browse-customers part=operation
query BrowseCustomers($filter: CustomerFilterInput!, $after: String) {
  listCustomers(
    first: 20
    after: $after
    filter: $filter
    sort: [{field: NAME, direction: ASC}]
  ) {
    edges {
      node { id name externalId email isActive }
      cursor
    }
    pageInfo { hasNextPage endCursor }
    totalCount
  }
}
```

Variables for the first page:

```json example=crm.browse-customers part=variables
{
  "filter": {
    "name": {
      "contains": "Northstar"
    },
    "isActive": {
      "eq": true
    }
  },
  "after": null
}
```

Read each customer from `edges[].node`. If `pageInfo.hasNextPage` is `true`, repeat the same query with `after` set to the returned `endCursor`. Keep the filter and sort unchanged. Cursors are opaque strings; pass them back unchanged.

To browse all active customers, remove the `name` condition from the variables. To include inactive customers too, omit the `isActive` condition. `totalCount` is the number matching the filter, not the number on the current page.

### Mark a customer inactive

When the customer relationship is no longer active, retain the record and change its status:

```graphql example=crm.mark-customer-inactive part=operation
mutation MarkCustomerInactive($id: CustomerID!) {
  updateCustomer(id: $id, input: {isActive: false}) {
    customer { id name isActive }
    errors { path message }
  }
}
```

Variables:

```json example=crm.mark-customer-inactive part=variables
{
  "id": "cus_01h455vb4pex5vsknk084sn02q"
}
```

Confirm `isActive: false` with no errors. The customer remains readable by ID, and the active-customer query above excludes it. This flag does not delete the customer or its related records. Set `isActive: true` through `updateCustomer` to reactivate it.

### Query reference

`!` means an input is required or an output cannot be `null`. Brackets describe a list. Nullable single-record queries can return `null` when no matching record is found.

| Query | Arguments | Result | Use it to |
| --- | --- | --- | --- |
| `getCustomer` | `id: CustomerID!` | `CustomerState` | Read one customer and optionally its related records |
| `getCustomerBy` | `externalId: String` | `CustomerState` | Find a customer by an external reference; supply the selector |
| `listCustomers` | `first: Int`, `last: Int`, `after: String`, `before: String`, `filter: CustomerFilterInput`, `sort: [CustomerSortInput!]` | `CustomerConnection!` | Browse matching customers |
| `getContact` | `id: ContactID!` | `ContactState` | Read a contact by its own identifier |
| `getContactBy` | `externalId: String`, `email: String` | `ContactState` | Look up a contact using a supplied selector |
| `listContacts` | `first: Int`, `last: Int`, `after: String`, `before: String`, `filter: ContactFilterInput`, `sort: [ContactSortInput!]` | `ContactConnection!` | Browse contacts, optionally within a customer |
| `getAddress` | `id: AddressID!` | `AddressState` | Read one address |
| `getAddressBy` | `externalId: String` | `AddressState` | Find an address by an external reference |
| `listAddresses` | `first: Int`, `last: Int`, `after: String`, `before: String`, `filter: AddressFilterInput`, `sort: [AddressSortInput!]` | `AddressConnection!` | Browse addresses, optionally within a customer or contact |

The list queries accept `first: Int`, `after: String`, `last: Int` and `before: String`. For forward pagination, use `first` and `after` as shown above. Use `getContact(id:)` when you already know which contact you want; use a customer-filtered `listContacts` when an email could identify more than one person.

### Mutation reference

| Mutation | Arguments | Result | Use it to |
| --- | --- | --- | --- |
| `createCustomer` | `input: CreateCustomerInput!` | `UpsertCustomerPayload!` | Register a customer |
| `updateCustomer` | `id: CustomerID!`, `input: UpdateCustomerInput!` | `UpsertCustomerPayload!` | Change selected customer fields |
| `createContact` | `input: CreateContactInput!` | `UpsertContactPayload!` | Add a person to a customer |
| `updateContact` | `id: ContactID!`, `input: UpdateContactInput!` | `UpsertContactPayload!` | Change selected contact fields |
| `createAddress` | `input: CreateAddressInput!` | `UpsertAddressPayload!` | Record an address |
| `updateAddress` | `id: AddressID!`, `input: UpdateAddressInput!` | `UpsertAddressPayload!` | Change selected address fields |
| `deleteCustomer` | `id: CustomerID!` | `DeletePayload!` | Request customer deletion; requires `customer:delete` |
| `deleteContact` | `id: ContactID!` | `DeleteContactPayload!` | Request contact deletion; requires `contact:delete` |
| `deleteAddress` | `id: AddressID!` | `DeleteAddressPayload!` | Request address deletion; requires `address:delete` |
| `upsertCustomer` | `input: UpsertCustomerInput!` | `UpsertCustomerPayload!` | Advanced: Create or update a customer using a complete record |
| `upsertContact` | `input: UpsertContactInput!` | `UpsertContactPayload!` | Advanced: Create or update a contact using a complete record |
| `upsertAddress` | `input: UpsertAddressInput!` | `UpsertAddressPayload!` | Advanced: Create or update an address using a complete record |

Deletion is different from marking a customer inactive. Existing references can prevent deletion; inspect the result rather than assuming it succeeded.

**Advanced: upsert.** Prefer the explicit create and update operations above for everyday CRM work. `upsertCustomer`, `upsertContact` and `upsertAddress` are also available, each taking its corresponding `Upsert…Input!` and returning the same payload type as creation. Their inputs describe a complete record and can replace optional values with defaults or empty values. Use the explicit create and update operations above for the tasks in this chapter. An upsert is not a request to find a record by matching its external ID.

### Type reference

#### Customer fields

| Fields | Types | Meaning |
| --- | --- | --- |
| `id` | `CustomerID!` | Assigned CoCoCo identifier |
| `name` | `String!` | Business or individual name |
| `externalId` | `String` | Reference from another system |
| `email`, `phone`, `notes` | `String` | General contact details and customer note |
| `isActive` | `Boolean!` | Whether the customer is active |
| `creditLimit`, `creditCurrency` | `Float`, `CurrencyISO` | Recorded credit limit and its currency |
| `paymentTermsDays` | `Int` | Recorded net payment terms; `0` means prepayment, `null` means not set |
| `isCreditHold`, `creditHoldReason` | `Boolean!`, `String` | Credit-hold flag and explanation |
| `defaultTaxCodeId` | `TaxCodeID` | Reference to an existing tax code |
| `isTaxExempt`, `taxExemptReference` | `Boolean!`, `String` | Tax-exemption flag and reference |
| `createdAt`, `updatedAt` | `String!` | ISO 8601 timestamps |
| `contacts`, `addresses` | `[ContactState!]!`, `[AddressState!]!` | Related records, with their own read permissions |

Credit and tax fields record customer-specific values. This chapter does not configure tax codes or define how downstream ordering and invoicing processes apply those values.

#### Contact and address fields

| Type | Fields |
| --- | --- |
| `ContactState` identity and relationship | `id: ContactID!`, `customerId: CustomerID!`, `externalId: String` |
| Contact name and details | `firstName: String!`, `lastName: String!`, `email: String`, `phone: String`, `jobTitle: String`, `isPrimary: Boolean!` |
| `AddressState` identity and relationships | `id: AddressID!`, `customerId: CustomerID`, `contactId: ContactID`, `supplierId: SupplierID`, `externalId: String` |
| Address location | `addressType: AddressType!`, `street1: String!`, `street2: String`, `city: String!`, `state: String`, `postalCode: String`, `country: String!`, `timezone: String`, `isDefault: Boolean!` |
| Timestamps on both types | `createdAt: ISO8601!`, `updatedAt: ISO8601!` |

`AddressType` has values `BILLING`, `SHIPPING` and `BOTH`. The shared address type also supports supplier addresses; for the customer tasks here, supply the customer relationship. Identifiers are opaque strings: retain the complete value, including its prefix.

#### Inputs for these tasks

| Input | Required fields | Optional fields |
| --- | --- | --- |
| `CreateCustomerInput` | `name: String!` | `externalId: String`, `email: String`, `phone: String`, `notes: String`, `isActive: Boolean`, `creditLimit: Float`, `creditCurrency: CurrencyISO`, `paymentTermsDays: Int`, `isCreditHold: Boolean`, `creditHoldReason: String`, `defaultTaxCodeId: TaxCodeID`, `isTaxExempt: Boolean`, `taxExemptReference: String`, `createdAt: ISO8601`, `updatedAt: ISO8601` |
| `UpdateCustomerInput` | None inside `input`; the mutation requires `id` | `name: String`, `externalId: String`, `email: String`, `phone: String`, `notes: String`, `isActive: Boolean`, `creditLimit: Float`, `creditCurrency: CurrencyISO`, `paymentTermsDays: Int`, `isCreditHold: Boolean`, `creditHoldReason: String`, `defaultTaxCodeId: TaxCodeID`, `isTaxExempt: Boolean`, `taxExemptReference: String`, `updatedAt: ISO8601` |
| `CreateContactInput` | `customerId: CustomerID!`, `firstName: String!`, `lastName: String!` | `externalId: String`, `email: String`, `phone: String`, `jobTitle: String`, `isPrimary: Boolean` |
| `UpdateContactInput` | None inside `input`; the mutation requires `id` | `customerId: CustomerID`, `externalId: String`, `firstName: String`, `lastName: String`, `email: String`, `phone: String`, `jobTitle: String`, `isPrimary: Boolean` |
| `CreateAddressInput` | `addressType: AddressType!`, `street1: String!`, `city: String!`, `country: String!` | `timezone: String`, `customerId: CustomerID`, `contactId: ContactID`, `supplierId: SupplierID`, `externalId: String`, `street2: String`, `state: String`, `postalCode: String`, `isDefault: Boolean` |
| `UpdateAddressInput` | None inside `input`; the mutation requires `id` | `timezone: String`, `customerId: CustomerID`, `contactId: ContactID`, `supplierId: SupplierID`, `externalId: String`, `addressType: AddressType`, `street1: String`, `street2: String`, `city: String`, `state: String`, `postalCode: String`, `country: String`, `isDefault: Boolean` |

Optional input fields use the corresponding record field's type without `!`. Customer timestamp overrides use `ISO8601`, rather than the returned timestamp's `String` type. Omit timestamp overrides for ordinary day-to-day edits. Customer creation defaults `isActive` to `true`, `isCreditHold` to `false` and `isTaxExempt` to `false`.

#### Filters and sorting

| List | Filter fields | Sort fields |
| --- | --- | --- |
| Customers | `name`, `externalId`, `email`, `phone`, `isActive`, `createdAt`, `updatedAt` | `NAME`, `CREATED_AT`, `UPDATED_AT` |
| Contacts | `customerId`, `firstName`, `lastName`, `email`, `isPrimary`, `createdAt`, `updatedAt` | `LAST_NAME`, `FIRST_NAME`, `CREATED_AT`, `UPDATED_AT` |
| Addresses | `customerId`, `contactId`, `externalId`, `addressType`, `city`, `country`, `isDefault`, `createdAt`, `updatedAt` | `CITY`, `COUNTRY`, `CREATED_AT`, `UPDATED_AT` |

Each sort entry supplies `field` and `direction`, with `ASC` or `DESC` as the direction. String filters support `eq`, `neq`, `contains`, `startsWith`, `endsWith`, `in` and `notIn`. Relationship filters use an ID filter, for example `{customerId: {eq: "cus_..."}}`. Boolean filters use `{eq: true}` or `{eq: false}`. The address-type filter is a string filter even though the record field is an enum; use the enum's spelling, such as `{addressType: {eq: "SHIPPING"}}`.

#### Results and errors

`UpsertCustomerPayload` contains `customer` and `errors`. Contact and address payloads contain `contact` or `address` and `errors`. Deletion payloads contain `success` and `errors`.

Each `FieldError` supplies a `path` and `message`; a stable `code` may also be available. Correct the indicated field before retrying. Top-level GraphQL errors can also report permission failures or other request problems.

Paginated results contain `edges`, `pageInfo` and `totalCount`. Each edge holds a `node` and `cursor`. Use `pageInfo.hasNextPage` and `pageInfo.endCursor` to continue reading forward.

### How this fits the schema

These operations belong to the schema's `commerce` topic, declared through `@topic(names: ["commerce"])`. CRM is the customer-facing part of that broader topic covered here.

A topic helps you discover related operations. It does not decide whether an operation belongs in a runtime workflow: this guide uses customer, contact and address data operations, while infrastructure and provisioning remain in the IaC configuration.

### Review the customer's commercial account

Once the customer has estimates, orders or invoices, `customerAccountSummary` brings them together:

```graphql example=commerce.customer-account part=operation
query ReadCommerceCustomerAccount($customerId: CustomerID!) {
  customerAccountSummary(customerId: $customerId) {
    customer { id name }
    orders { id orderNumber totalAmount currency }
    estimates { id estimateNumber totalAmount currency }
    invoices { id invoiceNumber totalAmount paidAmount currency }
    totalInvoiced totalPaid totalOutstanding
    errors { path message }
  }
}
```

Variables:

```json example=commerce.customer-account part=variables
{
  "customerId": "cus_01h455vb4pex5vsknk084sn02q"
}
```

Inspect each document's currency when interpreting the totals. The summary's total fields do not themselves carry a currency; use the individual invoices when you need a breakdown by currency. Optional date arguments can narrow the review.

The complete customer operation list, including this account view, is:

| Operation | Use it to |
| --- | --- |
| `Query.getCustomer` | Read customer by identifier |
| `Query.getCustomerBy` | Read customer using a supplied lookup value |
| `Query.listCustomers` | Browse customers |
| `Query.listContacts` | Browse contacts |
| `Query.getContact` | Read contact by identifier |
| `Query.getContactBy` | Read contact using a supplied lookup value |
| `Query.getAddress` | Read address by identifier |
| `Query.getAddressBy` | Read address using a supplied lookup value |
| `Query.listAddresses` | Browse addresses |
| `Query.customerAccountSummary` | Review a customer’s orders, estimates, invoices and account totals |
| `Mutation.upsertCustomer` | Advanced: Create or update a customer. Prefer create/update for routine changes. |
| `Mutation.createCustomer` | Create a new customer |
| `Mutation.updateCustomer` | Change selected customer fields |
| `Mutation.deleteCustomer` | Delete a customer |
| `Mutation.upsertContact` | Advanced: Create or update a contact. Prefer create/update for routine changes. |
| `Mutation.createContact` | Create a new contact |
| `Mutation.updateContact` | Change selected contact fields |
| `Mutation.deleteContact` | Delete a contact |
| `Mutation.upsertAddress` | Advanced: Create or update a address. Prefer create/update for routine changes. |
| `Mutation.createAddress` | Create a new address |
| `Mutation.updateAddress` | Change selected address fields |
| `Mutation.deleteAddress` | Delete a address |

## Orders — record what the customer is buying

An order is the customer's commercial commitment. Its header records the customer, order number, dates, amounts and overall status. Its lines describe the products or services, quantities and prices. `OrderState` is the header; `OrderLineState` is an individual line.

An order is different from a purchase order: the order records what your customer buys from you; a purchase order records what you buy from a supplier.

### Find an existing order

When another system supplies a stable reference, look for it before creating a second order:

```graphql example=commerce.find-order part=operation
query FindCommerceOrder($externalId: String!) {
  getOrderBy(externalId: $externalId) { id orderNumber status paymentStatus customerId }
}
```

Variables:

```json example=commerce.find-order part=variables
{
  "externalId": "SALES-2042"
}
```

Keep the returned `id`. If you already have that ID, use `getOrder`. Use `listOrders` to browse by customer, order number or status; inspect matches rather than treating a partial number search as a unique lookup.

### Create an order with its lines

`createOrderGraph` creates the header and lines together. It can also create a customer and billing or shipping addresses in the same request. Here the customer already exists:

```graphql example=commerce.create-order part=operation
mutation CreateCommerceOrder($input: CreateOrderGraphInput!) {
  createOrderGraph(input: $input) {
    order { id orderNumber status totalAmount currency }
    lines { id lineNumber productName quantity lineTotal }
    errors { path message }
  }
}
```

Variables:

```json example=commerce.create-order part=variables
{
  "input": {
    "order": {
      "orderNumber": "ORD-2042",
      "externalId": "SALES-2042",
      "customerId": "cus_01h455vb4pex5vsknk084sn02q",
      "currency": "EUR",
      "subtotalAmount": 1000,
      "taxAmount": 0,
      "totalAmount": 1000
    },
    "lines": [
      {
        "lineNumber": 1,
        "productName": "Packaging sleeves",
        "quantity": 500,
        "unitPrice": 2,
        "lineTotal": 1000,
        "currency": "EUR"
      }
    ]
  }
}
```

This request requires `order:write` and, because it contains lines, `orderline:write`. Creating a nested customer requires `customer:write`; creating nested addresses requires `address:write`. Attaching files requires the relevant file permissions as well.

Use either an existing customer ID or a nested customer, never both. Apply the same choice to each address. Although the nested input types have names beginning with `Upsert`, this operation creates new records: omit nested `id` fields and each line's `orderId`. The new order supplies the line relationship.

Read the returned order and line IDs before proceeding. For separate steps, use `createOrder` and `createOrderLine`; those requests do not provide the same all-together creation boundary. Use `updateOrder` and `updateOrderLine` to change existing records.

### Follow the order and browse work

Use `orderLifecycle` when you want the order in context:

```graphql example=commerce.order-lifecycle part=operation
query ReadCommerceOrderLifecycle($orderId: OrderID!) {
  orderLifecycle(orderId: $orderId) {
    order { id orderNumber status paymentStatus }
    orderLines { id lineNumber quantity }
    estimate { id estimateNumber }
    invoices { id invoiceNumber totalAmount paidAmount currency }
    jobs { id name }
    errors { path message }
  }
}
```

Variables:

```json example=commerce.order-lifecycle part=variables
{
  "orderId": "ord_01h455vb4pex5vsknk084sn02q"
}
```

You choose which related records to request. This example reads the estimate, invoices and production jobs; the same result also offers shipment information.

To browse confirmed orders for one customer:

```graphql example=commerce.browse-orders part=operation
query BrowseCommerceOrders($customerId: ID!, $after: String) {
  listOrders(first: 20, after: $after, filter: {customerId: {eq: $customerId}, status: {eq: CONFIRMED}}, sort: [{field: ORDER_DATE, direction: DESC}]) {
    edges { cursor node { id orderNumber status totalAmount currency } }
    pageInfo { endCursor hasNextPage }
  }
}
```

Variables:

```json example=commerce.browse-orders part=variables
{
  "customerId": "cus_01h455vb4pex5vsknk084sn02q",
  "after": null
}
```

For the next page, pass `endCursor` as `after` while keeping the same filter and sort. Stop when `hasNextPage` is false.

The available order status values are `DRAFT`, `CONFIRMED`, `IN_PRODUCTION`, `PARTIALLY_SHIPPED`, `SHIPPED`, `DELIVERED`, `COMPLETED`, `CANCELLED`, `ON_HOLD`. These values describe the supported states; they do not make every transition appropriate. `bulkUpdateOrderStatuses` changes several orders in one request. Inspect its result for the orders changed and any reported errors. Use the fulfilment check below when completing an order; changing a status does not itself dispatch or deliver goods.

| Operation | Use it to |
| --- | --- |
| `Query.getOrder` | Read order by identifier |
| `Query.getOrderBy` | Read order using a supplied lookup value |
| `Query.listOrders` | Browse orders |
| `Query.getOrderLine` | Read order line by identifier |
| `Query.listOrderLines` | Browse order lines |
| `Query.orderLifecycle` | Read an order together with its lines, estimate, invoices, shipments and jobs |
| `Mutation.upsertOrder` | Advanced: Create or update a order. Prefer create/update for routine changes. |
| `Mutation.createOrder` | Create a new order |
| `Mutation.updateOrder` | Change selected order fields |
| `Mutation.createOrderGraph` | Create an order, its lines and optional customer and addresses together |
| `Mutation.deleteOrder` | Delete a order |
| `Mutation.upsertOrderLine` | Advanced: Create or update a order line. Prefer create/update for routine changes. |
| `Mutation.createOrderLine` | Create a new order line |
| `Mutation.updateOrderLine` | Change selected order line fields |
| `Mutation.deleteOrderLine` | Delete a order line |
| `Mutation.bulkUpdateOrderStatuses` | Change the status of several orders in one request |

## Read configured costs and check quoting readiness

Before pricing a printing job, establish what its production work costs. A cost category describes the kind of cost and the unit you measure. A cost rate supplies an amount and currency for that category. An operation cost mapping connects a production operation to the category used when calculating an estimate.

For example, press time might be measured in hours, while a material category uses sheets. The quantity and rate must use the same unit: 2.5 hours at EUR 75 per hour is EUR 187.50. Finished copies, press sheets and hours are different quantities, even when they belong to the same job.

**Provision cost categories, cost rates and operation cost mappings through IaC.** Read those configured records through the API when preparing estimates or recording actual costs. Keep the configuration review separate from entering the costs of an individual job.

### Find the category and rate

Find a configured category by its agreed code:

```graphql example=erp.find-cost-category part=operation
query FindERPCostCategory($code: String!) {
  getCostCategoryBy(code: $code) { id code name unit isActive }
}
```

Variables:

```json example=erp.find-cost-category part=variables
{
  "code": "PRESS_TIME"
}
```

Check its unit and active flag before using the returned ID. The example code is illustrative; it does not create a press-time category. Use `listCostCategories` when browsing, and `listCostRates` or `getCostRate` to inspect the selected rate, currency, version and effective dates. A cost centre can distinguish where the cost applies.

Rate versions matter when comparing work priced at different times. The reference exposes both in-place updates and version-producing upserts; their behaviour is different. Manage rate changes through IaC with the intended history in mind, rather than assuming every change creates a new version.

### Check the configuration before automated quoting

Run the readiness check before relying on automated estimates:

```graphql example=erp.quoting-readiness part=operation
query CheckERPQuotingReadiness {
  quotingReadinessCheck {
    isReady summary configuredOperationKinds unconfiguredOperationKinds
    unsupportedOperationKinds categoriesWithoutRates warnings
  }
}
```

Variables:

```json example=erp.quoting-readiness part=variables
{}
```

Read the summary and warnings as well as `isReady`. Missing operation mappings, categories without current rates and missing work-centre capabilities can leave the configuration incomplete. `unsupportedOperationKinds` identifies registered kinds that no configured work centre supports; that list is informational rather than a readiness failure by itself.

Readiness checks the costing configuration. It does not promise that a particular brochure can be produced, that its specification is complete or that a proposed delivery date is achievable. Review the individual estimate's feasibility and diagnostics as well.

`exportCostConfiguration` provides a CSV view for reviewing configured costs. `importCostConfiguration` remains an advanced bulk-import tool in the reference; use IaC for routine provisioning. An import can skip invalid rows while applying valid ones, and replacement mode deactivates omitted configuration. Its presence is not an invitation to maintain a second, competing source of configuration.

| Operation | Use it to |
| --- | --- |
| `Query.getCostCategory` | Read a configured cost category and its unit. |
| `Query.getCostCategoryBy` | Find a configured cost category by code or external reference. |
| `Query.listCostCategories` | Browse configured cost categories. |
| `Query.getCostRate` | Read a configured rate, currency and version. |
| `Query.listCostRates` | Browse configured rates and their effective dates. |
| `Query.getOperationCostMapping` | Read the cost mapping for a production operation. |
| `Query.listOperationCostMappings` | Browse configured operation cost mappings. |
| `Query.quotingReadinessCheck` | Check for gaps in the configuration used for automated quoting. |
| `Mutation.createCostCategory` | Manage through IaC: create a cost category. |
| `Mutation.updateCostCategory` | Manage through IaC: change a cost category. |
| `Mutation.upsertCostCategory` | Manage through IaC: create or update a cost category. |
| `Mutation.deleteCostCategory` | Manage through IaC: delete a cost category. |
| `Mutation.upsertCostRate` | Manage through IaC: create or update a cost rate. |
| `Mutation.createCostRate` | Manage through IaC: create a cost rate. |
| `Mutation.updateCostRate` | Manage through IaC: change a cost rate. |
| `Mutation.upsertOperationCostMapping` | Manage through IaC: create or update an operation cost mapping. |
| `Mutation.createOperationCostMapping` | Manage through IaC: create an operation cost mapping. |
| `Mutation.updateOperationCostMapping` | Manage through IaC: change an operation cost mapping. |
| `Mutation.deleteOperationCostMapping` | Manage through IaC: delete an operation cost mapping. |
| `Mutation.exportCostConfiguration` | Export configured costs and mappings for review. |
| `Mutation.importCostConfiguration` | Advanced configuration import; manage routine provisioning through IaC. |
| `Mutation.deleteCostRate` | Manage through IaC: delete an unused cost rate; dependent costs or later versions prevent deletion. |

## Estimates — prepare and revise an offer

An estimate records proposed quantities, prices and totals before an order is agreed. `EstimateState` is the offer; `EstimateLineState` describes one proposed item. Cost breakdowns explain the calculation behind a line, while the estimate itself records the amount offered to the customer.

### Prepare an estimate and a line

When supplying your own commercial amounts, create the estimate first:

```graphql example=commerce.create-estimate part=operation
mutation CreateCommerceEstimate($input: CreateEstimateInput!) {
  createEstimate(input: $input) {
    estimate { id estimateNumber status totalAmount currency }
    errors { path message }
  }
}
```

Variables:

```json example=commerce.create-estimate part=variables
{
  "input": {
    "estimateNumber": "EST-2042",
    "customerId": "cus_01h455vb4pex5vsknk084sn02q",
    "issueDate": "2026-10-21T09:00:00.000Z",
    "subtotalAmount": 1000,
    "taxAmount": 0,
    "totalAmount": 1000,
    "currency": "EUR"
  }
}
```

Use the returned estimate ID when adding its line:

```graphql example=commerce.add-estimate-line part=operation
mutation AddCommerceEstimateLine($input: CreateEstimateLineInput!) {
  createEstimateLine(input: $input) {
    estimateLine { id lineNumber productName quantity lineTotal }
    errors { path message }
  }
}
```

Variables:

```json example=commerce.add-estimate-line part=variables
{
  "input": {
    "estimateId": "est_01h455vb4pex5vsknk084sn02q",
    "lineNumber": 1,
    "productName": "Packaging sleeves",
    "quantity": 500,
    "unitPrice": 2,
    "lineTotal": 1000,
    "currency": "EUR"
  }
}
```

These are two requests. If the second fails, the estimate header still exists. Correct the error and continue with that estimate rather than creating another header. Keep the header totals consistent with the lines you intend to offer.

Use `generateEstimate` when you want an estimate calculated from production requirements instead of supplying all amounts yourself. Inspect its input and result in the reference; the calculated offer depends on the available production and costing information.

### Convert the agreed offer into an order

After reviewing the offer with the customer, convert it with:

```graphql example=commerce.convert-estimate part=operation
mutation ConvertCommerceEstimate($input: ConvertEstimateToOrderInput!) {
  convertEstimateToOrder(input: $input) {
    order { id orderNumber status }
    orderLines { id lineNumber quantity }
    updatedEstimate { id status convertedToOrderId }
    errors { path message }
  }
}
```

Variables:

```json example=commerce.convert-estimate part=variables
{
  "input": {
    "estimateId": "est_01h455vb4pex5vsknk084sn02q",
    "orderNumber": "ORD-2043"
  }
}
```

Conversion creates an order and copies the estimate lines. It marks the estimate accepted and connects it to the new order. The estimate must not already be converted, rejected or expired. Keep the returned order ID; do not also run the direct order-creation example for the same sale.

For a changed offer, use `reviseEstimate`: it makes a new draft revision with copied lines and marks the original revised. An already accepted estimate cannot be revised this way. For a different customer, use `duplicateEstimateForCustomer`, reviewing any requested price adjustment before submitting it.

Estimate status values are `DRAFT`, `SENT`, `ACCEPTED`, `REJECTED`, `EXPIRED`, `REVISED`. Use `getEstimate`, `getEstimateBy` and `listEstimates` to find offers, and `listEstimateCostBreakdowns` to inspect the costs behind a line.

| Operation | Use it to |
| --- | --- |
| `Query.getEstimate` | Read estimate by identifier |
| `Query.getEstimateBy` | Read estimate using a supplied lookup value |
| `Query.listEstimates` | Browse estimates |
| `Query.getEstimateLine` | Read estimate line by identifier |
| `Query.listEstimateLines` | Browse estimate lines |
| `Query.listEstimateCostBreakdowns` | Read the cost breakdowns behind an estimate line |
| `Mutation.upsertEstimate` | Advanced: Create or update a estimate. Prefer create/update for routine changes. |
| `Mutation.createEstimate` | Create a new estimate |
| `Mutation.updateEstimate` | Change selected estimate fields |
| `Mutation.deleteEstimate` | Delete a estimate |
| `Mutation.upsertEstimateLine` | Advanced: Create or update a estimate line. Prefer create/update for routine changes. |
| `Mutation.createEstimateLine` | Create a new estimate line |
| `Mutation.updateEstimateLine` | Change selected estimate line fields |
| `Mutation.deleteEstimateLine` | Delete a estimate line |
| `Mutation.convertEstimateToOrder` | Create an order and its lines from an estimate |
| `Mutation.reviseEstimate` | Create a new draft revision with the original estimate’s lines |
| `Mutation.generateEstimate` | Prepare an estimate from production requirements and calculated costs |
| `Mutation.duplicateEstimateForCustomer` | Copy an estimate for another customer, optionally adjusting prices |

## From a sale to production

Use `createJobsFromOrder` when an existing order is ready to become production work. Each job entry identifies a line belonging to that order:

```graphql example=commerce.create-production-jobs part=operation
mutation CreateCommerceProductionJobs($input: CreateJobsFromOrderInput!) {
  createJobsFromOrder(input: $input) {
    order { id status }
    jobs { id name }
    errors { path message }
  }
}
```

Variables:

```json example=commerce.create-production-jobs part=variables
{
  "input": {
    "orderId": "ord_01h455vb4pex5vsknk084sn02q",
    "jobs": [
      {
        "orderLineId": "oln_01h455vb4pex5vsknk084sn02q",
        "jobName": "Northstar packaging sleeves",
        "quantity": 500
      }
    ]
  }
}
```

This operation requires `job:write`. Read the returned job IDs and order status before handing work to production. Product-template information on a line can determine how its production work is created. The manual example supplies a job name and quantity for one line; select the lines and quantities appropriate to the actual order.

If you are starting with an estimate and want to accept it, create its order and create production jobs in one operation, use `acceptEstimateAndCreateJobs`. Choose this combined path or the separate conversion and job-creation path for a sale; do not run both as successive steps.

The creation of jobs is the handover. Scheduling, production progress and machine operation belong to the [Production guide](/docs/data-model/production). Artwork preparation belongs to [Prepress](/docs/data-model/prepress).

| Operation | Use it to |
| --- | --- |
| `Mutation.createJobsFromOrder` | Create production jobs from selected order lines |
| `Mutation.acceptEstimateAndCreateJobs` | Accept an estimate and create its order and production jobs |

## Record and review actual job costs

An estimate records what you expected to charge. A recorded cost captures a cost actually incurred. An invoice records what you billed. Keeping these separate lets you compare the printing job's outcome with the offer without rewriting the original commercial agreement.

Use `RecordedCostState` for an actual cost entry. Link it to the job with `jobId`, choose a configured cost category and record the quantity, rate, currency and time incurred. A description and source reference help explain where the entry came from.

### Record a cost incurred

Suppose the autumn brochure used 2.5 hours of press time at EUR 75 per hour. After checking that the configured category uses hours, record the entry:

```graphql example=erp.record-job-cost part=operation
mutation RecordERPJobCost($input: CreateRecordedCostInput!) {
  createRecordedCost(input: $input) {
    recordedCost {
      id jobId costCategoryId quantity unitRate totalCost currency recordedAt
    }
    errors { path message }
  }
}
```

Variables:

```json example=erp.record-job-cost part=variables
{
  "input": {
    "costCategoryId": "cca_00000000008008000000000201",
    "jobId": "job_000000000080080000000000r1",
    "quantity": 2.5,
    "unitRate": 75,
    "currency": "EUR",
    "description": "Autumn brochure — recorded press time",
    "referenceType": "timesheet",
    "referenceId": "TS-2026-1042",
    "recordedAt": "2026-10-22T13:00:00Z"
  }
}
```

Replace the category and job IDs with the IDs you have read. The returned `totalCost` is quantity multiplied by unit rate: EUR 187.50 in this example. Read the record and any errors before continuing.

This example supplies the rate explicitly. Alternatively, `costRateId` can supply the unit rate when `unitRate` is omitted. Check that the selected rate belongs to the intended category and currency; the input currency is still your responsibility. Do not omit both rate sources and assume the current rate will be found automatically.

The source reference identifies the timesheet entry for a reader or integration. It does not make repeated creates safe. If a response is lost, inspect existing entries before recording the same cost again.

### Correct an entry

Keep the returned recorded-cost ID. If the time was entered incorrectly, update that entry:

```graphql example=erp.correct-job-cost part=operation
mutation CorrectERPJobCost($id: RecordedCostID!, $input: UpdateRecordedCostInput!) {
  updateRecordedCost(id: $id, input: $input) {
    recordedCost { id jobId quantity unitRate totalCost currency description }
    errors { path message }
  }
}
```

Variables:

```json example=erp.correct-job-cost part=variables
{
  "id": "rco_00000000008008000000000202",
  "input": {
    "quantity": 2.75,
    "description": "Autumn brochure — corrected press time"
  }
}
```

Here the unit rate remains EUR 75 and the corrected total becomes EUR 206.25. The operation recalculates the total from the resulting quantity and rate. A correction changes the existing cost record; it does not create a separate adjustment entry.

Use `deleteRecordedCost` only for a specific entry that should not exist. Deleting a cost record does not reverse a supplier payment, warehouse movement or production report. `upsertRecordedCost` is available for deliberate create-or-update workflows; prefer create/update for routine entry and corrections.

### Browse the costs recorded against the job

```graphql example=erp.browse-job-costs part=operation
query BrowseERPJobCosts($first: Int!, $filter: RecordedCostFilterInput!, $after: String) {
  listRecordedCosts(first: $first, after: $after, filter: $filter) {
    edges {
      node {
        id jobId costCategoryId quantity unitRate totalCost currency recordedAt
        referenceType referenceId
      }
    }
    pageInfo { hasNextPage endCursor }
  }
}
```

Variables:

```json example=erp.browse-job-costs part=variables
{
  "first": 20,
  "filter": {
    "jobId": {
      "eq": "job_000000000080080000000000r1"
    }
  }
}
```

Follow `endCursor` while `hasNextPage` is true, keeping the filter unchanged. You can also filter by category, cost centre, currency and dates. Review the entries and their source references before deciding that the recorded costs are complete.

### Compare recorded costs with the job's commercial outcome

The same job summary used in Production brings the cost views together:

```graphql example=production.review-costs part=operation
query ReviewPrintingCosts($jobId: JobID!) {
  jobCostingSummary(jobId: $jobId) {
    job { id name }
    totalMaterialCost totalOperationCost totalCost
    recordedCostsAvailable totalRecordedCost recordedCostCurrency
    sellingPrice sellingPriceCurrency recordedCostMargin
    errors { path message }
  }
}
```

Variables:

```json example=production.review-costs part=variables
{
  "jobId": "job_0h248h248h848r248h248h248h"
}
```

Keep the purchased or planned material and operation costs separate from recorded actual costs. They can describe the same work, so adding the two views together would double count it. Recording shop-floor progress does not by itself create entries in this actual-cost view.

Check `recordedCostsAvailable`: job access alone does not grant access to recorded costs. When access is unavailable, hidden totals are not zero costs. With access, use `recordedCostTotals` for the currency breakdown. `totalRecordedCost` is present only when recorded entries exist in one currency; mixed currencies must remain separate.

`recordedCostMargin` compares the price of the single linked order line with costs recorded so far, when their currencies agree. It is not final profitability. Missing costs, multiple linked order lines or mismatched currencies prevent a meaningful single margin. Reconcile the entries before using the result to review the job.

| Operation | Use it to |
| --- | --- |
| `Query.getRecordedCost` | Read an actual cost entry. |
| `Query.listRecordedCosts` | Browse actual costs by job, category, currency or date. |
| `Query.jobCostingSummary` | Compare a job’s material and operation costs with recorded actual costs. |
| `Mutation.upsertRecordedCost` | Advanced: create or replace an actual cost entry. Prefer create/update for routine changes. |
| `Mutation.createRecordedCost` | Record a new actual cost incurred. |
| `Mutation.updateRecordedCost` | Correct selected fields on an actual cost entry. |
| `Mutation.deleteRecordedCost` | Delete an incorrectly recorded cost. |

## Invoices, credit notes and payments

An invoice records the amount billed to a customer. Its lines describe the charges. A payment records money received against an invoice. These are separate records: recording an order does not itself establish that the customer has been billed or has paid.

### Invoice an order

`invoiceOrder` creates an invoice and lines from an existing order:

```graphql example=commerce.invoice-order part=operation
mutation InvoiceCommerceOrder($input: InvoiceOrderInput!) {
  invoiceOrder(input: $input) {
    invoice { id invoiceNumber status totalAmount paidAmount currency }
    invoiceLines { id }
    errors { path message }
  }
}
```

Variables:

```json example=commerce.invoice-order part=variables
{
  "input": {
    "orderId": "ord_01h455vb4pex5vsknk084sn02q",
    "invoiceNumber": "INV-2042",
    "issueDate": "2026-10-21T09:00:00.000Z",
    "dueDate": "2026-11-20T09:00:00.000Z"
  }
}
```

Supply your invoice number and the intended dates. Keep the returned invoice ID and check its amounts and currency. The operation rejects an order that already has an invoice, including concurrent attempts; if a request's result is uncertain, inspect the order's invoices before trying again.

The API also provides individual invoice and invoice-line operations for workflows that need direct control. Creating or editing those records should reflect the document you actually intend to issue.

### Record a payment

After confirming that a payment was received, use the invoice ID:

```graphql example=commerce.record-payment part=operation
mutation RecordCommercePayment($input: OTCRecordPaymentInput!) {
  recordPayment(input: $input) {
    payment { id amount currency method reference }
    invoice { id status totalAmount paidAmount }
    order { id paymentStatus }
    errors { path message }
  }
}
```

Variables:

```json example=commerce.record-payment part=variables
{
  "input": {
    "invoiceId": "inv_01h455vb4pex5vsknk084sn02q",
    "amount": 1000,
    "currency": "EUR",
    "method": "BANK_TRANSFER",
    "reference": "BANK-2042",
    "receivedAt": "2026-10-22T09:00:00.000Z"
  }
}
```

Use the actual amount, currency, date received and reference. `recordPayment` stores the payment and updates the invoice's paid amount and payment state, together with the associated order's payment status. Read all three results. For a partial payment, send only the amount received.

The payment methods are `BANK_TRANSFER`, `CHEQUE`, `CARD`, `DIRECT_DEBIT`, `CREDIT_NOTE`, `CASH`, `OTHER`. A bank reference helps you identify a payment; check existing payments before retrying a request whose outcome you do not know.

For a credit against an invoice, use `issueCreditNote` and inspect the resulting credit document. To remove an incorrectly recorded payment, `deletePayment` removes that payment and recalculates the associated payment totals. It requires `payment:delete`; confirm the specific payment ID before using it. Deleting a payment record does not move money through a bank.

| Operation | Use it to |
| --- | --- |
| `Query.getInvoice` | Read invoice by identifier |
| `Query.listInvoices` | Browse invoices |
| `Query.getInvoiceLine` | Read invoice line by identifier |
| `Query.listInvoiceLines` | Browse invoice lines |
| `Query.getPayment` | Read payment by identifier |
| `Query.listPayments` | Browse payments |
| `Mutation.upsertInvoice` | Advanced: Create or update a invoice. Prefer create/update for routine changes. |
| `Mutation.createInvoice` | Create a new invoice |
| `Mutation.updateInvoice` | Change selected invoice fields |
| `Mutation.deleteInvoice` | Delete a invoice |
| `Mutation.upsertInvoiceLine` | Advanced: Create or update a invoice line. Prefer create/update for routine changes. |
| `Mutation.createInvoiceLine` | Create a new invoice line |
| `Mutation.updateInvoiceLine` | Change selected invoice line fields |
| `Mutation.deleteInvoiceLine` | Delete a invoice line |
| `Mutation.recordPayment` | Record an invoice payment and update the invoice and order payment totals together |
| `Mutation.deletePayment` | Remove a payment and recalculate the invoice and order payment totals |
| `Mutation.invoiceOrder` | Generate one invoice and its lines from an order |
| `Mutation.issueCreditNote` | Issue a credit note against an invoice |

## Suppliers and purchase orders

A supplier is a business you buy from. A purchase order records the materials or services you request from that supplier, and its lines record the quantities and prices. Supplier records are independent of customer records, even when the same business fills both roles.

### Find the configured supplier

Manage supplier records through IaC. For purchasing, browse the configured suppliers and choose the intended business:

```graphql example=commerce.browse-suppliers part=operation
query BrowseCommerceSuppliers($name: String!) {
  listSuppliers(first: 20, filter: {name: {contains: $name}}) {
    edges { node { id name code currency isActive } }
    pageInfo { endCursor hasNextPage }
  }
}
```

Variables:

```json example=commerce.browse-suppliers part=variables
{
  "name": "Riverbend"
}
```

Inspect the returned name, code, active flag and currency; a partial name search can match several suppliers. Keep the selected supplier ID. If the supplier is missing or its details need changing, update your IaC configuration before placing the purchase order.

### Create a purchase order and a line

Purchase order headers expose `upsertPurchaseOrder`, without a separate create/update pair. This is an exception to the usual create/update approach. Omit `id` to create a new header:

```graphql example=commerce.create-purchase-order part=operation
mutation CreateCommercePurchaseOrder($input: UpsertPurchaseOrderInput!) {
  upsertPurchaseOrder(input: $input) {
    purchaseOrder { id poNumber status totalAmount currency }
    errors { path message }
  }
}
```

Variables:

```json example=commerce.create-purchase-order part=variables
{
  "input": {
    "poNumber": "PO-2042",
    "supplierId": "sup_01h455vb4pex5vsknk084sn02q",
    "orderDate": "2026-10-21T09:00:00.000Z",
    "totalAmount": 500,
    "currency": "EUR"
  }
}
```

Use the returned purchase order ID to add the material being ordered:

```graphql example=commerce.add-purchase-order-line part=operation
mutation AddCommercePurchaseOrderLine($input: CreatePurchaseOrderLineInput!) {
  createPurchaseOrderLine(input: $input) {
    purchaseOrderLine { id description quantity unitPrice lineTotal }
    errors { path message }
  }
}
```

Variables:

```json example=commerce.add-purchase-order-line part=variables
{
  "input": {
    "purchaseOrderId": "pur_01h455vb4pex5vsknk084sn02q",
    "lineNumber": 1,
    "description": "Material for packaging sleeves",
    "quantity": 250,
    "unitPrice": 2,
    "lineTotal": 500,
    "currency": "EUR"
  }
}
```

The header and line are separate requests. Check both results and keep amounts and currency consistent. Use `updatePurchaseOrder` and `updatePurchaseOrderLine` for subsequent changes, including the supported fields for received quantities when goods actually arrive.

Purchase order status values are `DRAFT`, `SUBMITTED`, `ACKNOWLEDGED`, `PARTIALLY_RECEIVED`, `RECEIVED`, `CANCELLED`, `DISPUTED`. Recording a status or received quantity is a change to the purchasing record; do not assume that it also performs every stock or logistics action required by your workflow.

| Operation | Use it to |
| --- | --- |
| `Query.getSupplier` | Read supplier by identifier |
| `Query.listSuppliers` | Browse suppliers |
| `Query.getPurchaseOrder` | Read purchase order by identifier |
| `Query.listPurchaseOrders` | Browse purchase orders |
| `Query.getPurchaseOrderLine` | Read purchase order line by identifier |
| `Query.listPurchaseOrderLines` | Browse purchase order lines |
| `Mutation.upsertSupplier` | Manage through IaC: create or update a supplier |
| `Mutation.createSupplier` | Manage through IaC: create a new supplier |
| `Mutation.updateSupplier` | Manage through IaC: change selected supplier fields |
| `Mutation.deleteSupplier` | Manage through IaC: delete a supplier |
| `Mutation.upsertPurchaseOrder` | Create or update a purchase order |
| `Mutation.updatePurchaseOrder` | Change selected purchase order fields |
| `Mutation.deletePurchaseOrder` | Delete a purchase order |
| `Mutation.upsertPurchaseOrderLine` | Advanced: Create or update a purchase order line. Prefer create/update for routine changes. |
| `Mutation.createPurchaseOrderLine` | Create a new purchase order line |
| `Mutation.updatePurchaseOrderLine` | Change selected purchase order line fields |
| `Mutation.deletePurchaseOrderLine` | Delete a purchase order line |

## Products, pricing and tax

Products describe what you sell. Price brackets express quantity-based rates for a scope. Customer price overrides record agreed exceptions. Tax codes record rates and related tax information. Manage products, price brackets and tax codes through IaC. Read those configured records through the API when preparing a sale. Customer price overrides have runtime API operations for agreed customer-specific exceptions.

| Record | Type | What to consider |
| --- | --- | --- |
| Product | `ProductState` | SKU, description, active flag, base price and currency |
| Price bracket | `PriceBracketState` | Scope, quantity range, rate type, currency and validity dates |
| Customer price override | `CustomerPriceOverrideState` | Customer, scope, value, priority and effective dates |
| Tax code | `TaxCodeState` | Code, rate, jurisdiction and active/default flags |

Browse active products to choose the product record for an order or estimate line:

```graphql example=commerce.browse-products part=operation
query BrowseCommerceProducts($first: Int!) {
  listProducts(first: $first, filter: {isActive: {eq: true}}) {
    edges { node { id sku name productType basePrice currency } }
    pageInfo { endCursor hasNextPage }
  }
}
```

Variables:

```json example=commerce.browse-products part=variables
{
  "first": 20
}
```

Use the returned product ID when linking a line to a catalogue product. Inspect the line's own price, tax, discount and total fields: a product reference alone is not a promise that every amount will be filled in or recalculated.

Before changing a customer price override, read the existing entries for that customer or scope. Check overlapping effective dates and priorities. For product, bracket or tax-code changes, use IaC. The operation list below marks those writes explicitly; their presence in the API does not change the recommended provisioning route.

| Operation | Use it to |
| --- | --- |
| `Query.listCustomerPriceOverrides` | Browse customer price overrides |
| `Query.getTaxCode` | Read tax code by identifier |
| `Query.listTaxCodes` | Browse tax codes |
| `Query.getPriceBracket` | Read price bracket by identifier |
| `Query.listPriceBrackets` | Browse price brackets |
| `Query.getProduct` | Read product by identifier |
| `Query.listProducts` | Browse products |
| `Mutation.upsertCustomerPriceOverride` | Advanced: Create or update a customer price override. Prefer create/update for routine changes. |
| `Mutation.createCustomerPriceOverride` | Create a new customer price override |
| `Mutation.updateCustomerPriceOverride` | Change selected customer price override fields |
| `Mutation.deleteCustomerPriceOverride` | Delete a customer price override |
| `Mutation.upsertTaxCode` | Manage through IaC: create or update a tax code |
| `Mutation.createTaxCode` | Manage through IaC: create a new tax code |
| `Mutation.updateTaxCode` | Manage through IaC: change selected tax code fields |
| `Mutation.deleteTaxCode` | Manage through IaC: delete a tax code |
| `Mutation.upsertPriceBracket` | Manage through IaC: create or update a price bracket |
| `Mutation.createPriceBracket` | Manage through IaC: create a new price bracket |
| `Mutation.updatePriceBracket` | Manage through IaC: change selected price bracket fields |
| `Mutation.deletePriceBracket` | Manage through IaC: delete a price bracket |
| `Mutation.upsertProduct` | Manage through IaC: create or update a product |
| `Mutation.createProduct` | Manage through IaC: create a new product |
| `Mutation.updateProduct` | Manage through IaC: change selected product fields |
| `Mutation.deleteProduct` | Manage through IaC: delete a product |

## Business units and cost centres

Business units and cost centres describe your configured organisational structure. **Manage their creation, changes and deletion through IaC.** Read them through the API when you need their IDs, codes or details during commercial work.

The operations below are included because they belong to the commercial topics. The write operations are listed for discoverability, with the recommended IaC route made explicit; they are not steps in an API provisioning walkthrough.

| Operation | Use it to |
| --- | --- |
| `Query.getBusinessUnit` | Read business unit by identifier |
| `Query.getBusinessUnitBy` | Read business unit using a supplied lookup value |
| `Query.listBusinessUnits` | Browse business units |
| `Query.getCostCenter` | Read cost center by identifier |
| `Query.getCostCenterBy` | Read cost center using a supplied lookup value |
| `Query.listCostCenters` | Browse cost centers |
| `Mutation.upsertBusinessUnit` | Manage through IaC: create or update a business unit |
| `Mutation.createBusinessUnit` | Manage through IaC: create a new business unit |
| `Mutation.updateBusinessUnit` | Manage through IaC: change selected business unit fields |
| `Mutation.deleteBusinessUnit` | Manage through IaC: delete a business unit |
| `Mutation.upsertCostCenter` | Manage through IaC: create or update a cost center |
| `Mutation.createCostCenter` | Manage through IaC: create a new cost center |
| `Mutation.updateCostCenter` | Manage through IaC: change selected cost center fields |
| `Mutation.deleteCostCenter` | Manage through IaC: delete a cost center |

## Order fulfilment

An order's commercial status is useful only when it reflects the work actually completed. Use `orderFulfillmentCompletion` to inspect the current completion point and the assessment for an order:

```graphql example=commerce.check-fulfilment part=operation
query CheckCommerceFulfilment($orderId: OrderID!) {
  orderFulfillmentCompletion(orderId: $orderId) {
    orderId completionPoint outcome diagnosticSummary diagnostics
  }
}
```

Variables:

```json example=commerce.check-fulfilment part=variables
{
  "orderId": "ord_01h455vb4pex5vsknk084sn02q"
}
```

Read the outcome and diagnostics. The completion point is `DISPATCH`, `DELIVERY`. You can also request the per-item results when you need to understand which planned quantities are preventing completion.

When the order satisfies the required milestone, use `completeOrderFulfillment` with its order ID and the `expectedCompletionPoint` you just reviewed. That expectation protects the meaning of your request if the completion policy changes. The operation checks the current conditions; it does not invent dispatch or delivery activity to make the order complete.

Planning deliveries, recording dispatch and recording delivery are covered in [Warehousing and Logistics](/docs/data-model/logistics). This section covers the commercial assessment and completion of the order.

| Operation | Use it to |
| --- | --- |
| `Query.orderFulfillmentCompletion` | Check whether every order line meets the current completion milestone |
| `Mutation.completeOrderFulfillment` | Complete an order after checking its fulfilment milestone |

## Notes on commercial records

Use a note to preserve a conversation, instruction or explanation attached to a record. For example, attach a delivery instruction to an order:

```graphql example=commerce.add-order-note part=operation
mutation AddCommerceOrderNote($input: CreateEntityNoteInput!) {
  createEntityNote(input: $input) {
    entityNote { id targetId body version }
    errors { path message }
  }
}
```

Variables:

```json example=commerce.add-order-note part=variables
{
  "input": {
    "targetId": "ord_01h455vb4pex5vsknk084sn02q",
    "body": "Customer requested delivery after 10:00. Confirm the delivery appointment before dispatch."
  }
}
```

Keep the returned note ID and `version`. To edit or delete a note, provide its current version as `expectedVersion`. If someone else has changed it, read the latest note before deciding how to proceed.

Use `entityNotes` with a target filter to browse a record's notes, or `searchEntityNotes` to search the notes you may access. Source integration and key lookups help integrations find their own notes. Access follows the target record's permissions. Saving a note and making it available in search are separate concerns; a note can be saved even when its search indexing needs attention. `reindexEntityNotes` requests refreshed indexing for a bounded selection.

| Operation | Use it to |
| --- | --- |
| `Query.getEntityNote` | Read a note attached to a record you can access |
| `Query.getEntityNoteBySource` | Find a note by its source integration and key |
| `Query.entityNotes` | Browse notes on a record |
| `Query.searchEntityNotes` | Search notes you are allowed to read |
| `Mutation.createEntityNote` | Add a note to a record |
| `Mutation.updateEntityNote` | Edit a note using its current version |
| `Mutation.deleteEntityNote` | Delete a note using its current version |
| `Mutation.reindexEntityNotes` | Request refreshed search indexing for a selected set of notes |

## Examples and shared API tools

Each recipe has a stable name. Use `topicExamples(names: [...])` to retrieve its GraphQL document, variables and any illustrative response. For example, `commerce.create-order` is the order-and-lines recipe and `crm.find-customer` is the customer lookup. Fetching an example does not run it. Supply the returned variables with the returned document only when you are ready to perform that task.

The Commerce topic also exposes help, schema discovery, compact named queries and change subscriptions. These support working with the API; they are not additional commercial records. The IaC discovery and validation tools support infrastructure authoring, while `dataChangeEvents` allows a client to follow record changes subject to its access.

| Operation | Use it to |
| --- | --- |
| `Query.topicExamples` | Fetch named operations, example variables and illustrative responses |
| `Query.magicQuery` | Read data with a compact named query |
| `Query.getMagicQuerySchema` | Inspect the inputs and result of a compact named query |
| `Query.listMagicQueries` | Discover the available compact named queries |
| `Query.getMagicQueryGraphQL` | Inspect the GraphQL operation for a compact named query |
| `Query.listDataChangeEventTopics` | Discover record-change event topics |
| `Query.generateDefaultDataQuery` | Prepare a query for a record-change event |
| `Query.ping` | Check whether the API responds |
| `Query.iacSchema` | Read the infrastructure schema for IaC tooling |
| `Query.iacValidate` | Validate an IaC source document |
| `Query.iacGuides` | Read IaC authoring guides |
| `Query.helpChapters` | Discover help chapters |
| `Query.helpChapter` | Read a help chapter |
| `Query.searchHelp` | Find help by search term |
| `Query.schemaAuthoringCatalog` | Discover supported schema authoring features |
| `Query.compileSchemaXML` | Convert an authored XML schema to JSON Schema |
| `Query.renderSchemaXML` | Convert JSON Schema to authored XML |
| `Query.iacSchemaDocument` | Read a schema document used by IaC tooling |
| `Subscription.dataChangeEvents` | Subscribe to record-change events |

For the precise arguments, return types, input fields and enum values, continue to the [ERP API reference](/docs/data-model/erp-reference).