# Price quote

The quote endpoint prices one concrete configuration. Call it on every change to the buyer's selection to show a live total. It is read-only: no cart or order is created, and it requires only the `commerce:read` scope.

## POST /v1/api/commerce/configurator/products/{id}/quote

Prices a specific configuration of an active configurable product. The server validates the selected choices (existence, active, at most one per `single` group, required groups covered, `requires` and `excludes` rules), then the dimensions (every configured dimension present, integer, inside its min and max after tightening by the selected choices' constraints), resolves the base price from the winning price matrix (or the product price when the product has no dimensions), applies choice deltas in two passes (`fixed`, `per_area`, and `percent` first, then each `percent_of_total` against that subtotal without compounding), and returns the total with a line-item breakdown. See the chapter introduction for the full algorithm.

### Path parameters

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| id | string (uuid) | Yes | Product ID. Must be an active product of type `configurable`. |

### Body parameters

The request body is JSON.

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| dimensions | object | Conditional | Map of dimension `key` (from the schema) to integer value in the dimension's unit (for example cm). Required whenever the product has dimensions: every configured dimension must be present and numeric. Unknown keys are rejected with a 422. May be omitted only for a product with no dimensions. |
| choices | array of string (uuid) | No | Selected option choice IDs. Duplicates are de-duplicated. Each element must be a UUID referencing an active choice of this product. Required groups must be covered, and at most one choice per `single` group is allowed. Omit or send an empty array only when the product has no required groups. |

```bash title="Request"
curl -X POST "https://api.borkol.com/v1/api/commerce/configurator/products/9c2f1e6a-8b4d-4f2a-9e11-3d5a7b9c0e21/quote" \
  -H "Authorization: Bearer {api_key}" \
  -H "Accept: application/json" \
  -H "Content-Type: application/json" \
  -d '{
    "dimensions": { "width": 420, "depth": 300 },
    "choices": [
      "c1a2b3c4-d5e6-4f7a-8b9c-0d1e2f3a4b5c",
      "f6a7b8c9-d0e1-4f2a-b3c4-d5e6f7a8b9c0"
    ]
  }'
```

```javascript title="Request (JS)"
const res = await fetch(
  `https://api.borkol.com/v1/api/commerce/configurator/products/${productId}/quote`,
  {
    method: "POST",
    headers: {
      Authorization: `Bearer ${apiKey}`,
      Accept: "application/json",
      "Content-Type": "application/json",
    },
    body: JSON.stringify({
      dimensions: { width: 420, depth: 300 },
      choices: [
        "c1a2b3c4-d5e6-4f7a-8b9c-0d1e2f3a4b5c",
        "f6a7b8c9-d0e1-4f2a-b3c4-d5e6f7a8b9c0",
      ],
    }),
  }
);
if (res.status === 422) {
  const { errors } = await res.json();
  // errors is a map of field => [message]; render per-field.
} else {
  const { data } = await res.json();
}
```

```json title="Response"
{
  "data": {
    "total": 561500,
    "currency": "EUR",
    "breakdown": [
      { "label": "Base", "amount": 530000 },
      { "label": "LED lighting: LED set (6)", "amount": 31500 }
    ],
    "matrix": {
      "id": "d4e5f6a7-b8c9-4d0e-9f1a-2b3c4d5e6f7a",
      "name": "Glass roof"
    }
  }
}
```

### Response fields

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| total | integer | always | Total price in minor units (cents). |
| currency | string | always | The product's ISO currency code, for example `"EUR"`. |
| breakdown | array | always | Line items. Always starts with `{"label":"Base","amount":<base>}`, followed by one line per priced choice labeled `"{Group name}: {Choice name}"`, in group and choice position order (with all `percent_of_total` lines after the others). Choices with a delta of 0 still appear with `amount: 0`. All amounts are integer minor units. |
| matrix | object or null | always | `{id, name}` of the price matrix that supplied the base price. `null` when the base came from the product's own price (a product without dimensions). |

### Errors

Authentication and infrastructure errors are the same as on the other storefront routes:

| Status | Body | Cause |
| --- | --- | --- |
| 401 | `{"message":"Unauthenticated."}` | Missing or invalid API key. |
| 403 | `{"message":"Invalid ability provided."}` | Key lacks the `commerce:read` scope. |
| 403 | `{"error":"Module not enabled: commerce"}` | Tenant does not have the commerce module enabled. Note the `error` key instead of `message`. |
| 403 | `{"message":"Tenant suspended.","code":"tenant_suspended"}` | Tenant account is suspended. |
| 404 | `{"message":"..."}` | Product does not exist, is not active, or is not of type `configurable`. |
| 429 | `{"message":"Too Many Attempts."}` | Rate limit of 120 requests per minute exceeded. |

### 422 validation errors

All validation failures use the standard Laravel envelope: `{"message":"<first error>","errors":{"<field>":["<message>", ...]}}`. There are two layers.

**Input validation** (shape of the request body): `dimensions` must be an object, `choices` must be an array, and every `choices.*` element must be a UUID. Example:

```json title="Response (422, input validation)"
{
  "message": "The choices.0 field must be a valid UUID.",
  "errors": {
    "choices.0": ["The choices.0 field must be a valid UUID."]
  }
}
```

**Domain validation** (the configuration itself): the same envelope; each error key carries exactly one message. Choice errors are checked first and short-circuit: when any choice error occurs, dimensions are not validated in that response. The possible keys and messages:

| Error key | Message | Cause |
| --- | --- | --- |
| `choices` | `One or more choices are not available.` | A choice ID does not exist on this product, or the choice is inactive or deleted. |
| `choices` | `Choose one {Group}.` | Two or more choices selected in a `single` type group. |
| `choices` | `{Group} is required.` | A required group has no selected choice. |
| `choices` | `Area-priced choice needs two dimensions.` | A `per_area` choice was selected on a product that does not have exactly two dimensions. |
| `choices.{choiceId}` | `{Choice} cannot be combined with {Target}.` | An `excludes` rule is violated. |
| `choices.{choiceId}` | `{Choice} requires one of: {names}.` | A `requires` rule is unmet (none of the targets in one target group is selected). |
| `dimensions.{key}` | `{Label} is required.` | The dimension is missing from the request or its value is not numeric. |
| `dimensions.{key}` | `{Label} must be between {min} and {max} {unit}.` | The value is outside the dimension's range. |
| `dimensions.{key}` | `{Label} must be between {min} and {max} {unit} when {choice names} is selected.` | The value is outside the range after tightening by the selected choices' constraints. |
| `dimensions.{key}` | `{Label} has no valid range for the selected choices ({names}).` | The selected choices' constraints collapse the range (effective min exceeds effective max). |
| `dimensions.{key}` | `Unknown dimension.` | The request contains a dimension key that is not configured on the product. |
| `configuration` | `not_priceable` | No base price could be resolved: a product without dimensions has a `null` price, no price matrix's conditions are satisfied by the selected choices, or the winning matrix has no price rule covering the dimension values. |

Example domain validation failure:

```json title="Response (422, domain validation)"
{
  "message": "Width must be between 200 and 600 cm when Glass is selected.",
  "errors": {
    "dimensions.width": [
      "Width must be between 200 and 600 cm when Glass is selected."
    ]
  }
}
```

Note that `requires_quote` on a choice never causes an error here: the endpoint prices such configurations normally. The flag, exposed in the schema, only signals that the storefront should route the buyer to a manual quote request instead of direct checkout.
