# Cart lines

Cart lines hold the products in a cart. Each line snapshots the product name and SKU at add time, stores the resolved unit price in minor units, and, for made-to-measure products, a full configuration snapshot. All three endpoints require the `commerce:write` scope, an `active` cart, and pass the cart ownership check (see the Carts page). Every endpoint returns the full cart envelope, the same shape as `GET /v1/api/commerce/carts/{id}`.

## Product capability rules

How a product is added depends on its `capability` (see [Products](/api/catalog/products)):

- **`standard` products with active variants**: `variant_id` is required and must be an active variant of the product.
- **`standard` products without variants**: `variant_id` must be omitted.
- **`made_to_measure` products**: `configuration` is required and `variant_id` is forbidden. The configuration is an object `{"dimensions": {...}, "choices": ["choice_id", ...]}` priced by the configurator engine; dimensions are whole numbers. The line stores a `config_hash` plus a full snapshot under `attributes.configuration` (dimensions, choices, price breakdown, component list, matrix cell). Component stock is reserved per configurator component.
- For `standard` products, `configuration` is forbidden.
- A product whose `capability_active` is `false` cannot be added: the commerce module sells `standard` products and the configurator module sells `made_to_measure` ones.

## Quantities

`quantity` is a JSON number greater than zero with at most 8 integer digits and at most 3 decimals (maximum `99999999.999`). Send it as a JSON number or as a plain decimal string with a dot (`"0.75"`); a string with a decimal comma or an exponent (`"0,75"`, `"1e3"`) is refused. A product with `unit` `piece` takes whole numbers only (`2` or `2.0`, never `2.5`); a product in any other unit (`kg`, `m2`, `hour`, ...) takes up to three decimals, for example `0.75`. Quantities come back as JSON numbers (`0.75`, `12`). A line total is the unit price times the quantity, rounded half up to whole minor units.

## Refusal codes

When a line cannot be added, the 422 body carries a machine-readable top-level `code` next to `message` and `errors`:

| `code` | Meaning |
| --- | --- |
| `CAPABILITY_INACTIVE` | The product's capability cannot be sold right now (`capability_active` is `false`): the module that sells it is disabled for the store. Reported on `errors.product_id`. |
| `INVALID_CONFIGURATION` | The variant or configuration does not fit the product: a variant is missing, unknown, or not allowed; a configuration is missing or sent for a `standard` product; or the configurator engine refused the configuration (its error keys, for example `dimensions.width`, are passed through). |
| `NOT_PURCHASABLE` | The product cannot be sold, for example it has no resolvable price. Reported on `errors.product_id`. |

```json title="Response (422, line refusal)"
{
  "message": "Width must be a whole number.",
  "errors": {
    "dimensions.width": ["Width must be a whole number."]
  },
  "code": "INVALID_CONFIGURATION"
}
```

Plain input validation (a missing or malformed `product_id` or `quantity`, an unknown or inactive product, a fractional quantity for a `piece` product) uses the standard 422 envelope without `code`. Branch on `code` when present, and fall back to `errors` otherwise.

Lines deduplicate on the combination of product, variant, and configuration hash: adding the same combination again merges into the existing line, summing the quantity and re-resolving the unit price at the new quantity tier.

Unit prices resolve against the request's pricing context: the customer identified by `X-Customer-Token` if present, else the cart's bound customer, else guest pricing.

## POST /v1/api/commerce/carts/{id}/lines

Adds a product to the cart, or merges into an existing identical line. Returns the full cart with status 201.

### Parameters

**Path**

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| `id` | string (UUID) | yes | Cart id. Must be an `active` cart. |

**Body**

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| `product_id` | string (UUID) | yes | The product to add. Must be an active product of the store. |
| `variant_id` | string (UUID) or null | conditional | Required when the product has active variants; forbidden otherwise, and always forbidden for `made_to_measure` products. |
| `quantity` | number | yes | Greater than zero, at most 3 decimals; a whole number for a `piece` product (see Quantities above). The merged line quantity may not exceed the policy's `max_quantity_per_line`. |
| `configuration` | object | conditional | Required for `made_to_measure` products, forbidden otherwise. Shape: `{"dimensions": {...}, "choices": [choice_ids]}`, dimensions as whole numbers. Validated by the configurator engine. |

**Headers**

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| `X-Customer-Token` | string | no | Selects the pricing context; required if the cart is bound to a non-guest customer. |

