# Sepetler

Sepet, bir storefront'un checkout öncesinde adım adım oluşturduğu değiştirilebilir ön-sipariş nesnesidir. Her sepet tek bir mağazaya aittir, UUID bir id'ye sahiptir ve her zaman `EUR` para birimiyle ve 30 günlük geçerlilik süresiyle oluşturulur. Sepete yapılan her yazma işlemi (satır ekleme, e-posta değiştirme, indirim kodu ekleme) geçerlilik süresini 30 gün daha uzatır ve sepetin son etkinlik zaman damgasını günceller. Süresi dolan sepetler fiziksel olarak silinir; süresi dolmuş bir sepet id'si 404 döner.

Tüm sepet endpoint'leri `Authorization: Bearer {api_key}` header'ını gerektirir. Okuma endpoint'leri `commerce:read` scope'una, yazma endpoint'leri `commerce:write` scope'una ihtiyaç duyar ve commerce modülünün mağaza için etkin olması gerekir. Dakikada 120 request'lik tenant rate limit'i geçerlidir.

## Sepet yaşam döngüsü ve durumu

| Durum | Anlamı |
| --- | --- |
| `active` | Sepet yazma işlemlerini kabul eder. Bir sepetin oluşturulduğu tek durum budur. |
| `converted` | Sepet bir siparişe (checkout) veya teklife (teklif isteği) dönüştü. Salt okunur. |
| `merged` | Sepet bir claim sırasında başka bir sepetle birleştirildi. Salt okunur. |

Yazma endpoint'leri sepeti `status = active` koşuluyla arar; bu nedenle `converted` veya `merged` durumundaki bir sepet id'si her yazma işleminde 404 döner. İstisnalar, herhangi bir durumdaki sepeti döndüren `GET /v1/api/commerce/carts/{id}` ve zaten dönüştürülmüş bir sepet için mevcut siparişi döndüren `POST /v1/api/commerce/carts/{id}/checkout`'tur (idempotent tekrar).

## Sahiplik: X-Customer-Token header'ı

Sepetler anonim başlar. Opsiyonel `X-Customer-Token` header'ı (customers modülü tarafından verilen) opak bir müşteri oturum token'ı taşır ve sepet route'larını üç şekilde etkiler:

1. **Fiyatlandırma.** Mevcut ve geçerli olduğunda fiyatlar o müşterinin fiyatlandırma bağlamında çözümlenir (müşteri grubu fiyatlandırması, B2B fiyat gösterimi). Yokluğunda misafir fiyatlandırması uygulanır.
2. **Sahiplik.** Bir sepetin `customer_id` alanı doluysa ve bu müşteri gerçek (misafir olmayan) bir hesapsa, sepet kapsamındaki her request'in aynı müşteriye çözümlenen bir `X-Customer-Token` taşıması gerekir. Aksi halde request `403 {"message": "This cart belongs to a customer."}` ile başarısız olur. `customer_id` alanı null olan sepetler açık kalır; e-posta yakalamayla oluşturulmuş bir misafir müşteri kaydına bağlı sepetler de öyle (misafirlerin sunacak token'ı yoktur). Customers modülü kullanılamıyorsa kontrol aynı 403 ile güvenli tarafta (kapalı) başarısız olur.
3. **Bağlama.** `POST /v1/api/commerce/carts/{id}/claim` bu header'ı zorunlu kılar ve checkout sırasında geçerli bir token ortaya çıkan siparişi müşteriye bağlar.

Mevcut olup çözümlenemeyen bir token; checkout, teklif isteği ve sipariş geçmişinde `401 {"message": "Invalid customer token."}`, claim'de ise düz bir 401 döner. Diğer sepet route'larında çözümlenemeyen bir token, sepet bir müşteriye bağlı olmadığı sürece (o durumda sahiplik 403'ü uygulanır) sessizce misafir fiyatlandırmasına düşer.

## Sepet response gövdesi

Her sepet endpoint'i (ve birkaç sepet satırı ve indirim endpoint'i) aynı zarfı döndürür: `{"data": {...cart...}}`. Alanlar:

