# Prijsofferte

Het quote-endpoint prijst één concrete configuratie. Roep het aan bij elke wijziging in de selectie van de koper om een live totaal te tonen. Het is read-only: er wordt geen cart of order aangemaakt, en het vereist alleen een leesscope: een key met ofwel `catalog:read` ofwel `commerce:read` wordt geaccepteerd.

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

Prijst een specifieke configuratie van een actief made-to-measure-product (capability `made_to_measure`). De server valideert de geselecteerde keuzes (bestaan, beschikbaarheid, maximaal één per `single`-groep, verplichte groepen gedekt, `requires`- en `excludes`-regels), daarna de dimensies (elke geconfigureerde dimensie aanwezig, een geheel getal, binnen zijn min en max na aanscherping door de constraints van de geselecteerde keuzes), bepaalt de basisprijs uit de winnende prijsmatrix (of de productprijs wanneer het product geen dimensies heeft), past de keuzedelta's toe in twee passes (eerst `fixed`, `per_area` en `percent`, daarna elke `percent_of_total` tegen dat subtotaal zonder samengestelde berekening), en retourneert het totaal met een regel-voor-regel uitsplitsing. Zie de hoofdstukintroductie voor het volledige algoritme.

### Path-parameters

| Naam | Type | Verplicht | Beschrijving |
| --- | --- | --- | --- |
| id | string (uuid) | Ja | Product-ID. Moet een actief product met capability `made_to_measure` zijn. |

### Body-parameters

De request body is JSON.

| Naam | Type | Verplicht | Beschrijving |
| --- | --- | --- | --- |
| dimensions | object | Voorwaardelijk | Map van dimensie-`key` (uit het schema) naar een geheel getal in de eenheid van de dimensie (bijvoorbeeld cm). Een waarde met decimalen wordt geweigerd met een 422 op `dimensions.{key}`. Verplicht zodra het product dimensies heeft: elke geconfigureerde dimensie moet aanwezig en numeriek zijn. Onbekende keys worden geweigerd met een 422. Mag alleen weggelaten worden bij een product zonder dimensies. |
| choices | array of string (uuid) | Nee | Geselecteerde optiekeuze-ID's. Duplicaten worden ontdubbeld. Elk element moet een UUID zijn die naar een beschikbare keuze van dit product verwijst, dus een keuze die het schema-endpoint publiceert. Verplichte groepen moeten gedekt zijn, en per `single`-groep is maximaal één keuze toegestaan. Laat weg of stuur een lege array alleen wanneer het product geen verplichte groepen heeft. |

```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"
    }
  }
}
```

### Responsevelden

| Naam | Type | Verplicht | Beschrijving |
| --- | --- | --- | --- |
| total | integer | altijd | Totaalprijs in minor units (centen). |
| currency | string | altijd | De ISO-valutacode van het product, bijvoorbeeld `"EUR"`. |
| breakdown | array | altijd | Regelitems. Begint altijd met `{"label":"Base","amount":<base>}`, gevolgd door één regel per geprijsde keuze met label `"{Group name}: {Choice name}"`, in volgorde van groep- en keuzepositie (met alle `percent_of_total`-regels na de andere). Keuzes met een delta van 0 verschijnen alsnog met `amount: 0`. Alle bedragen zijn integer minor units. |
| matrix | object or null | altijd | `{id, name}` van de prijsmatrix die de basisprijs leverde. `null` wanneer de basis uit de eigen prijs van het product kwam (een product zonder dimensies). |

### Fouten

Authenticatie- en infrastructuurfouten zijn hetzelfde als op de andere storefront-routes:

| Status | Body | Oorzaak |
| --- | --- | --- |
| 401 | `{"message":"Unauthenticated."}` | Ontbrekende of ongeldige API-key. |
| 403 | `{"message":"Invalid ability provided."}` | De key heeft noch de scope `catalog:read`, noch `commerce:read`. |
| 403 | `{"error":"Module not enabled: configurator"}` | De tenant heeft de configurator-module niet ingeschakeld. Let op de key `error` in plaats van `message`. |
| 403 | `{"message":"Tenant suspended.","code":"tenant_suspended"}` | Het tenant-account is geschorst. |
| 404 | `{"message":"..."}` | Het product bestaat niet, is niet actief, of de capability is niet `made_to_measure`. |
| 429 | `{"message":"Too Many Attempts."}` | Rate limit van 120 requests per minuut overschreden. |