```bash title="Request"
curl -X POST https://api.borkol.com/v1/api/commerce/carts/9d3f2a10-6b7c-4e6d-9f21-8a54c1d2e3f4/lines \
  -H "Authorization: Bearer {api_key}" \
  -H "Content-Type: application/json" \
  -d '{
    "product_id": "1a2b3c4d-5e6f-4a7b-8c9d-0e1f2a3b4c5d",
    "variant_id": "2b3c4d5e-6f7a-4b8c-9d0e-1f2a3b4c5d6e",
    "quantity": 2
  }'
```

```javascript title="Request (JS)"
const res = await fetch(
  `https://api.borkol.com/v1/api/commerce/carts/${cartId}/lines`,
  {
    method: "POST",
    headers: {
      Authorization: `Bearer ${apiKey}`,
      "Content-Type": "application/json",
    },
    body: JSON.stringify({
      product_id: "1a2b3c4d-5e6f-4a7b-8c9d-0e1f2a3b4c5d",
      variant_id: "2b3c4d5e-6f7a-4b8c-9d0e-1f2a3b4c5d6e",
      quantity: 2,
    }),
  }
);
const { data: cart } = await res.json();
```

```json title="Response"
{
  "data": {
    "id": "9d3f2a10-6b7c-4e6d-9f21-8a54c1d2e3f4",
    "status": "active",
    "lines": [
      {
        "id": "5e6f7a8b-9c0d-4e1f-a2b3-c4d5e6f7a8b9",
        "product_id": "1a2b3c4d-5e6f-4a7b-8c9d-0e1f2a3b4c5d",
        "variant_id": "2b3c4d5e-6f7a-4b8c-9d0e-1f2a3b4c5d6e",
        "name": "Garden Chair",
        "sku": "CHAIR-GRN-M",
        "quantity": 2,
        "unit_price_amount": 2250,
        "unit_price": { "amount": 2250, "currency": "EUR" },
        "line_total": { "amount": 4500, "currency": "EUR" },
        "attributes": null
      }
    ],
    "...": "same cart shape as GET /v1/api/commerce/carts/{id}"
  }
}
```

For a `made_to_measure` product, the created line carries the configuration snapshot. `components[].qty` is an exact decimal string per configured unit (multiply by the line quantity for the stock reserved):

```json title="Response (made-to-measure line excerpt)"
{
  "attributes": {
    "configuration": {
      "dimensions": { "width": 120, "depth": 180 },
      "choices": ["c1d2e3f4-..."],
      "breakdown": [
        { "label": "Base", "amount": 18900 },
        { "label": "Motor: Motorised", "amount": 7500 }
      ],
      "components": [{ "product_id": "...", "qty": "2.16" }],
      "matrix": { "id": "...", "name": "Fabric blinds" }
    }
  }
}
```

### Errors

Line refusals are 422 errors with the message attached to the offending field in `errors` and a top-level `code` (see Refusal codes above):

| Status | Body | When |
| --- | --- | --- |
| 422 | `errors.product_id: ["Product is not available."]` | Product unknown or not active. No `code`. |
| 422 | `errors.product_id: ["Product is not available."]`, `code: "NOT_PURCHASABLE"` | The product exists and is active but cannot be sold, for example it has no resolvable price. |
| 422 | `errors.product_id: ["Product is not available."]`, `code: "CAPABILITY_INACTIVE"` | The product's capability is not active for the store (`capability_active` is `false`). |
| 422 | `errors.variant_id: ["Choose a variant for this product."]`, `code: "INVALID_CONFIGURATION"` | Product has active variants and `variant_id` was omitted. |
| 422 | `errors.variant_id: ["Variant is not available."]`, `code: "INVALID_CONFIGURATION"` | Variant id does not resolve to an active variant of the product. |
| 422 | `errors.variant_id: ["This product has no variants."]`, `code: "INVALID_CONFIGURATION"` | `variant_id` sent for a product without variants. |
| 422 | `errors.variant_id: ["Configurable products have no variants."]`, `code: "INVALID_CONFIGURATION"` | `variant_id` sent for a `made_to_measure` product. |
| 422 | `errors.configuration: ["Configure this product before adding it."]`, `code: "INVALID_CONFIGURATION"` | `made_to_measure` product without a `configuration`. |
| 422 | `errors.configuration: ["This product is not configurable."]`, `code: "INVALID_CONFIGURATION"` | `configuration` sent for a `standard` product. |
| 422 | `errors.choices`, `errors.choices.{choiceId}`, `errors.dimensions.{key}`, `errors.configuration`, `code: "INVALID_CONFIGURATION"` | Configurator engine errors, attached to their own field keys exactly as documented in the [quote error catalog](/api/configurator/quote), including `{Label} must be a whole number.` for a fractional dimension. |
| 422 | `errors.quantity: ["The quantity must be a whole number."]` | Fractional quantity for a `piece` product. No `code`. |
| 422 | `errors.quantity: ["..."]` | Quantity missing, not a plain decimal (comma, exponent, more than 3 decimals or 8 integer digits), or not greater than zero. No `code`. |
| 422 | `{"message": "Quantity exceeds the per-line maximum.", "code": "cart_constraint"}` | Merged quantity would exceed `max_quantity_per_line`. |
| 422 | `{"message": "Cart line limit reached.", "code": "cart_constraint"}` | Adding a new distinct line would exceed `max_lines`. |
| 404 | `{"message": "..."}` | Cart unknown, expired, or not `active`. |
| 403 | ownership, scope, module, or suspension body | See the Carts page. |
| 429 | `{"message": "Too Many Attempts."}` | Rate limit exceeded. |

## PATCH /v1/api/commerce/carts/{id}/lines/{lineId}

Changes a line's quantity, under the same quantity rules as adding (a whole number for a `piece` product). Non-configured product lines are re-priced at the new quantity (tier pricing) using the request's pricing context. Configured lines, and lines whose variant has since been deactivated, keep their stored unit price; checkout is where unavailability surfaces. Returns the full cart with status 200.

### Parameters

**Path**

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| `id` | string (UUID) | yes | Cart id. Must be an `active` cart. |
| `lineId` | string (UUID) | yes | Line id. Must belong to this cart. |

**Body**

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| `quantity` | number | yes | Greater than zero, at most 3 decimals, a whole number for a `piece` product; maximum the policy's `max_quantity_per_line`. |

**Headers**

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| `X-Customer-Token` | string | no | Selects the pricing context; required if the cart is bound to a non-guest customer. |

```bash title="Request"
curl -X PATCH https://api.borkol.com/v1/api/commerce/carts/9d3f2a10-6b7c-4e6d-9f21-8a54c1d2e3f4/lines/5e6f7a8b-9c0d-4e1f-a2b3-c4d5e6f7a8b9 \
  -H "Authorization: Bearer {api_key}" \
  -H "Content-Type: application/json" \
  -d '{"quantity": 3}'