| Ad | Tür | Zorunlu | Açıklama |
| --- | --- | --- | --- |
| `id` | string (UUID) | her zaman | Sepet id'si. İstemci tarafında saklayın; ziyaretçinin tek erişim anahtarıdır. |
| `currency` | string | her zaman | Her zaman `EUR`. |
| `status` | string | her zaman | `active`, `converted` veya `merged`. |
| `email` | string veya null | her zaman | `PATCH /carts/{id}` ile yakalanan e-posta, yoksa null. |
| `metadata` | object veya null | her zaman | `PATCH /carts/{id}` ile ayarlanan serbest biçimli skaler map, yoksa null. |
| `discount_code` | string veya null | her zaman | Eklenen kod, olduğu gibi saklanır, yoksa null. |
| `discount_code_error` | string veya null | her zaman | Kod uygulanabildiğinde (veya kod ayarlanmadığında) null. Aksi halde şunlardan biri: `invalid`, `expired`, `min_order`, `not_eligible`, `usage_limit`. |
| `discounts` | array | her zaman | Uygulanan indirimler, her biri `{id, name, code, kind, amount}`. `kind` şunlardan biridir: `order`, `products`, `shipping`, `bogo`; otomatik indirimlerde `code` null'dur; `amount` minör birim cinsinden bir tamsayıdır. |
| `policy` | object | her zaman | Mağazanın sepet politikası, aşağıya bakın. |
| `requires_quote` | boolean | her zaman | Sepet şu anda bir teklif tetikleyicisine takılıyorsa ve checkout `QUOTE_REQUIRED` ile reddedecekse true. Mağazada sales modülü devre dışıysa veya teklif talepleri kapalıysa her zaman false. |
| `quote_reasons` | array of strings | her zaman | Aktif tetikleyici nedenleri: `cart_threshold`, `configurator_choice`, `customer_group`, `product_purchasability`. `requires_quote` false olduğunda boştur. |
| `lines` | array | her zaman | Sepet satırları, aşağıya bakın. |
| `totals` | object | her zaman | Sepet toplamları, aşağıya bakın. |

### `policy`

Tenant ayarlarından canlı olarak değerlendirilen, mağazanın sepet politikası. Storefront'lar arayüzü buna göre oluşturmalıdır (örneğin misafirler için fiyatları gizlemek, checkout öncesinde giriş zorunlu kılmak).

| Ad | Tür | Zorunlu | Açıklama |
| --- | --- | --- | --- |
| `identity_mode` | string | her zaman | `guest_allowed`, `login_required` veya `guest_only`. |
| `audience` | string | her zaman | `both`, `b2b_only` veya `b2c_only`. |
| `min_order_value_amount` | integer veya null | her zaman | Checkout için gereken minör birim cinsinden minimum sepet toplamı, minimum yoksa null. |
| `max_lines` | integer | her zaman | Maksimum ayrı satır sayısı. Varsayılan 100. |
| `max_quantity_per_line` | integer | her zaman | Satır başına maksimum adet. Varsayılan 100. |
| `pricing` | object | her zaman | Geçerli fiyatlandırma bağlamı için fiyat gösterim bloğu. |
| `pricing.guest_price_visibility` | string | her zaman | `visible` veya `hidden`. |
| `pricing.vat_display` | string | her zaman | `inc` veya `ex`. B2B müşteriler için otomatik olarak `ex` değerine çözümlenir. |
| `pricing.show_original_price` | boolean | her zaman | İndirimli fiyatın yanında indirim öncesi fiyatın gösterilip gösterilmeyeceği. |
| `pricing.show_tier_table` | boolean | her zaman | Adet bazlı kademeli fiyat tablolarının gösterilip gösterilmeyeceği. |

### `lines[]`

