# Carts

Een cart is het muteerbare pre-order-object dat een storefront opbouwt vóór de checkout. Elke cart hoort bij één store, heeft een UUID als id en wordt altijd aangemaakt in valuta `EUR` met een vervaltermijn van 30 dagen. Elke schrijfactie op een cart (regels toevoegen, e-mail wijzigen, een kortingscode koppelen) verlengt de vervaltermijn met nog eens 30 dagen en werkt de last-activity-timestamp van de cart bij. Verlopen carts worden fysiek verwijderd; een verlopen cart-id geeft een 404.

Alle cart-endpoints vereisen de header `Authorization: Bearer {api_key}`. Lees-endpoints hebben de scope `commerce:read` nodig, schrijf-endpoints `commerce:write`, en de commerce-module moet voor de store zijn ingeschakeld. De tenant rate limit van 120 requests per minuut is van toepassing.

## Levenscyclus en status van een cart

| Status | Betekenis |
| --- | --- |
| `active` | De cart accepteert schrijfacties. Dit is de enige status waarin een cart ooit wordt aangemaakt. |
| `converted` | De cart is een order geworden (checkout) of een offerte (quote request). Alleen-lezen. |
| `merged` | De cart is bij een claim samengevoegd met een andere cart. Alleen-lezen. |

Schrijf-endpoints zoeken de cart op met `status = active`, dus een `converted` of `merged` cart-id geeft bij elke schrijfactie een 404. De uitzonderingen zijn `GET /v1/api/commerce/carts/{id}`, dat een cart in elke status teruggeeft, en `POST /v1/api/commerce/carts/{id}/checkout`, dat voor een reeds geconverteerde cart de bestaande order teruggeeft (idempotente replay).

## Eigenaarschap: de X-Customer-Token header

Carts beginnen anoniem. De optionele header `X-Customer-Token` bevat een opaak klantsessietoken (uitgegeven door de customers-module) en beïnvloedt cart-routes op drie manieren:

1. **Prijsbepaling.** Wanneer aanwezig en geldig worden prijzen bepaald in de pricingcontext van die klant (klantgroepprijzen, B2B-prijsweergave). Zonder token geldt guest pricing.
2. **Eigenaarschap.** Als het `customer_id` van een cart is gezet en die klant een echt (niet-guest) account is, moet elke cart-gebonden request een `X-Customer-Token` meesturen dat naar diezelfde klant herleidt. Anders faalt de request met `403 {"message": "This cart belongs to a customer."}`. Carts met `customer_id` null blijven open, net als carts die gebonden zijn aan een guest-klantrij die via e-mailregistratie is aangemaakt (guests hebben geen token om te tonen). Als de customers-module niet beschikbaar is, faalt de check gesloten met dezelfde 403.
3. **Binding.** `POST /v1/api/commerce/carts/{id}/claim` vereist de header, en bij checkout koppelt een geldig token de resulterende order aan de klant.

Een token dat aanwezig is maar niet herleidbaar geeft `401 {"message": "Invalid customer token."}` bij checkout, quote request en orderhistorie, en een kale 401 bij claim. Op de overige cart-routes valt een onherleidbaar token simpelweg terug op guest pricing, tenzij de cart klantgebonden is (dan geldt de eigenaarschaps-403).

## De cart-responsestructuur

Elk cart-endpoint (en meerdere cart-line- en discount-endpoints) geeft dezelfde envelope terug: `{"data": {...cart...}}`. Velden:

| Naam | Type | Verplicht | Beschrijving |
| --- | --- | --- | --- |
| `id` | string (UUID) | altijd | Cart-id. Bewaar dit client-side; het is de handle van de bezoeker. |
| `currency` | string | altijd | Altijd `EUR`. |
| `status` | string | altijd | `active`, `converted` of `merged`. |
| `email` | string of null | altijd | E-mail vastgelegd via `PATCH /carts/{id}`, anders null. |
| `metadata` | object of null | altijd | Vrije scalar-map gezet via `PATCH /carts/{id}`, anders null. |
| `discount_code` | string of null | altijd | De gekoppelde code, letterlijk opgeslagen, anders null. |
| `discount_code_error` | string of null | altijd | Null wanneer de code van toepassing is (of er geen code is gezet). Anders een van `invalid`, `expired`, `min_order`, `not_eligible`, `usage_limit`. |
| `discounts` | array | altijd | Toegepaste kortingen, elk `{id, name, code, kind, amount}`. `kind` is een van `order`, `products`, `shipping`, `bogo`; `code` is null voor automatische kortingen; `amount` is een integer in minor units. |
| `policy` | object | altijd | De cart policy van de store, zie hieronder. |
| `requires_quote` | boolean | altijd | True wanneer de cart op dit moment een quote-trigger activeert en checkout zal weigeren met `QUOTE_REQUIRED`. Altijd false wanneer de winkel de sales-module uit heeft staan of offerteaanvragen heeft uitgeschakeld. |
| `quote_reasons` | array van strings | altijd | Actieve triggerredenen: `cart_threshold`, `configurator_choice`, `customer_group`, `product_purchasability`. Leeg wanneer `requires_quote` false is. |
| `lines` | array | altijd | Cartregels, zie hieronder. |
| `totals` | object | altijd | Carttotalen, zie hieronder. |

