# Checkout

Checkout zet een `active` cart om in een order met status `pending_payment`. Het is het strengste endpoint in de cart-flow: het hervalideert elke regel, bepaalt elke prijs opnieuw, controleert voorraad en gebruikslimieten van kortingen opnieuw, en dwingt de cart policy en quote-triggers van de store af. Een geslaagde checkout markeert de cart als `converted` en zet een orderbevestigingsmail in de wachtrij.

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

Vereist `commerce:write`. Wat er gebeurt, op volgorde:

1. **Eigenaarschap.** Inline afgedwongen in plaats van via middleware, zodat idempotente replay van een reeds geconverteerde cart is vrijgesteld: een `active` cart gebonden aan een niet-guest klant vereist de bijbehorende `X-Customer-Token`, anders `403 {"message": "This cart belongs to a customer."}`. Guest-gebonden carts (via e-mailregistratie) blijven open.
2. **Validatie.** Adressen en, indien van toepassing, de verzendmethode worden gevalideerd (zie de body-tabel).
3. **Customer token.** Als `X-Customer-Token` aanwezig is moet het herleidbaar zijn, anders `401 {"message": "Invalid customer token."}`. Een geldig token koppelt de resulterende order aan die klant.
4. **Quote-triggers.** Als de store de sales-module heeft ingeschakeld met offerteaanvragen aan, en de cart een trigger activeert, weigert checkout met `409 {"code": "QUOTE_REQUIRED", "reasons": [...]}`. De cart moet dan via de quote-request-flow. De cart-responsevelden `requires_quote` en `quote_reasons` maken dit vooraf zichtbaar.
5. **Cart policy.** De cart policy van de tenant wordt afgedwongen; overtredingen geven een 422 met een machineleesbare `code` (zie fouten).
6. **Voorraad.** De voorraad wordt opnieuw gecontroleerd en bij de start van de checkout wordt een reservering (hold) vastgelegd voor maximaal 60 minuten; bij succes wordt de reservering omgezet in een echte afboeking.
7. **Herprijzen.** Elke regel wordt gehervalideerd (product actief, capability nog actief, variant actief, configuratie nog geldig) en opnieuw geprijsd. Elke afwijking of onbeschikbaarheid breekt af met een 409 cart conflict; de ververste prijzen worden in de cart opgeslagen zodat de storefront opnieuw kan renderen en het opnieuw kan proberen.
8. **Kortingen.** Gebruikslimieten worden onder een lock opnieuw gecontroleerd; een uitgeputte korting breekt af met een 409 cart conflict met `discount_unavailable`.
9. **Orderaanmaak.** De order wordt aangemaakt met een per store oplopend integer `number`, de cart wordt `converted`, checkout-funnelgebeurtenissen worden geregistreerd, en een bevestigingsmail wordt in de wachtrij gezet.

**Verzendvereiste.** `shipping_method_id` is alleen verplicht wanneer beide voorwaarden gelden: de cart bevat minstens één regel waarvan het product `requires_shipping: true` heeft, en de store heeft minstens één actieve verzendmethode. Anders is het veld optioneel en wordt het genegeerd. Carts met uitsluitend producten zonder verzending (downloads, licenties, diensten) vereisen het nooit.

**Idempotentie.** Checkout opnieuw POSTen voor een reeds geconverteerde cart geeft de bestaande order terug met status 200 (een eerste succes geeft 201). Een unieke constraint op het cart-id vangt gelijktijdige dubbele submits af. Bij replay wordt de body nog steeds gevalideerd op de adresvelden, maar `shipping_method_id` is niet verplicht en de eigenaarschapscheck op het token wordt overgeslagen.

### Parameters

**Pad**

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

**Body**