| Ad | Tür | Zorunlu | Açıklama |
| --- | --- | --- | --- |
| `id` | string (UUID) | her zaman | Satır id'si. |
| `product_id` | string (UUID) | her zaman | Ürün. |
| `variant_id` | string (UUID) veya null | her zaman | Ürünün varyantları olduğunda varyant. |
| `name` | string | her zaman | Satır eklendiği anda alınan ürün adı anlık görüntüsü. |
| `sku` | string veya null | her zaman | SKU anlık görüntüsü. |
| `quantity` | number | her zaman | Adet, sıfırdan büyük. `piece` olarak satılan bir ürün için tam sayı; diğer tüm birimler için en fazla üç ondalık basamak (örneğin 0,75 kg için `0.75`). |
| `unit_price_amount` | integer | her zaman | Minör birim (kuruş/cent) cinsinden birim fiyat. |
| `unit_price` | object | her zaman | Aynı birim fiyatın para nesnesi hali: `{"amount": int, "currency": "EUR"}`. |
| `line_total` | object | her zaman | İndirim dağıtımı sonrası satır toplamı para nesnesi. Birim fiyat çarpı adet olarak hesaplanır ve yarım yukarı yuvarlanarak tam küçük birime getirilir. |
| `attributes` | object veya null | her zaman | Düz satırlar için null. Made-to-measure ürünlerde tam `configuration` anlık görüntüsünü tutar (boyutlar, seçimler, döküm, bileşenler, matris). Her `components[]` öğesi `{product_id, qty}` şeklindedir; `qty`, yapılandırılmış birim başına kesin bir ondalık string'dir (örneğin `"2.16"`). |

### `totals`

Tüm para değerleri, `amount` alanı minör birim cinsinden olmak üzere `{"amount": int, "currency": "EUR"}` nesneleridir. Fiyatların vergi içerip içermediği bir mağaza ayarıdır (`prices_include_tax`, varsayılan true).

| Ad | Tür | Zorunlu | Açıklama |
| --- | --- | --- | --- |
| `subtotal` | money | her zaman | Vergi hariç ürün toplamı. |
| `tax_total` | money | her zaman | Toplam vergi. |
| `total` | money | her zaman | Genel toplam. |
| `discount_total` | money | her zaman | Uygulanan toplam ürün indirimi. |
| `tax_breakdown` | array | her zaman | Oran bazında döküm; her biri `{"rate": float, "base": int, "tax": int}`, `base` ve `tax` minör birim cinsindendir. |

## POST /v1/api/commerce/carts

Yeni ve boş bir sepet oluşturur. `commerce:write` gerektirir. Request body yoktur ve müşteri token'ı gerekmez: sepetler anonim başlar. Sepet `EUR` para birimiyle, `active` durumuyla ve 30 günlük bir `expires_at` ile oluşturulur. Dönen `id` değerini istemci tarafında saklayın (cookie veya local storage); sepete erişimin tek anahtarı budur.

### Parametreler

Yok.

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

Response durumu 201'dir.

### Hatalar

| Durum | Gövde | Ne zaman |
| --- | --- | --- |
| 401 | `{"message": "Unauthenticated."}` | Eksik veya geçersiz API anahtarı. |
| 403 | `{"message": "Invalid ability provided."}` | Anahtarda `commerce:write` yok. |
| 403 | `{"error": "Module not enabled: commerce"}` | Commerce modülü mağaza için devre dışı. |
| 403 | `{"message": "Tenant suspended.", "code": "tenant_suspended"}` | Mağaza askıya alınmış. |
| 429 | `{"message": "Too Many Attempts."}` | Rate limit aşıldı. |

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