### `policy`

De cart policy van de store, live geëvalueerd op basis van de tenant-instellingen. Storefronts horen hun weergave hierop te baseren (bijvoorbeeld prijzen verbergen voor guests, inloggen vereisen vóór checkout).

| Naam | Type | Verplicht | Beschrijving |
| --- | --- | --- | --- |
| `identity_mode` | string | altijd | `guest_allowed`, `login_required` of `guest_only`. |
| `audience` | string | altijd | `both`, `b2b_only` of `b2c_only`. |
| `min_order_value_amount` | integer of null | altijd | Minimaal carttotaal in minor units dat vereist is om af te rekenen, of null voor geen minimum. |
| `max_lines` | integer | altijd | Maximaal aantal afzonderlijke regels. Standaard 100. |
| `max_quantity_per_line` | integer | altijd | Maximale hoeveelheid per regel. Standaard 100. |
| `pricing` | object | altijd | Prijsweergaveblok voor de huidige pricingcontext. |
| `pricing.guest_price_visibility` | string | altijd | `visible` of `hidden`. |
| `pricing.vat_display` | string | altijd | `inc` of `ex`. Wordt voor B2B-klanten automatisch `ex`. |
| `pricing.show_original_price` | boolean | altijd | Of de oorspronkelijke prijs naast een afgeprijsde prijs getoond moet worden. |
| `pricing.show_tier_table` | boolean | altijd | Of staffelprijstabellen per hoeveelheid getoond moeten worden. |

### `lines[]`

| Naam | Type | Verplicht | Beschrijving |
| --- | --- | --- | --- |
| `id` | string (UUID) | altijd | Regel-id. |
| `product_id` | string (UUID) | altijd | Het product. |
| `variant_id` | string (UUID) of null | altijd | De variant, wanneer het product varianten heeft. |
| `name` | string | altijd | Snapshot van de productnaam op het moment dat de regel werd toegevoegd. |
| `sku` | string of null | altijd | SKU-snapshot. |
| `quantity` | number | altijd | Hoeveelheid, groter dan nul. Een geheel getal voor een product dat per `piece` verkocht wordt; tot drie decimalen voor elke andere eenheid (bijvoorbeeld `0.75` voor 0,75 kg). |
| `unit_price_amount` | integer | altijd | Stukprijs in minor units (centen). |
| `unit_price` | object | altijd | Dezelfde stukprijs als money-object `{"amount": int, "currency": "EUR"}`. |
| `line_total` | object | altijd | Regeltotaal als money-object, na toewijzing van kortingen. Berekend als stukprijs maal hoeveelheid, half naar boven afgerond op hele minor units. |
| `attributes` | object of null | altijd | Null voor gewone regels. Voor made-to-measure-producten bevat dit de volledige `configuration`-snapshot (dimensions, choices, breakdown, components, matrix). Elk item in `components[]` is `{product_id, qty}` met `qty` als exacte decimale string (bijvoorbeeld `"2.16"`) per geconfigureerde eenheid. |

### `totals`

Alle geldwaarden zijn objecten `{"amount": int, "currency": "EUR"}` met `amount` in minor units. Of prijzen inclusief btw zijn is een store-instelling (`prices_include_tax`, standaard true).

| Naam | Type | Verplicht | Beschrijving |
| --- | --- | --- | --- |
| `subtotal` | money | altijd | Goederentotaal exclusief btw. |
| `tax_total` | money | altijd | Totale btw. |
| `total` | money | altijd | Eindtotaal. |
| `discount_total` | money | altijd | Totaal toegepaste goederenkorting. |
| `tax_breakdown` | array | altijd | Uitsplitsing per tarief, elk `{"rate": float, "base": int, "tax": int}` met `base` en `tax` in minor units. |