| Naam | Type | Verplicht | Beschrijving |
| --- | --- | --- | --- |
| `email` | string | ja | Geldig e-mailadres voor de order. |
| `billing_address` | object | ja | Factuuradres. |
| `billing_address.name` | string | ja | Volledige naam. |
| `billing_address.street` | string | ja | Straat en huisnummer. |
| `billing_address.postal_code` | string | ja | Postcode. |
| `billing_address.city` | string | ja | Plaats. |
| `billing_address.country` | string | ja | Exact 2 tekens (ISO 3166-1 alpha-2). |
| `shipping_address` | object of null | nee | Vrij adresobject; dezelfde vorm als `billing_address` wordt aanbevolen. Null of weggelaten betekent verzenden naar het factuuradres. |
| `shipping_method_id` | string (UUID) | conditioneel | Verplicht wanneer de cart verzendbare regels heeft en de store minstens één actieve verzendmethode heeft. Moet het id van een actieve verzendmethode zijn. Anders optioneel en genegeerd. |

**Headers**

| Naam | Type | Verplicht | Beschrijving |
| --- | --- | --- | --- |
| `X-Customer-Token` | string | nee | Indien aanwezig moet het herleidbaar zijn (anders 401). Koppelt de order aan de klant en is vereist wanneer de cart gebonden is aan een niet-guest klant. |

```bash title="Request"
curl -X POST https://api.borkol.com/v1/api/commerce/carts/9d3f2a10-6b7c-4e6d-9f21-8a54c1d2e3f4/checkout \
  -H "Authorization: Bearer {api_key}" \
  -H "Content-Type: application/json" \
  -d '{
    "email": "jane@example.com",
    "billing_address": {
      "name": "Jane Doe",
      "street": "Keizersgracht 1",
      "postal_code": "1015 CC",
      "city": "Amsterdam",
      "country": "NL"
    },
    "shipping_address": null,
    "shipping_method_id": "4d5e6f7a-8b9c-4d0e-1f2a-3b4c5d6e7f8a"
  }'
```