Bir sepeti satırları, canlı değerlendirilen indirimleri, toplamları, mağazanın sepet politikası bloğu ve güncel teklif tetikleyici durumuyla birlikte getirir. `commerce:read` gerektirir. Yazma endpoint'lerinin aksine, bu endpoint herhangi bir durumdaki (`active`, `converted`, `merged`) sepet için çalışır. İndirimler ve `discount_code_error` her okumada yeniden değerlendirilir; bu nedenle sepeti render etmek için doğruluk kaynağı bu endpoint'tir. Sepet sahipliği uygulanır (yukarıya bakın).

### Parametreler

**Path**

| Ad | Tür | Zorunlu | Açıklama |
| --- | --- | --- | --- |
| `id` | string (UUID) | evet | Sepet id'si. |

**Header'lar**

| Ad | Tür | Zorunlu | Açıklama |
| --- | --- | --- | --- |
| `X-Customer-Token` | string | hayır | Sepet misafir olmayan bir müşteriye bağlıysa zorunludur. Ayrıca gösterilen fiyatlar için müşteri grubu fiyatlandırmasını seçer. |

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

### Hatalar

| Durum | Gövde | Ne zaman |
| --- | --- | --- |
| 404 | `{"message": "..."}` | Bilinmeyen id, süresi dolmuş (temizlenmiş) sepet veya başka bir mağazaya ait sepet. |
| 403 | `{"message": "This cart belongs to a customer."}` | Sepet bir müşteriye bağlı ve token eksik veya farklı bir müşteriye çözümleniyor. |
| 403 | scope, modül veya askıya alma gövdesi | Bkz. POST /carts. |
| 429 | `{"message": "Too Many Attempts."}` | Rate limit aşıldı. |

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

Sepetin e-postasını ve/veya metadata'sını günceller. `commerce:write` gerektirir; sepet `active` olmalıdır; sahiplik uygulanır. `email` ayarlandığında değer küçük harfe çevrilir ve customers modülü aktifse bir misafir müşteri kaydı bulunur veya oluşturulur ve sepet ona bağlanır (ya da geçerli bir `X-Customer-Token` sahipliği kanıtlıyorsa token'ın hesabına bağlanır). Bu bağlama, terk edilmiş sepet kurtarmanın temelidir; misafire bağlı bir sepet token olmadan erişilebilir kalır.

### Parametreler

**Path**

| Ad | Tür | Zorunlu | Açıklama |
| --- | --- | --- | --- |
| `id` | string (UUID) | evet | Sepet id'si. |

**Body**

| Ad | Tür | Zorunlu | Açıklama |
| --- | --- | --- | --- |
| `email` | string | hayır | Geçerli e-posta adresi, en fazla 255 karakter. Küçük harfe çevrilerek saklanır. |
| `metadata` | object veya null | hayır | Serbest biçimli map, en fazla 16 anahtar. Her değer bir skaler (string, number, boolean) veya null olmalıdır. Temizlemek için null gönderin. |

Her iki alan da `sometimes` kurallıdır: bir alanı atlamak onu değiştirmeden bırakır.

**Header'lar**

| Ad | Tür | Zorunlu | Açıklama |
| --- | --- | --- | --- |
| `X-Customer-Token` | string | hayır | Sepet misafir olmayan bir müşteriye bağlıysa zorunludur. |

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

Response, `GET /v1/api/commerce/carts/{id}` ile şekil olarak birebir aynı tam sepet zarfıdır ve durum kodu 200'dür.

### Hatalar

| Durum | Gövde | Ne zaman |
| --- | --- | --- |
| 422 | `{"message": "...", "errors": {"email": ["The email field must be a valid email address."]}}` | Doğrulama hatası (geçersiz e-posta, çok fazla metadata anahtarı, skaler olmayan metadata değeri). |
| 404 | `{"message": "..."}` | Sepet bilinmiyor, süresi dolmuş veya `active` değil. |
| 403 | `{"message": "This cart belongs to a customer."}` | Sahiplik kontrolü başarısız. |
| 403 | scope, modül veya askıya alma gövdesi | Bkz. POST /carts. |
| 429 | `{"message": "Too Many Attempts."}` | Rate limit aşıldı. |