## POST /v1/api/commerce/carts

Maakt een nieuwe lege cart aan. Vereist `commerce:write`. Er is geen request body en er is geen customer token nodig: carts beginnen anoniem. De cart wordt aangemaakt met valuta `EUR`, status `active` en een `expires_at` van 30 dagen. Bewaar de teruggegeven `id` client-side (cookie of local storage); het is de enige handle naar de cart.

### Parameters

Geen.

```bash title="Request"
curl -X POST https://api.borkol.com/v1/api/commerce/carts \
  -H "Authorization: Bearer {api_key}"
```

```javascript title="Request (JS)"
const res = await fetch("https://api.borkol.com/v1/api/commerce/carts", {
  method: "POST",
  headers: { Authorization: `Bearer ${apiKey}` },
});
const { data: cart } = await res.json();
// persist cart.id
```

```json title="Response"
{
  "data": {
    "id": "9d3f2a10-6b7c-4e6d-9f21-8a54c1d2e3f4",
    "currency": "EUR",
    "status": "active",
    "email": null,
    "metadata": null,
    "discount_code": null,
    "discount_code_error": null,
    "discounts": [],
    "policy": {
      "identity_mode": "guest_allowed",
      "audience": "both",
      "min_order_value_amount": null,
      "max_lines": 100,
      "max_quantity_per_line": 100,
      "pricing": {
        "guest_price_visibility": "visible",
        "vat_display": "inc",
        "show_original_price": false,
        "show_tier_table": true
      }
    },
    "requires_quote": false,
    "quote_reasons": [],
    "lines": [],
    "totals": {
      "subtotal": { "amount": 0, "currency": "EUR" },
      "tax_total": { "amount": 0, "currency": "EUR" },
      "total": { "amount": 0, "currency": "EUR" },
      "discount_total": { "amount": 0, "currency": "EUR" },
      "tax_breakdown": []
    }
  }
}
```

De responsestatus is 201.

### Fouten

| Status | Body | Wanneer |
| --- | --- | --- |
| 401 | `{"message": "Unauthenticated."}` | Ontbrekende of ongeldige API key. |
| 403 | `{"message": "Invalid ability provided."}` | Key mist `commerce:write`. |
| 403 | `{"error": "Module not enabled: commerce"}` | Commerce-module uitgeschakeld voor de store. |
| 403 | `{"message": "Tenant suspended.", "code": "tenant_suspended"}` | Store geschorst. |
| 429 | `{"message": "Too Many Attempts."}` | Rate limit overschreden. |

## GET /v1/api/commerce/carts/{id}

Haalt een cart op met regels, live geëvalueerde kortingen, totalen, het cart-policyblok van de store en de huidige quote-triggerstatus. Vereist `commerce:read`. Anders dan schrijf-endpoints werkt dit voor een cart in elke status (`active`, `converted`, `merged`). Kortingen en `discount_code_error` worden bij elke read opnieuw geëvalueerd, dus dit endpoint is de bron van waarheid voor het weergeven van de cart. Cart-eigenaarschap wordt afgedwongen (zie hierboven).

### Parameters

**Pad**

| Naam | Type | Verplicht | Beschrijving |
| --- | --- | --- | --- |
| `id` | string (UUID) | ja | Cart-id. |

**Headers**

| Naam | Type | Verplicht | Beschrijving |
| --- | --- | --- | --- |
| `X-Customer-Token` | string | nee | Vereist als de cart gebonden is aan een niet-guest klant. Bepaalt daarnaast klantgroepprijzen voor de getoonde prijzen. |

```bash title="Request"
curl https://api.borkol.com/v1/api/commerce/carts/9d3f2a10-6b7c-4e6d-9f21-8a54c1d2e3f4 \
  -H "Authorization: Bearer {api_key}"
```