### 422-validatiefouten

Alle validatiefouten gebruiken de standaard Laravel-envelope: `{"message":"<first error>","errors":{"<field>":["<message>", ...]}}`, zonder `code` op het hoogste niveau. Dat wijkt af van een cart-add, die dezelfde configuratiefouten meldt met `code` `INVALID_CONFIGURATION` op het hoogste niveau (zie [Cartregels](/nl/api/carts-and-checkout/cart-lines)). Er zijn twee lagen.

**Invoervalidatie** (de vorm van de request body): `dimensions` moet een object zijn, `choices` moet een array zijn, en elk `choices.*`-element moet een UUID zijn. Voorbeeld:

```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."]
  }
}
```

**Domeinvalidatie** (de configuratie zelf): dezelfde envelope; elke foutkey bevat precies één melding. Keuzefouten worden eerst gecontroleerd en kortsluiten de rest: zodra een keuzefout optreedt, worden dimensies in die response niet gevalideerd. De mogelijke keys en meldingen:

| Foutkey | Melding | Oorzaak |
| --- | --- | --- |
| `choices` | `One or more choices are not available.` | Een keuze-ID bestaat niet op dit product, of de keuze is niet beschikbaar: inactief, verwijderd, of zonder vervulbare `requires`-doelgroep. Het bericht is bewust generiek en noemt de niet-beschikbare keuze nooit bij naam. |
| `choices` | `Choose one {Group}.` | Twee of meer keuzes geselecteerd in een groep van type `single`. |
| `choices` | `{Group} is required.` | Een verplichte groep heeft geen geselecteerde keuze. |
| `choices` | `Area-priced choice needs two dimensions.` | Een `per_area`-keuze is geselecteerd op een product dat niet precies twee dimensies heeft. |
| `choices.{choiceId}` | `{Choice} cannot be combined with {Target}.` | Een `excludes`-regel is overtreden. |
| `choices.{choiceId}` | `{Choice} requires one of: {names}.` | Aan een `requires`-regel is niet voldaan (geen van de targets in één targetgroep is geselecteerd). `{names}` noemt alleen keuzes die het schema voor dit product publiceert, dus een naam in dit bericht is altijd een keuze die de koper kan selecteren. |
| `dimensions.{key}` | `{Label} is required.` | De dimensie ontbreekt in het request of de waarde is niet numeriek. |
| `dimensions.{key}` | `{Label} must be a whole number.` | De waarde heeft decimalen, bijvoorbeeld `120.5`. |
| `dimensions.{key}` | `{Label} must be between {min} and {max} {unit}.` | De waarde valt buiten het bereik van de dimensie. |
| `dimensions.{key}` | `{Label} must be between {min} and {max} {unit} when {choice names} is selected.` | De waarde valt buiten het bereik na aanscherping door de constraints van de geselecteerde keuzes. |
| `dimensions.{key}` | `{Label} has no valid range for the selected choices ({names}).` | De constraints van de geselecteerde keuzes laten geen bereik over (effectief minimum is groter dan effectief maximum). |
| `dimensions.{key}` | `Unknown dimension.` | Het request bevat een dimensiekey die niet op het product is geconfigureerd. |
| `configuration` | `not_priceable` | Er kon geen basisprijs worden bepaald: een product zonder dimensies heeft een `null`-prijs, geen enkele prijsmatrix heeft conditions waaraan de geselecteerde keuzes voldoen, of de winnende matrix heeft geen prijsregel die de dimensiewaarden dekt. Een matrix met een condition op een keuze die niet beschikbaar is, kan nooit vervuld worden en levert dus nooit een basisprijs; het schema toont zo'n matrix ook niet. |

Voorbeeld van een domeinvalidatiefout:

```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."
    ]
  }
}
```

Let op: `requires_quote` op een keuze veroorzaakt hier nooit een fout: het endpoint prijst zulke configuraties gewoon. De vlag, beschikbaar in het schema, signaleert alleen dat de storefront de koper naar een handmatige offerteaanvraag hoort te leiden in plaats van naar directe checkout.
