# Teklif token'ları

Satıcı bir teklif gönderdiğinde, alıcı düz 48 karakterlik bir kabul token'ı içeren bir e-posta alır. Sunucu tarafında bu token'ın yalnızca SHA-256 hash'i saklanır; dolayısıyla e-postadaki bağlantı tek kimlik bilgisidir ve token'ı yeniden türetmenin bir yolu yoktur. Storefront, bağlantıdaki token'ı alıp bu üç endpoint'i süren bir teklif sayfası barındırır. `X-Customer-Token` işin içinde değildir: erişim kanıtı kabul token'ının kendisidir.

Bir teklif hem bir storefront sepetinden hem de doğrudan satıcı tarafından yazılarak ortaya çıkabilir (aşağıdaki `origin`'e bakın) — bu üç endpoint her iki durumu da aynı şekilde sunar. Her endpoint iki özdeş yolda kullanılabilir: `/v1/api/sales/quotes/by-token/{token}` ve `/v1/api/commerce/quotes/by-token/{token}`. İkisi de `sales` modülünün etkin olmasını gerektirir ve bir API anahtarının **ya** `sales:read`/`sales:write` yetenek ailesini **ya da** eski `commerce:read`/`commerce:write` ailesini taşımasını kabul eder — bkz. aşağıdaki Scopes. Bu sayfadaki örnekler `sales` yolunu kullanır; her biri `commerce` yolunda da aynı şekilde çalışır.

Token sayfasının davranışı, mağazanın kabul modasına (`acceptance_mode`, GET yanıtına bakın) bağlıdır: `click` modunda kabul için ek bir girdi gerekmez; `signature` modunda kabul için çizilmiş bir imza gerekir. `POST .../accept` ve `POST .../decline`, çağıranın IP'siyle anahtarlanarak **dakikada 30 istek** ile sınırlandırılmıştır.

Üç endpoint de önce tembel (lazy) süre dolumu uygular: teklif `offered` durumundaysa ve `valid_until` geçmişse, request işlenmeden önce durumu `expired` olur. Bu nedenle süre dolumundan sonraki bir okuma `"status": "expired"` döner; kabul veya ret `QUOTE_EXPIRED` ile başarısız olur.

Mağazanın hiçbir teklifiyle eşleşmeyen bir token 404 döner.

## GET /v1/api/sales/quotes/by-token/{token}

### Alias: `/v1/api/commerce/quotes/by-token/{token}`

Modül ayrımından önce oluşturulmuş entegrasyonlar için kalıcı olarak korunan, özdeş rota, request ve response.

Bu token'a ait teklifi getirir. `sales:read` (veya `commerce:read`) gerektirir. Müşteriye dönük teklif serileştirmesini döner (`merchant_note`, `cart_id`, `customer_id`, `contact_id`, `document_type_id`, `template_id`, `billing_address_id`, `work_address_id` ve `invoice_id` kaldırılır; token veya hash'i asla body'de yer almaz). Teklif tutarları money nesnesi değil, minör birim cinsinden düz tamsayılardır.

### Parametreler

**Path**

| Ad | Tür | Zorunlu | Açıklama |
| --- | --- | --- | --- |
| `token` | string | evet | Teklif e-postasındaki düz 48 karakterlik token. |

```bash title="Request"
curl https://api.borkol.com/v1/api/sales/quotes/by-token/{token} \
  -H "Authorization: Bearer {api_key}"
```

```json title="Response"
{
  "data": {
    "id": "6e7f8a9b-0c1d-4e2f-a3b4-c5d6e7f8a9b0",
    "number": "Q-17",
    "status": "offered",
    "email": "buyer@corp.example",
    "name": "Bob Buyer",
    "phone": "+31 6 12345678",
    "billing_address": {
      "name": "Corp BV",
      "street": "Main St 1",
      "postal_code": "1000 AA",
      "city": "Amsterdam",
      "country": "NL"
    },
    "shipping_address": null,
    "currency": "EUR",
    "customer_note": "Need delivery before October.",
    "message_to_customer": "Volume price applied.",
    "valid_until": "2026-08-31T23:59:59+00:00",
    "subtotal_amount": 44421,
    "tax_total_amount": 9329,
    "shipping_total_amount": 0,
    "total_amount": 53750,
    "tax_breakdown": [{ "rate": 21, "base": 44421, "tax": 9329 }],
    "converted_order_id": null,
    "submitted_at": "2026-08-14T10:15:30+00:00",
    "offered_at": "2026-08-15T09:00:00+00:00",
    "accepted_at": null,
    "declined_at": null,
    "cancelled_at": null,
    "created_at": "2026-08-14T10:15:30+00:00",
    "updated_at": "2026-08-15T09:00:00+00:00",
    "origin": "merchant",
    "intro": "Thanks for the opportunity to quote this project.",
    "terms": "Prices valid for 30 days. 50% deposit on acceptance.",
    "acceptance_mode": "click",
    "pdf_url": "https://storage.borkol.com/private/....pdf?signature=...",
    "signed_at": null,
    "signed_pdf_url": null,
    "scope_items": [
      {
        "id": "b1c2d3e4-5f6a-4b7c-8d9e-0f1a2b3c4d5e",
        "category": "included",
        "label": "Delivery within 10 business days",
        "included": true,
        "sort_order": 0
      }
    ],
    "media_ids": ["a1b2c3d4-5e6f-4a7b-8c9d-0e1f2a3b4c5d"],
    "lines": [
      {
        "id": "9b0c1d2e-3f4a-4b5c-d6e7-f8a9b0c1d2e3",
        "product_id": "1a2b3c4d-5e6f-4a7b-8c9d-0e1f2a3b4c5d",
        "variant_id": null,
        "is_custom": false,
        "title": "Garden Chair",
        "sku": "CHAIR-GRN",
        "quantity": 25,
        "indicative_unit_price": 2250,
        "quoted_unit_price": 2150,
        "unit_price": 2150,
        "tax_class_id": null,
        "attributes": null,
        "sort_order": 0,
        "kind": "product",
        "unit": "pcs",
        "description": null,
        "tax_rate": "21.00"
      }
    ]
  }
}
```

`lines[].kind`, `product`, `service`, `material` veya `other` olabilir. `lines[].unit` bir birim etiketidir: ürün satırlarında ürünün birimi (adet olarak satılan ürün için `pcs`, aksi halde örneğin `kg` veya `m2`), diğer satırlarda serbest metin (örneğin `hour`). Birimler kaydedilmeye başlamadan önce yazılmış satırlarda `null` olabilir. `lines[].description`, satır düzeyinde isteğe bağlı bir nottur. `lines[].tax_rate`, gönderim anında anlık görüntüsü alınan ondalık vergi oranıdır (bir string olarak, örneğin `"21.00"`), `tax_class_id`'den bağımsızdır. `quantity` bir sayıdır ve kesirli olabilir (örneğin `1.5`), en fazla üç ondalık basamakla; adet olarak satılan bir ürünün satırı her zaman tam sayıdır.

`scope_items[]`, teklife eklenen "dahil olan / olmayan" maddelerini listeler; her biri serbest metin bir `category` (örneğin `included`, `excluded`) ve bir `label` içerir. `intro` ve `terms`, teklif gönderildiği andaki giriş ve şartlar metnidir (mağaza veya teklif bunları hiç ayarlamadıysa `null`). `origin`, doğrudan satıcının yazdığı bir teklif için `"merchant"`, sepet tabanlı bir teklif talebi olarak başlayan bir teklif için `"storefront"`'tur. `acceptance_mode`, mağazanın güncel ayarıdır (`"click"` veya `"signature"`) ve `POST .../accept`'in bir `signature` gerektirip gerektirmediğini belirler. `pdf_url`, teklif belgesine 15 dakika geçerli geçici bir URL'dir; bu teklif için henüz bir PDF oluşturulmadıysa `null`'dır. `signed_at`, teklif kabul edildiği anda (her iki kabul modunda da) ayarlanır ve o ana kadar `null` kalır. `signed_pdf_url` yanıtta her zaman bulunur ancak teklif kabul edilene kadar `null`'dır; kabul edildikten sonra karşı imzalı belgeye 15 dakika geçerli geçici bir URL'dir.

### Hatalar

| Durum | Gövde | Ne zaman |
| --- | --- | --- |
| 404 | `{"message": "..."}` | Bilinmeyen token veya başka bir mağazaya ait token. |
| 403 | scope, modül veya askıya alma gövdesi | Anahtar `sales:read` (veya `commerce:read`) scope'undan yoksun, modül devre dışı veya mağaza askıya alınmış. |
| 429 | `{"message": "Too Many Attempts."}` | Rate limit aşıldı. |

## POST /v1/api/sales/quotes/by-token/{token}/accept

### Alias: `/v1/api/commerce/quotes/by-token/{token}/accept`

Modül ayrımından önce oluşturulmuş entegrasyonlar için kalıcı olarak korunan, özdeş rota, request ve response.

Teklif verilmiş bir teklifi kabul eder. Sonrasında ne olacağı, tenant'ın `on_accept` ayarına bağlıdır: teklif ya `pending_payment` durumunda bir siparişe (sipariş modu) ya da bir faturaya (fatura modu) dönüşür. `sales:write` (veya `commerce:write`) gerektirir. İşlem satır kilitli (row-locked) ve idempotent'tir: sipariş modunda zaten kabul edilmiş bir teklifi kabul etmek aynı siparişi yeniden döner.

Sipariş modunda, siparişi oluşturmadan önce toplamlar yeniden hesaplanır ve gönderim anında dondurulan rakamlarla artık eşleşmiyorlarsa (örneğin satıcının yeniden düzenlemesi veya bir vergi değişikliği sonrasında) `OFFER_CHANGED` ile reddedilir; ayrıca her ürün satırı için ürünün line handler'ı üzerinden teklif edilen fiyattan stok rezerve edilir (made-to-measure bir satır bileşenlerini düşer) ve yalnızca gerçek bir yetersizlikte `OUT_OF_STOCK` ile reddedilir. Ürünü artık satılamayan bir satır (geri çekilmiş, capability kapatılmış, yapılandırma artık geçerli değil) yine de stok hareketi olmadan dönüştürülür ve siparişte satıcı için işaretlenir. Kesirli satır miktarları siparişe olduğu gibi aktarılır.

Response, `GET .../by-token/{token}` ile aynı müşteriye dönük teklif şeklini `data` içine sarılmış olarak taşır; `order_id` ve `order_number` ise `data`'nın içinde değil, envelope'un kökünde `data`'nın yanına eklenir. Tenant'ın `on_accept` ayarı sipariş yerine fatura oluşturduğunda ikisi de `null`'dır. `order_number` düz bir tamsayıdır. Siparişin tamamına ihtiyacınız varsa oluşturulan siparişi `GET /v1/api/commerce/orders/{order_id}` ile getirin.

### Parametreler

**Path**

| Ad | Tür | Zorunlu | Açıklama |
| --- | --- | --- | --- |
| `token` | string | evet | Teklif e-postasındaki düz token. |

**Body**

| Ad | Tür | Zorunlu | Açıklama |
| --- | --- | --- | --- |
| `signature` | string veya null | mağazanın `acceptance_mode`'u `signature` olduğunda zorunlu | Çizilmiş imzanın en fazla 2 MB'lık bir `data:image/png;base64,` veri URL'si. Çözülen baytlar gerçek bir PNG olarak yeniden doğrulanır. |

`acceptance_mode` `click` iken gönderilen bir `signature` yok sayılır. Click modunda kabul, çizilmiş bir imza bloğu olmadan da kabul edilen belge için bir `signed_pdf_url` üretir — karşı imzalı bir belgeye sahip olmak için imza gerekmez.

```bash title="Request"
curl -X POST https://api.borkol.com/v1/api/sales/quotes/by-token/{token}/accept \
  -H "Authorization: Bearer {api_key}" \
  -H "Content-Type: application/json" \
  -d '{"signature": "data:image/png;base64,iVBORw0KGgo..."}'
```

```javascript title="Request (JS)"
const res = await fetch(
  `https://api.borkol.com/v1/api/sales/quotes/by-token/${token}/accept`,
  {
    method: "POST",
    headers: {
      Authorization: `Bearer ${apiKey}`,
      "Content-Type": "application/json",
    },
    body: JSON.stringify({ signature: null }),
  }
);
if (res.status === 409) {
  const err = await res.json();
  // err.code: "QUOTE_NOT_OFFERED" | "OFFER_CHANGED" | "OUT_OF_STOCK" | "ALREADY_CONVERTED"
}
if (res.status === 422) {
  const err = await res.json();
  // err.code: "SIGNATURE_REQUIRED" | "SIGNATURE_INVALID"
}
const { data: quote, order_id, order_number } = await res.json();
```

```json title="Response"
{
  "data": {
    "id": "6e7f8a9b-0c1d-4e2f-a3b4-c5d6e7f8a9b0",
    "number": "Q-17",
    "status": "accepted",
    "...": "same customer-facing quote shape as GET /v1/api/sales/quotes/by-token/{token}"
  },
  "order_id": "7f8a9b0c-1d2e-4f3a-b4c5-d6e7f8a9b0c1",
  "order_number": 1043
}
```

### Hatalar

| Durum | Gövde | Ne zaman |
| --- | --- | --- |
| 422 | `{"code": "SIGNATURE_REQUIRED", "message": "A signature is required to accept this quote."}` | Mağaza çizilmiş bir imza gerektiriyor ve hiçbiri gönderilmedi. |
| 422 | `{"code": "SIGNATURE_INVALID", "message": "..."}` | İmza bir PNG veri URL'si değil, 2 MB'dan büyük veya gerçek bir PNG'ye çözülmüyor. |
| 409 | `{"code": "QUOTE_NOT_OFFERED", "message": "Quote is not awaiting a response (status: declined)."}` | Teklif şu anda `offered` değil (`submitted`, `declined`, `expired` veya `cancelled`; mesajdaki durum gerçek durumu yansıtır). |
| 409 | `{"code": "OFFER_CHANGED", "message": "This offer has changed since it was sent. Ask the merchant to re-send it."}` | Yalnızca sipariş modu: yeniden hesaplanan toplamlar dondurulmuş teklif rakamlarıyla artık eşleşmiyor. |
| 409 | `{"code": "OUT_OF_STOCK", "message": "Insufficient stock.", "out_of_stock": [{"product_id": "...", "variant_id": null, "requested": 25, "available": 10}]}` | Yalnızca sipariş modu: bir veya daha fazla ürün satırı (ya da made-to-measure bir satırın bileşenleri) için yetersiz stok. `requested` ve `available` JSON sayılarıdır ve ondalıklı olabilir. |
| 409 | `{"code": "ALREADY_CONVERTED", "message": "This quote was already converted to a document."}` | Yalnızca sipariş modu ve yalnızca bir yarış durumunda: teklif, arkasında henüz bir sipariş olmadan başka bir request tarafından zaten kabul edildi. |
| 410 | `{"code": "QUOTE_EXPIRED", "message": "This quote is no longer valid."}` | Teklifin geçerlilik süresi doldu; durumu `expired` olarak değişti. |
| 404 | `{"message": "..."}` | Bilinmeyen token. |
| 403 | scope, modül veya askıya alma gövdesi | Anahtar `sales:write` (veya `commerce:write`) scope'undan yoksun, modül devre dışı veya mağaza askıya alınmış. |
| 429 | `{"message": "Too Many Attempts."}` | Çağıranın IP'siyle anahtarlanarak dakikada 30'dan fazla accept veya decline isteği. |

## POST /v1/api/sales/quotes/by-token/{token}/decline

### Alias: `/v1/api/commerce/quotes/by-token/{token}/decline`

Modül ayrımından önce oluşturulmuş entegrasyonlar için kalıcı olarak korunan, özdeş rota, request ve response.

Teklif verilmiş bir teklifi, satıcının dahili notuna eklenen isteğe bağlı bir nedenle reddeder. `sales:write` (veya `commerce:write`) gerektirir. Eşzamanlı yarışan bir kabule karşı satır kilitlidir. Güncellenmiş müşteriye dönük teklifi (durum `declined`, `declined_at` ayarlı) 200 durum koduyla döner. Reddedilmiş bir teklif terminal değildir: satıcı yine de yeniden teklif verebilir veya iptal edebilir. Accept ile aynı şekilde sınırlandırılmıştır: çağıranın IP'siyle anahtarlanarak dakikada 30 istek.

### Parametreler

**Path**

| Ad | Tür | Zorunlu | Açıklama |
| --- | --- | --- | --- |
| `token` | string | evet | Teklif e-postasındaki düz token. |

**Body**

| Ad | Tür | Zorunlu | Açıklama |
| --- | --- | --- | --- |
| `reason` | string veya null | hayır | Alıcının neden reddettiği. Satıcının dahili notuna eklenir; müşteriye dönük teklifte asla geri gösterilmez. |

```bash title="Request"
curl -X POST https://api.borkol.com/v1/api/sales/quotes/by-token/{token}/decline \
  -H "Authorization: Bearer {api_key}" \
  -H "Content-Type: application/json" \
  -d '{"reason": "Found a better price elsewhere."}'