```json title="Response"
{
  "data": {
    "id": "9d3f2a10-6b7c-4e6d-9f21-8a54c1d2e3f4",
    "currency": "EUR",
    "status": "active",
    "email": "jane@example.com",
    "metadata": { "source": "web" },
    "discount_code": "WELCOME10",
    "discount_code_error": null,
    "discounts": [
      {
        "id": "71c9e0aa-2f4b-4d31-9a77-0b1c2d3e4f5a",
        "name": "Welcome 10%",
        "code": "WELCOME10",
        "kind": "order",
        "amount": 450
      }
    ],
    "policy": {
      "identity_mode": "guest_allowed",
      "audience": "both",
      "min_order_value_amount": null,
      "max_lines": 100,
      "max_quantity_per_line": 100,
      "pricing": {
        "guest_price_visibility": "visible",
        "vat_display": "inc",
        "show_original_price": false,
        "show_tier_table": true
      }
    },
    "requires_quote": false,
    "quote_reasons": [],
    "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": 4050, "currency": "EUR" },
        "attributes": null
      }
    ],
    "totals": {
      "subtotal": { "amount": 3347, "currency": "EUR" },
      "tax_total": { "amount": 703, "currency": "EUR" },
      "total": { "amount": 4050, "currency": "EUR" },
      "discount_total": { "amount": 450, "currency": "EUR" },
      "tax_breakdown": [{ "rate": 21, "base": 3347, "tax": 703 }]
    }
  }
}
```

### Fouten

| Status | Body | Wanneer |
| --- | --- | --- |
| 404 | `{"message": "..."}` | Onbekend id, verlopen (opgeruimde) cart, of een cart van een andere store. |
| 403 | `{"message": "This cart belongs to a customer."}` | Cart is klantgebonden en het token ontbreekt of herleidt naar een andere klant. |
| 403 | scope-, module- of schorsingsbody | Zie POST /carts. |
| 429 | `{"message": "Too Many Attempts."}` | Rate limit overschreden. |

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

Werkt de e-mail en/of metadata van de cart bij. Vereist `commerce:write`; de cart moet `active` zijn; eigenaarschap wordt afgedwongen. Bij het zetten van `email` wordt deze naar kleine letters omgezet en, wanneer de customers-module actief is, wordt een guest-klantrij gevonden of aangemaakt en de cart daaraan gebonden (of gebonden aan het account van het token wanneer een geldige `X-Customer-Token` het eigenaarschap aantoont). Deze binding is de basis voor abandoned-cart-herstel; een guest-gebonden cart blijft toegankelijk zonder token.

### Parameters

**Pad**

| Naam | Type | Verplicht | Beschrijving |
| --- | --- | --- | --- |
| `id` | string (UUID) | ja | Cart-id. |

**Body**

| Naam | Type | Verplicht | Beschrijving |
| --- | --- | --- | --- |
| `email` | string | nee | Geldig e-mailadres, max 255 tekens. Opgeslagen in kleine letters. |
| `metadata` | object of null | nee | Vrije map, max 16 keys. Elke waarde moet een scalar zijn (string, number, boolean) of null. Geef null door om te wissen. |

Beide velden zijn `sometimes`-regels: een weggelaten veld blijft ongewijzigd.

**Headers**

| Naam | Type | Verplicht | Beschrijving |
| --- | --- | --- | --- |
| `X-Customer-Token` | string | nee | Vereist als de cart gebonden is aan een niet-guest klant. |

```bash title="Request"
curl -X PATCH https://api.borkol.com/v1/api/commerce/carts/9d3f2a10-6b7c-4e6d-9f21-8a54c1d2e3f4 \
  -H "Authorization: Bearer {api_key}" \
  -H "Content-Type: application/json" \
  -d '{"email": "jane@example.com", "metadata": {"utm_source": "newsletter"}}'
```

```json title="Response"
{
  "data": {
    "id": "9d3f2a10-6b7c-4e6d-9f21-8a54c1d2e3f4",
    "email": "jane@example.com",
    "metadata": { "utm_source": "newsletter" },
    "...": "same cart shape as GET /v1/api/commerce/carts/{id}"
  }
}
```

De response is de volledige cart-envelope, identiek van vorm aan `GET /v1/api/commerce/carts/{id}`, met status 200.

### Fouten

| Status | Body | Wanneer |
| --- | --- | --- |
| 422 | `{"message": "...", "errors": {"email": ["The email field must be a valid email address."]}}` | Validatiefout (ongeldige e-mail, te veel metadata-keys, niet-scalaire metadata-waarde). |
| 404 | `{"message": "..."}` | Cart onbekend, verlopen of niet `active`. |
| 403 | `{"message": "This cart belongs to a customer."}` | Eigenaarschapscheck mislukt. |
| 403 | scope-, module- of schorsingsbody | Zie POST /carts. |
| 429 | `{"message": "Too Many Attempts."}` | Rate limit overschreden. |