```javascript title="Request (JS)"
const res = await fetch(
  `https://api.borkol.com/v1/api/commerce/carts/${cartId}/checkout`,
  {
    method: "POST",
    headers: {
      Authorization: `Bearer ${apiKey}`,
      "Content-Type": "application/json",
    },
    body: JSON.stringify({
      email: "jane@example.com",
      billing_address: {
        name: "Jane Doe",
        street: "Keizersgracht 1",
        postal_code: "1015 CC",
        city: "Amsterdam",
        country: "NL",
      },
      shipping_address: null,
      shipping_method_id: "4d5e6f7a-8b9c-4d0e-1f2a-3b4c5d6e7f8a",
    }),
  }
);
if (res.status === 409) {
  const conflict = await res.json();
  // conflict.code === "QUOTE_REQUIRED" or conflict.message === "Cart contents changed."
}
const { data: order } = await res.json();
```

```json title="Response"
{
  "data": {
    "id": "7f8a9b0c-1d2e-4f3a-b4c5-d6e7f8a9b0c1",
    "number": 1042,
    "status": "pending_payment",
    "email": "jane@example.com",
    "billing_address": {
      "name": "Jane Doe",
      "street": "Keizersgracht 1",
      "postal_code": "1015 CC",
      "city": "Amsterdam",
      "country": "NL"
    },
    "shipping_address": null,
    "currency": "EUR",
    "lines": [
      {
        "id": "8a9b0c1d-2e3f-4a4b-c5d6-e7f8a9b0c1d2",
        "product_id": "1a2b3c4d-5e6f-4a7b-8c9d-0e1f2a3b4c5d",
        "variant_id": "2b3c4d5e-6f7a-4b8c-9d0e-1f2a3b4c5d6e",
        "variant_label": "Green / M",
        "name": "Garden Chair",
        "sku": "CHAIR-GRN-M",
        "quantity": 2,
        "unit": "piece",
        "unit_price": { "amount": 2250, "currency": "EUR" },
        "line_subtotal": { "amount": 3347, "currency": "EUR" },
        "tax_rate": 21,
        "tax": { "amount": 703, "currency": "EUR" },
        "line_total": { "amount": 4050, "currency": "EUR" },
        "attributes": { "returnable": true, "lead_time_days": null }
      }
    ],
    "subtotal": { "amount": 3347, "currency": "EUR" },
    "tax_total": { "amount": 789, "currency": "EUR" },
    "total": { "amount": 4545, "currency": "EUR" },
    "tax_breakdown": [{ "rate": 21, "base": 3756, "tax": 789 }],
    "discount_total": { "amount": 0, "currency": "EUR" },
    "discount_breakdown": null,
    "shipping_method_name": "Standard",
    "shipping": { "amount": 409, "currency": "EUR" },
    "shipping_tax": { "amount": 86, "currency": "EUR" },
    "tracking_carrier": null,
    "tracking_number": null,
    "invoice_number": null,
    "invoiced_at": null,
    "customer_id": null,
    "contact_id": null,
    "placed_at": "2026-08-14T10:15:30+00:00",
    "created_at": "2026-08-14T10:15:30+00:00",
    "invoice_id": null
  }
}
```

Status 201 bij een nieuwe order, 200 bij idempotente replay. De volledige veldreferentie van orders staat op de pagina Orders; de vorm hier is identiek.

### De 409 cart-conflict-response

Wanneer het herprijzen een afwijking detecteert, een regel onbeschikbaar is geworden, er voorraadtekort is, of de gebruikslimiet van een korting is uitgeput, breekt checkout af met status 409:

```json title="Response (409 cart conflict)"
{
  "message": "Cart contents changed.",
  "cart": {
    "id": "9d3f2a10-6b7c-4e6d-9f21-8a54c1d2e3f4",
    "lines": [
      {
        "id": "5e6f7a8b-9c0d-4e1f-a2b3-c4d5e6f7a8b9",
        "unavailable": false,
        "...": "same line shape as GET /v1/api/commerce/carts/{id}"
      }
    ],
    "...": "same cart shape as GET /v1/api/commerce/carts/{id}"
  },
  "out_of_stock": [
    {
      "product_id": "1a2b3c4d-5e6f-4a7b-8c9d-0e1f2a3b4c5d",
      "variant_id": null,
      "requested": 2,
      "available": 1
    }
  ],
  "discount_unavailable": [
    {
      "discount_id": "71c9e0aa-2f4b-4d31-9a77-0b1c2d3e4f5a",
      "name": "Welcome 10%",
      "code": "WELCOME10"
    }
  ]
}
```

`cart` is de volledige cart-resource met de ververste (al opgeslagen) prijzen, plus een extra boolean `unavailable` op elke regel. Een regel is onbeschikbaar wanneer hij niet meer te bepalen is: product ingetrokken, variant gedeactiveerd, configuratie niet meer geldig, of de capability niet meer actief. `out_of_stock[].requested` en `.available` zijn JSON-getallen en kunnen decimalen hebben voor producten die per gewicht, lengte, oppervlakte of volume verkocht worden. `out_of_stock` en `discount_unavailable` zijn alleen aanwezig wanneer ze van toepassing zijn. De juiste clientreactie is de cart opnieuw renderen vanuit `cart`, de klant laten bevestigen, en checkout opnieuw POSTen.

### Fouten

| Status | Body | Wanneer |
| --- | --- | --- |
| 422 | `{"message": "...", "errors": {...}}` | Validatiefout op adres of e-mail; ook `Cart is empty.` en `Choose a valid shipping method.` |
| 422 | `{"message": "Log in to complete this order.", "code": "login_required"}` | Overtreding van de cart policy. Andere codes: `guest_only`, `b2b_approval_required`, `b2c_only`, `min_order_value`. |
| 401 | `{"message": "Invalid customer token."}` | `X-Customer-Token` aanwezig maar niet herleidbaar. |
| 403 | `{"message": "This cart belongs to a customer."}` | Actieve klantgebonden cart zonder het token van de eigenaar. |
| 409 | `{"code": "QUOTE_REQUIRED", "reasons": ["product_purchasability"]}` | De cart moet via de quote-request-flow. Redenen: `cart_threshold`, `configurator_choice`, `customer_group`, `product_purchasability`. Alleen wanneer de store de sales-module heeft ingeschakeld met offerteaanvragen aan. |
| 409 | `{"message": "Cart contents changed.", "cart": {...}, ...}` | Prijsafwijking, onbeschikbare regel, voorraadtekort of uitgeputte korting (zie hierboven). |
| 404 | `{"message": "..."}` | Onbekend cart-id of een cart van een andere store. |
| 403 | scope-, module- of schorsingsbody | Zie de pagina Carts. |
| 429 | `{"message": "Too Many Attempts."}` | Rate limit overschreden. |