```

```json title="Response"
{
  "data": {
    "id": "6e7f8a9b-0c1d-4e2f-a3b4-c5d6e7f8a9b0",
    "number": "Q-17",
    "status": "declined",
    "declined_at": "2026-08-16T08:30:00+00:00",
    "...": "same customer-facing quote shape as GET /v1/api/sales/quotes/by-token/{token}"
  }
}
```

### Hatalar

| Durum | Gövde | Ne zaman |
| --- | --- | --- |
| 409 | `{"code": "QUOTE_NOT_OFFERED", "message": "Quote is not awaiting a response (status: accepted)."}` | Teklif şu anda `offered` değil. |
| 422 | `{"message": "...", "errors": {"reason": ["..."]}}` | `reason` mevcut ancak string değil. |
| 404 | `{"message": "..."}` | Bilinmeyen token. |
| 403 | scope, modül veya askıya alma gövdesi | Anahtar `sales:write` (veya `commerce:write`) scope'undan yoksun, modül devre dışı veya mağaza askıya alınmış. |
| 429 | `{"message": "Too Many Attempts."}` | Çağıranın IP'siyle anahtarlanarak dakikada 30'dan fazla accept veya decline isteği. |

## Scope'lar

`GET`, her iki yolda da `sales:read` gerektirir; `accept`/`decline` ise `sales:write` gerektirir. Modül ayrımından önce oluşturulmuş anahtarlar için `commerce:read` / `commerce:write`, `sales:read` / `sales:write`'ın kabul edildiği her yerde kabul edilir — mevcut bir anahtarın yeniden oluşturulmasına gerek yoktur. Bu yüzeyde `sales.view`, `sales.manage` veya `sales.settings` diye bir scope yoktur: bu adlar mevcut değildir.