```

```json title="Response"
{
  "data": {
    "id": "9d3f2a10-6b7c-4e6d-9f21-8a54c1d2e3f4",
    "lines": [{ "id": "5e6f7a8b-...", "quantity": 3, "...": "..." }],
    "...": "same cart shape as GET /v1/api/commerce/carts/{id}"
  }
}
```

### Errors

| Status | Body | When |
| --- | --- | --- |
| 422 | `{"message": "...", "errors": {"quantity": ["..."]}}` | Missing or malformed quantity, not greater than zero, or fractional for a `piece` product. This endpoint never answers with a line refusal `code`: a line that no longer resolves keeps its price until checkout. |
| 422 | `{"message": "Quantity exceeds the per-line maximum.", "code": "cart_constraint"}` | Above `max_quantity_per_line`. |
| 404 | `{"message": "..."}` | Cart unknown or not `active`, or the line does not belong to this cart. |
| 403 | ownership, scope, module, or suspension body | See the Carts page. |
| 429 | `{"message": "Too Many Attempts."}` | Rate limit exceeded. |

## DELETE /v1/api/commerce/carts/{id}/lines/{lineId}

Removes a line from the cart and releases any stock hold held for the cart. Returns the full cart, without the removed line, with status 200.

### Parameters

**Path**

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| `id` | string (UUID) | yes | Cart id. Must be an `active` cart. |
| `lineId` | string (UUID) | yes | Line id. Must belong to this cart. |

No body.

```bash title="Request"
curl -X DELETE https://api.borkol.com/v1/api/commerce/carts/9d3f2a10-6b7c-4e6d-9f21-8a54c1d2e3f4/lines/5e6f7a8b-9c0d-4e1f-a2b3-c4d5e6f7a8b9 \
  -H "Authorization: Bearer {api_key}"
```

```json title="Response"
{
  "data": {
    "id": "9d3f2a10-6b7c-4e6d-9f21-8a54c1d2e3f4",
    "lines": [],
    "...": "same cart shape as GET /v1/api/commerce/carts/{id}"
  }
}
```

### Errors

| Status | Body | When |
| --- | --- | --- |
| 404 | `{"message": "..."}` | Cart unknown or not `active`, or the line does not belong to this cart. |
| 403 | ownership, scope, module, or suspension body | See the Carts page. |
| 429 | `{"message": "Too Many Attempts."}` | Rate limit exceeded. |
