Metadata-Version: 2.5
Name: rewloy
Version: 0.3.0
Summary: The official Python library for the Rewloy API: digital loyalty cards, typed from the OpenAPI document.
Project-URL: Homepage, https://rewloy.com/gelistiriciler
Project-URL: Documentation, https://rewloy.com/gelistiriciler/api
Project-URL: Repository, https://github.com/Rewloy/rewloy-python
Project-URL: Changelog, https://github.com/Rewloy/rewloy-python/blob/main/CHANGELOG.md
Author: Rewloy
License-Expression: MIT
License-File: LICENSE
Keywords: api,loyalty,openapi,rewloy,sdk,wallet
Classifier: Development Status :: 3 - Alpha
Classifier: Intended Audience :: Developers
Classifier: Operating System :: OS Independent
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3 :: Only
Classifier: Programming Language :: Python :: 3.9
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Programming Language :: Python :: 3.14
Classifier: Typing :: Typed
Requires-Python: >=3.9
Provides-Extra: dev
Requires-Dist: build>=1.2; extra == 'dev'
Requires-Dist: httpx>=0.27; extra == 'dev'
Requires-Dist: mypy>=1.11; extra == 'dev'
Requires-Dist: pytest>=8; extra == 'dev'
Provides-Extra: httpx
Requires-Dist: httpx>=0.27; extra == 'httpx'
Description-Content-Type: text/markdown

# Rewloy Python

**Rewloy API'nin resmî Python kütüphanesi.**

> **Durum: önizleme (0.x), PyPI'da yayımlandı. API kararlı; kütüphane arayüzü 1.0'a kadar değişebilir.**

[Rewloy](https://rewloy.com), işletmelerin dijital sadakat kartlarını
müşterinin telefonuna koyar. Kart türleri damga, puan, VIP, cashback, hediye
kartı, kupon ve indirimdir:
- iPhone'da Apple Cüzdan;
- Android'de Rewloy Cüzdan ve Google Cüzdan;
- her yerde web kartı.

Kasada QR okutulur; bakiye, ödül ve kampanyalar kartın kendisinde güncellenir.
Panelde yapılabilen her şey [Rewloy API v1](https://rewloy.com/gelistiriciler)
ile de yapılabilir; bu kütüphane onu Python'dan kullanır. Geliştirici
belgeleri: **https://rewloy.com/gelistiriciler**.

- **Tam tipli.** API'nin her işlemi, `operationId` adının snake_case hâliyle
  bir metottur (`passAction` → `pass_action`). İstek gövdeleri, sorgular ve
  yanıtlar OpenAPI belgesinden
  ([`openapi.json`](https://app.rewloy.com/v1/openapi.json)) üretilen
  `TypedDict` tipleriyle gelir; `mypy --strict` geçer. CI belgeyi her gün okur
  ve değişince yeniden üretir.
- **Bağımlılıksız.** Python 3.9 ve üstü; yalnız standart kütüphane (`urllib`,
  `json`, `hmac`). Bağlantı havuzu ve HTTP/2 isteyen için `httpx` taşıması
  isteğe bağlıdır.
- **Güvenli tekrar.** Geçici hatalarda ölçülü yeniden deneme; satışta, kasa
  işleminde ve kampanyada `Idempotency-Key`.
- **Ötesi:** sayfalama, canlı akış (SSE), webhook imzası doğrulama,
  kullanımdan kalkma uyarıları.

## Kurulum

Python 3.9 ya da üstü gerekir:

```sh
pip install rewloy
```

`httpx` taşıması için: `pip install "rewloy[httpx]"`.

## Başlarken

```python
import os
from rewloy import Rewloy

rewloy = Rewloy(api_key=os.environ["REWLOY_API_KEY"])

kart = rewloy.get_pass("ABCD-EFGH-JKLM")
# "Şimdi ne yapılabilir?" için `actions[].ready` okunur; `rewardReady` yalnız damga ve puanda "ödül hazır"dır.
odul = [a for a in kart["actions"] if a["action"] in ("redeem-stamps", "redeem-reward") and a["ready"]]
print(kart["type"], kart["balance"], bool(odul))
```

Her işlem, adı `operationId`'nin snake_case hâli olan bir metottur
([API referansı](https://rewloy.com/gelistiriciler/api); `OPERATION_IDS` ve
`METHOD_NAMES` ikisini eşler). Argümanlar:
- adresteki parametreler konumsaldır: `get_pass(seri)`;
- `query`: sorgu parametreleri (sözlük);
- `body`: JSON gövde (sözlük);
- `merchant`: `Rewloy-Merchant` başlığı;
- `idempotency_key`: `Idempotency-Key` başlığı (satış, kasa işlemi, kampanya ve mağaza iadesinde zorunlu);
- `timeout` (saniye) ve `max_retries`.

Sorgu ve gövde sözlükleri API'deki adlarıyla yazılır (`programId`,
`kvkkConsent`); anahtarlar çevrilmez. Metot yanıttaki `data`yı döndürür:
- bir `TypedDict` ya da liste; yanıtı olduğu gibi alırsınız, API'nin sonradan
  eklediği bir alan hemen sözlüğünüzdedir;
- sayfalı listelerde `Page` (`sayfa.data`, `sayfa.meta`);
- gövdesiz yanıtta (`204`) `None`;
- dosyada (QR, harita, CSV, `.pkpass`) `bytes`.

Tipler `rewloy.types` altındadır: `IssuePassBody`, `GetPassData`,
`ListCustomersItem`, `ErrorCode`… Çalışma anında yüklenmemeleri için
(`import rewloy` onları yüklemez; yaklaşık 80 ms tutar) yalnız açıklamada
kullanıyorsanız `TYPE_CHECKING` altında içe aktarın:

```python
from typing import TYPE_CHECKING

if TYPE_CHECKING:
    from rewloy.types import IssuePassBody
```

### Kimlik

| İstemci | Ne için |
|---|---|
| `Rewloy(api_key="rwk_…")` | API anahtarı: kasa, e-ticaret, kendi sisteminiz |
| `Rewloy(staff_session="rws_…", merchant=…)` | ekip oturumu: bir kişinin işletme uygulaması |
| `Rewloy(holder_session="rwh_…")` | kart sahibi oturumu: Rewloy Cüzdan gibi müşteri uygulamaları |
| `Rewloy()` | kimlik istemeyen uç noktalar: giriş, katılım, kod |

`merchant`, ekip oturumu birden fazla işletmede koltuk taşıyorsa hangi işletme
için çalıştığını söyler (`Rewloy-Merchant`). Her çağrıda `merchant=` ile
değiştirilebilir. Oturumlar kimliksiz bir istemciyle açılır:

```python
oturum = Rewloy().login(body={"email": eposta, "password": parola})
ekip = Rewloy(staff_session=oturum["token"], merchant=isletme_id)
if oturum["mfaRequired"]:
    ekip.prove_mfa(body={"code": "123456"})
```

Bir işlem istemcinin kimlik türünü kabul etmiyor ama kimliksiz de çalışıyorsa
(örneğin `login`), istemci onu kimliksiz çağırır. API, işlemin kabul etmediği
bir kimliği reddeder (`CREDENTIAL_NOT_ALLOWED`). Kimlik `repr`de görünmez.

Diğer seçenekler:
- `base_url` (varsayılan `https://app.rewloy.com`; sonuna `/v1` eklemeniz ya da eklememeniz fark etmez: `https://app.rewloy.com/v1` de olur, kütüphane `/v1`i kendisi ekler);
- `timeout` (saniye; varsayılan 60, `0` sınırsız);
- `max_retries` (2);
- `transport`: HTTP katmanı (bkz. [Taşıma](#taşıma-ve-test-etmek));
- `user_agent`: gönderilen `User-Agent`a eklenir, örneğin `"KasaPOS/4.2"`;
- `sleep`: yeniden denemeler arasındaki beklemeyi değiştirir (testler için).

İstemci iş parçacıkları arasında paylaşılabilir. `with Rewloy(...) as rewloy:`
ya da `rewloy.close()` taşımanın tuttuklarını bırakır.

### Başka bir adres (staging)

API'nin başka bir kopyasına (kendi staging ortamınız ya da bir vekil sunucu)
`base_url` ile bağlanılır:

```python
rewloy = Rewloy(
    api_key=os.environ["REWLOY_API_KEY"],
    base_url="https://rewloy-staging.ornek.com",   # sonuna /v1 yazsanız da olur
)
```

Gerçek müşterilere dokunmadan denemek için adres değiştirmeniz gerekmez:
[test modu](#test-modu) aynı adreste, ayrı bir test ortamıyla çalışır.

## Kart vermek ve kasada işlem

```python
sonuc = rewloy.issue_pass(
    body={"programId": program_id, "email": "ayse@ornek.com", "firstName": "Ayşe", "kvkkConsent": True},
)
seri, kart_adresi = sonuc["serial"], sonuc["cardUrl"]

islem = rewloy.pass_action(
    seri,
    body={"action": "earn-stamps", "locationId": sube_id, "count": 1},
    idempotency_key=f"kasa3-z0187-fis{fis_no}",   # aşağıya bakın
)
if islem.get("duplicate"):
    print("Bu işlem zaten yazılmış")
```

### Satış: `record_sale`

Kasa ya da kendi yazılımınız için en kolay yol `record_sale`dir: "bu satış
oldu, sen yaz". Ödenen toplamı (kartın para biriminde, kuruş) gönderirsiniz;
ne yazılacağına kartın türü ve programın kendi kuralı karar verir. Kartın
türünü bilmeniz gerekmez.

```python
kart = rewloy.get_pass(seri)
# Kartın türüne özgü alanlar; `balance` yerine bunları okuyun.
if "stamps" in kart:
    print(f"{kart['stamps']['count']} / {kart['stamps']['max']} damga")
if "points" in kart:
    print(f"{kart['points']} puan")
if "money" in kart:
    print(kart["money"]["amountMinor"] / 100, kart["money"]["currency"])
musteri = kart.get("customer")   # yalnız customers.read yetkisiyle; yoksa None
print(kart["programName"], musteri["name"] if musteri else None)

# Fiş numarası anahtar olamaz: kasa + Z no + fiş no, ya da satışla saklanan bir UUID.
anahtar = f"kasa3-z0187-fis{fis_no}"
satis = rewloy.record_sale(
    seri,
    body={
        "locationId": sube_id,
        "amountMinor": 4550,          # 45,50: kartın para biriminde (kart["currency"]), kuruş
        "currency": kart["currency"], # isteğe bağlı güvence: uyuşmazsa 422 CURRENCY_MISMATCH
        "reference": f"fis-{fis_no}", # fiş numarası buraya yazılır
    },
    idempotency_key=anahtar,
)
if satis["applied"] == "none":
    print("Yazılan bir şey yok:", satis.get("reason"))
else:
    print(satis["credited"], satis["applied"], "yazıldı, bakiye", satis["balance"])
# Fişi çizmek için ayrıca okumanız gerekmez: yazımdan sonraki kart satis["card"]'dadır (yetki yoksa None).
yazimdan_sonra = satis["card"]
if yazimdan_sonra and any(a["action"] in ("redeem-stamps", "redeem-reward") and a["ready"] for a in yazimdan_sonra["actions"]):
    print("Ödül hazır")
```

`actions[].ready`, kartın kendi durumuna göre işlemin şimdi yapılıp
yapılamayacağıdır (damga ödülü hazır mı, puan bir ödüle yetiyor mu, bakiye var
mı, kupon kullanılmamış mı, VIP ziyareti bu pencerede sayılmış mı). `rewardReady`
aynen kalır ama türe göre anlam değiştirir: damga ve puanda "ödül hazır";
cashback ve hediye kartında bakiye sıfırdan büyükse; **VIP'te her zaman
`True`**. Kasa ekranında "Ödül hazır" yazısını yalnız damga ve puanda gösterin.

`GET /v1/passes/{serial}` ayrıca `actions` (kartın aldığı kasa işlemleri ve
şimdi yapılıp yapılamayacakları) ve `sale` (bir satışın bu kartta ne
yazacağı) alanlarını verir.

**İade.** `reverse_sale` bir satışın karta yazdığını geri alır; satışı
yazarken gönderdiğiniz anahtarla (`saleKey`) ya da `reference`la bulur:

```python
geri = rewloy.reverse_sale(seri, body={"saleKey": anahtar, "locationId": sube_id})
print(geri["reversed"], geri["applied"], "geri alındı, bakiye", geri["balance"])
```

Bir satış bir kez geri alınır (tekrar `duplicate: true` döner). Kazanılan
kullanılmışsa (ödüle ya da harcamaya gitmişse) `409 SALE_ALREADY_SPENT` gelir ve
hiçbir şey yazılmaz.

**Çevrimdışı kasa kuyruğu: `occurredAt`.** Bağlantı koptuğunda satışı sonra
yazıyorsanız `occurredAt` ile satışın gerçekten olduğu anı (ISO 8601, saat
dilimiyle) gönderin; kartın geçmişinde o anla görünür. Gelecekte olamaz (2
dakikalık saat farkı kabul edilir). `idempotency_key` kuyruktaki kayıtla birlikte
saklanır, tekrar gönderilince satış ikinci kez yazılmaz.

```python
rewloy.record_sale(
    seri,
    body={"locationId": sube_id, "amountMinor": 4550, "reference": f"fis-{fis_no}", "occurredAt": "2026-10-05T14:32:10+03:00"},
    idempotency_key=anahtar,
)
```

**Kasa işlemini iptal etmek: `reverse_action`.** `pass_action` ile yapılan bir
harcama, ödül ya da kullanım yanlışlıkla yapıldıysa (`spend`, `spend-points`,
`redeem-stamps`, `redeem-reward`, `use`) `reverse_action` tamamını geri verir.
İşlemi, yaparken gönderdiğiniz `Idempotency-Key` (`actionKey`) ya da işlemin
`reference` değeriyle bulur (`pass_action` artık isteğe bağlı bir `reference`
alır). `reverse_action` bir `Idempotency-Key` **istemez**: bir işlem bir kez geri
alınır, tekrar `duplicate: true` döner.

```python
rewloy.pass_action(
    seri,
    body={"action": "spend", "locationId": sube_id, "amountMinor": 2500},
    idempotency_key=f"kasa3-z0187-iptal{fis_no}",
)
iptal = rewloy.reverse_action(seri, body={"actionKey": f"kasa3-z0187-iptal{fis_no}", "locationId": sube_id})
print(iptal["undone"], iptal["restored"], iptal["balance"], iptal["reopened"], iptal["duplicate"])
```

`pass_action`ın yanıtı kart türüne göre iki biçimdedir ve bir `Union` türüdür:
bakiyeli kartlarda `balance` (damga, puan, VIP, cashback, hediye kartı), kupon ve
indirim kartında `status`, `uses` ve `usesLeft`. mypy ve pyright `"uses" in islem`
ile ayırır. Kazanımlar (`earn-stamps`, `earn-points`, `visit`) `reverse_action`la
değil `reverse_sale`la geri alınır.

**Yazımın yanıtında kartın durumu: `card`.** `record_sale`, `pass_action`,
`reverse_sale` ve `reverse_action` yanıtları `card` taşır: yazımdan sonraki kart,
`get_pass`'in `customer` hariç aynı alanlarıyla (`programName`, `currency`,
`stamps`/`points`/`money`, `actions`…). Yazımla aynı işlemde okunur, yanıtın
`balance`'ıyla aynı anı söyler. **Tekrarda** (`duplicate: True`) kartın
**şimdiki** durumudur. Kimliğin kartın programında `passes.read` yetkisi yoksa
(yalnız kasa yetkisi olan bir eklenti anahtarı) `card` `None`dır. `record_sale`
yanıtındaki `reversed: True`, bu anahtarla yazılan satışın sonradan geri
alındığını söyler (yalnız bir tekrarda olabilir; `credited` ilk isteğin
yazdığıdır, kart onu artık taşımaz): fişi yeniden yazmak için yeni bir anahtar
gönderin.

**Kartın işlemleri: `list_pass_operations`.** Kartın defterindeki işlemler,
yeniden eskiye, sayfalı (`rewloy.paginate("listPassOperations", path={"serial": seri})`):
bir kasa ekranındaki "son işlemler" listesi ve her birinin İade düğmesi için;
kasanın kendi anahtar günlüğünü tutması gerekmez. Her işlemde `undoWith` hangi
uç noktanın geri aldığını (`"sale/reverse"` ya da `"actions/reverse"`),
`reversible` bu kimliğin şimdi geri alıp alamayacağını söyler; bu kimliğin kendi
işlemlerinde `saleKey` ya da `actionKey` de gelir.

```python
for islem in rewloy.paginate("listPassOperations", path={"serial": seri}):
    if not islem["reversible"]:
        continue
    if islem["undoWith"] == "sale/reverse":
        rewloy.reverse_sale(seri, body={"saleKey": islem["saleKey"]})
    else:
        rewloy.reverse_action(seri, body={"actionKey": islem["actionKey"]})
```

**`occurredAt` reddedilirse** `400 VALIDATION` gelir ve
`err.details[0]["reason"]` nedeni söyler: `in_future`, `too_old` (72 saatten
eski), `before_issue` (kart o anda yoktu: `occurredAt` olmadan yeniden
gönderin), `invalid`. Tanımadığınız bir `reason`'ı `invalid` gibi ele alın.

### Fiş satırları, kazanım kuralları ve satır iadesi (API 1.3.0, kütüphane 0.3.0)

`record_sale` isteğe bağlı `lines` (en çok 500) ve yalnız onunla
`receiptDiscountMinor` alır. Programda **kazanım kuralı** varsa satırlar ürün
gruplarına göre sınıflanır ve kurallar uygulanır; kuralı olmayan program ve
satırsız satış bugünkü gibi kazanır (satırlar yalnız kaydedilir). Satırlı bir
satışın yanıtı `earn` taşır: her satırın durumu (`earned`, `no_rule`…) ve payı,
her kuralın ne yaptığı ve toplamın adım adım dökümü.

```python
# 1) Ürün grubu ve kural: "Sıcak içeceklerde her ürüne 1 damga"
grup = rewloy.create_earn_group(body={
    "name": "Sıcak içecek",
    "members": [{"effect": "include", "match": "category", "value": "İçecek > Sıcak"}],
})
kurallar = rewloy.get_earn_rules(program_id)              # revision: okuduğunuz sürüm (hiç kaydedilmediyse 0)
rewloy.put_earn_rules(program_id, body={                  # tamamı yazılır; aradan biri kaydettiyse 409 REVISION_CONFLICT
    "revision": kurallar["revision"],
    "rules": [{"kind": "stamp.perUnit", "groupId": grup["id"], "stamps": 1}],
})

# 2) Fiş kapanmadan önce: bu fiş ne kazandırır? (hiçbir şey yazmaz, Idempotency-Key istemez)
satirlar = [
    {"lineId": "1", "name": "Latte", "unitPriceMinor": 9000, "quantity": 2, "category": "İçecek > Sıcak"},
    {"lineId": "2", "name": "Kek", "unitPriceMinor": 5000, "category": "Tatlı"},
]
onizleme = rewloy.preview_sale(seri, body={"locationId": sube_id, "amountMinor": 23000, "lines": satirlar})
print(onizleme["credited"], onizleme["preview"])          # 2 True

# 3) Yaz; yanıttaki earn "neden iki damga?" sorusunu yanıtlar
satis = rewloy.record_sale(
    seri,
    body={"locationId": sube_id, "amountMinor": 23000, "reference": f"fis-{fis_no}", "lines": satirlar},
    idempotency_key=anahtar,
)
for satir in satis["earn"]["lines"]:
    print(satir["lineId"], satir["status"], satir["earned"])   # 1 earned 2 / 2 no_rule 0

# 4) Satır iadesi: yalnız farkı geri alır. Idempotency-Key ister; kalan satırlar linesLeft'te
iade = rewloy.reverse_sale(
    seri,
    body={"saleKey": anahtar, "lines": [{"lineId": "1", "quantity": 1}]},
    idempotency_key=f"{anahtar}-iade1",
)
print(iade["reversed"], iade["linesLeft"])
```

`preview_earn(program_id, body={...})` aynı hesabı kart olmadan yapar; ayrıca
kaydedilmemiş taslak kurallarla (`ruleSet`), bir şubenin kasa kampanyasıyla
(`locationId`) ve bir anla (`occurredAt`) deneyebilirsiniz. Hatalar:
`TOO_MANY_LINES`, `LINE_AMOUNT_INVALID`, `LINES_TOTAL_MISMATCH`,
`LINE_NOT_FOUND`, `LINE_ALREADY_REFUNDED`, `GROUP_IN_USE`,
`REVISION_CONFLICT`. Kasaların gönderdiği kategoriler `list_seen_lines`,
hazır kural setleri `list_earn_templates` ile okunur.

### Şube QR'ı, dondurma, kodlar (API 1.3.0)

Her şubenin kalıcı bir QR'ı vardır (`sube["qr"]`: `code`, `url`, `state`).
Herkese açık sayfası kimliksiz okunur, görüntüsü ve baskısı `bytes` döner:

```python
sayfa = Rewloy().public_branch(sube["qr"]["code"])            # kimlik gerekmez
png = rewloy.location_qr_png(sube_id, query={"size": 1024})
open("sube-qr.png", "wb").write(png)
afis = rewloy.location_qr_sheet_pdf(sube_id)                  # A4 afiş, A6 masa standı, etiket
liste = rewloy.get_location_qr_items(sube_id)                 # QR'da hangi kartlar, hangi sırayla
rewloy.put_location_qr_items(sube_id, body={"version": liste["version"], "items": [...]})  # 409 QR_LIST_CHANGED
```

Bir şubeyi dondurmak (`freeze_location`) bir ekip oturumu ve kişinin şifresi
ister; API anahtarı `403 CREDENTIAL_NOT_ALLOWED` alır. Donuk şubede yeni kasa
işlemi `409 LOCATION_FROZEN` ile reddedilir, her şube donukken işletme
duraklar (`BUSINESS_FROZEN`); önceki işlemlerin geri alınması çalışır. Dondurma
geçmişi `list_location_freezes` ile okunur, bir şubeyi `unfreeze_location` açar.
`update_batch` bir kodu sonradan düzenler, `copy_program` hediye kartı, kupon ya
da indirim kartının değeri değişik bir kopyasını oluşturur (sadakat kartı
`422 NOT_AN_INSTRUMENT`), `extend_program_cards` var olan kartların süresini uzatır.

### `Idempotency-Key`

`record_sale`, `pass_action`, `send_campaign` ve `refund_shop_redemption` bir
`Idempotency-Key` **ister**: API'nin tanımında (OpenAPI) bu başlık bu işlemlerde
zorunludur, bu yüzden `idempotency_key` bu metotlarda zorunlu bir anahtar
sözcük argümanıdır (vermezseniz `TypeError`; `request()` ile çağırırken
`ValueError`, ikisi de istek göndermeden). Kütüphane **sizin yerinize anahtar
üretmez**. Üretilmiş rastgele bir anahtar yalnızca tek çağrının yeniden
denemelerini korurdu: uygulama çöküp yeniden başlarsa yeni bir anahtar çıkar ve
satış ikinci kez yazılabilirdi. Anahtarı kendiniz üretip satışla birlikte
saklayın. Anahtar 8–64 karakterlik görünür ASCII olmalıdır (0x21–0x7E: harf,
rakam ve noktalama; boşluk, Türkçe harf ya da `fiş` gibi ASCII dışı karakter
olmaz); aksi halde kütüphane yine istek göndermeden `ValueError` fırlatır.
Başlığın isteğe bağlı olduğu işlemlerde (örneğin `issue_pass`) anahtar
verilmezse kütüphane bir UUID üretir ve aynı çağrının her denemesinde aynısını
gönderir.

- **Anahtar bir kimlik için kalıcı olarak tekildir** (8–64 karakter; defterden
  hiç silinmez). Aynı anahtarla aynı isteğin tekrarı ikinci kez yazmaz ve
  ilk sonucu `duplicate: true` ile döndürür. Aynı anahtar başka bir gövdeyle
  `422 IDEMPOTENCY_KEY_REUSED` alır.
- **Fiş numarası tek başına anahtar olamaz:** yazarkasa fiş numaraları Z
  raporundan sonra yeniden başlar. Kasa + Z no + fiş no birleşimi
  (`kasa3-z0187-fis0042`) ya da satışla birlikte saklanıp tekrarda yeniden
  gönderilen bir UUID kullanın.
- **Fiş numarası `reference` alanına** yazılır; müşterinin geçmişinde ve işlem
  dökümünde görünür.

## Sayfalama

```python
for musteri in rewloy.paginate("listCustomers", query={"consent": "yes", "limit": 200}):
    print(musteri["displayName"], musteri["identifiers"])
```

`paginate` sayfalı her listeyi (`page`/`limit` ve `meta`) öğe öğe dolaşır ve
son sayfada durur; tembeldir, bıraktığınız yerde istek de durur. Adreste
parametresi olan listelere `path={"id": …}` verilir. Tek bir sayfa için
metodun kendisi yeter: `sayfa = rewloy.list_customers(query={"page": 2})`
(`sayfa.data`, `sayfa.meta`).

## Canlı akış

```python
with rewloy.live_feed() as akis:
    for olay in akis:
        if olay.event == "event":
            ev = olay.json()
            print(ev["kind"], ev["location"], ev["program"], ev["delta"], ev["unit"], ev["name"])
```

`live_feed` (işletmenin tezgâh akışı) ve `holder_card_events` (kart sahibinin
kartındaki değişiklik) sunucu olayları (`text/event-stream`) yayınlar.
`rewloy.stream("liveFeed", …)` aynı işi görür. Her olay `event`, `data` ve
`id` taşır; `json()` `data`yı ayrıştırır.

- **Yeniden bağlanma.** Bağlantı koparsa akış kendiliğinden yeniden bağlanır:
  sunucunun `retry:` süresi kadar bekler, bir olay `id` taşıdıysa
  `Last-Event-ID` gönderir. `reconnect=False` bunu kapatır.
- **Sessiz bağlantı.** API 25 saniyede bir `: hb` gönderir; 60 saniye hiç veri
  gelmezse bağlantı kopmuş sayılır (`idle_timeout=`).
- **Durdurmak:** döngüden `break` (bağlantı hemen kapanır), `with` bloğundan
  çıkmak ya da başka bir iş parçacığından `akis.close()`.
- **Bitiren hatalar.** Yeniden bağlanmanın düzeltemeyeceği bir hata (`401`,
  `403`, `404`) akışı `RewloyError` ile bitirir.

## Webhook doğrulama

Rewloy her teslimi imzalar:

```
Rewloy-Signature: t=<unix saniye>,v1=<hex HMAC-SHA256(sır, "<t>.<ham gövde>")>
```

`verify_webhook` imzayı **ham gövdeyle** ve webhook oluşturulurken bir kez
gösterilen sırla (`whsec_…`) doğrular:
- karşılaştırmayı `hmac.compare_digest` ile sabit sürede yapar;
- `t` şimdiden 300 saniyeden (`tolerance=`) uzaksa reddeder;
- gövdeyi ayrıştırılmış olarak (`dict`) döndürür.

Webhook'u panelden ya da API'den ekleyebilirsiniz. `webhooks.manage` yetkili
bir API anahtarı `create_webhook`, `list_webhooks`, `get_webhook`,
`set_webhook_status`, `test_webhook` ve `list_webhook_deliveries`i çağırabilir;
`webhook_events` abone olunabilecek olayları söyler. Sır (`secret`) yalnız
`create_webhook` yanıtında gelir, saklayın:

```python
yeni = rewloy.create_webhook(
    body={"url": "https://ornek.com/rewloy/webhook", "events": ["pass.activity", "pass.voided"]},
)
sir = yeni["secret"]
rewloy.test_webhook(yeni["webhook"]["id"])   # webhook.test olayı gönderir
```

Adres herkese açık bir `https` adresi olmalıdır (test ortamında da);
yerelde bir tünel kullanın.

**API 1.3.0 olayları.** `pass.extended` (kartın bitiş günü ileri alındı:
`reason` `merchant` ya da `branch_frozen`, `from`, `to`; `PassExtendedData`),
`location.frozen`, `location.unfrozen`, `business.paused` ve
`business.resumed` (kart olayı değildir, `card` ve `customer_id` `None`;
`BranchEvent`). Var olan webhook'lar bunları yalnız seçerlerse alır
(`create_webhook(body={"events": [...]})`). `WebhookEvent` artık
`PassEvent | BranchEvent | WebhookTestEvent`: `event["type"]`la ayırın.

**Sırrı yenilemek.** Kaybolan ya da sızan bir sır için `rotate_webhook_secret`
webhook'a yeni bir sır verir (yeni `secret` yalnız o yanıtta döner); webhook'u
silip yeniden eklemek gerekmez. Eski sır 24 saat daha yeninin yanında imzalar:
o sürede `Rewloy-Signature` iki `v1` taşır ve teslimler
`Rewloy-Signature-Rotating: 1` başlığıyla gelir. `verify_webhook` her `v1`'i ve
`secret` olarak verilen birden çok sırrı dener; yenilemeden önce alıcınızı
`[yeni, eski]` ile güncelleyin. `delete_webhook` webhook'u teslim geçmişiyle
birlikte kalıcı siler (`204`).

```python
yeni = rewloy.rotate_webhook_secret(webhook_id)["secret"]
# yeni sırrı alıcınıza ekleyin, 24 saat sonra eskisini bırakın
olay = verify_webhook(ham_govde, imza_basligi, [yeni, eski_sir])
```

Tutmazsa `WebhookSignatureError` atar: 400 ile yanıtlayın ve hiçbir işlem
yapmayın. Gövde mutlaka ham olmalıdır (`str` ya da `bytes`). JSON olarak
ayrıştırılıp yeniden yazılan bir gövde imzayı tutturmaz; ayrıştırılmış bir
`dict` verirseniz `TypeError` alırsınız.

Flask:

```python
import os
from flask import Flask, request
from rewloy import WebhookSignatureError, verify_webhook

app = Flask(__name__)

@app.post("/rewloy/webhook")
def rewloy_webhook():
    try:
        olay = verify_webhook(
            request.get_data(),
            request.headers.get("Rewloy-Signature"),
            os.environ["REWLOY_WEBHOOK_SECRET"],
        )
    except WebhookSignatureError:
        return "", 400
    # Rewloy-Delivery bir teslimin her denemesinde aynıdır: işlediyseniz atlayın.
    if daha_once_islendi(request.headers.get("Rewloy-Delivery")):
        return "", 200
    if olay["type"] == "pass.activity":
        print(olay["data"]["card"], olay["data"]["kind"], olay["data"]["delta"])
    return "", 200
```

FastAPI (Django'da ham gövde `request.body`dir):

```python
from fastapi import FastAPI, HTTPException, Request

app = FastAPI()

@app.post("/rewloy/webhook")
async def rewloy_webhook(request: Request) -> dict[str, bool]:
    try:
        olay = verify_webhook(await request.body(), request.headers.get("rewloy-signature"), SIR)
    except WebhookSignatureError:
        raise HTTPException(status_code=400)
    ...
    return {"ok": True}
```

Başlıklar:
- `Rewloy-Event`: olay türü (`pass.issued`, `pass.activity`, `pass.voided`,
  `webhook.test`); gövdedeki `type` ile aynı.
- `Rewloy-Delivery`: teslimin kimliği. Teslim "en az bir kez"dir: çift gelen
  teslimi bununla ayıklayın.

Gövde kişinin iletişim bilgisini taşımaz; kişiyi `customer_id` ile API'den
okuyun. 2xx dışı bir yanıt yaklaşık 45 saat boyunca 8 kez yeniden denenir ve
her deneme yeni bir `t` ile imzalanır. Sonuç `PassEvent` ya da
`WebhookTestEvent` tipindedir: `olay["type"]` ile `mypy` türü daraltır, bilinmeyen
yeni bir tür için bir `else` dalı bırakın. Kendi işleyicinizi test etmek için
`sign_webhook(govde, sir)` aynı başlığı üretir.

**Webhook'un durumu.** Webhook nesnesinde (`list_webhooks`, `get_webhook`,
`set_webhook_status`, `create_webhook` ve `rotate_webhook_secret`'ın webhook'u)
iki tarih alanı hep vardır, ikisi de boş olabilir (`str | None`, bir tarih):
- `pausedUntil`: alıcınız art arda iki kez `5xx`, `429` verdi ya da yanıt vermedi;
  açık webhook'un teslimleri bu ana kadar bekler, sonra kendiliğinden yeniden
  denenir (60 saniye). Bekletilmiyorsa ya da webhook kapalıysa boştur.
- `resumableUntil`: webhook'u **kurallar** kapattı ve bekleyen teslimleri
  saklanıyor (kapanıştan 24 saat sonrasına kadar). Bu andan önce
  `set_webhook_status(id, body={"active": True})` ile
  açarsanız kaldığı yerden devam eder: saklananlar hemen gider, kapalıyken olan
  olaylar da gelir. Açıksa, bir kişi ya da anahtar kapattıysa ya da süre geçtiyse boştur.

```python
for w in rewloy.list_webhooks():
    if w["pausedUntil"]:
        print(f'{w["url"]}: {w["pausedUntil"]} anına kadar bekletiliyor')
    if w["resumableUntil"]:
        print(f'{w["url"]}: {w["resumableUntil"]} öncesinde açın, kaldığı yerden sürer')
```

## Hatalar ve yeniden deneme

```python
from rewloy import RateLimitError, RewloyError

try:
    rewloy.pass_action(
        seri,
        body={"action": "spend", "locationId": sube_id, "amountMinor": 5000},
        idempotency_key=f"kasa3-z0187-fis{fis_no}",
    )
except RateLimitError as err:
    print(f"{err.retry_after} saniye sonra yeniden deneyin")
except RewloyError as err:
    if err.code == "INSUFFICIENT_BALANCE":
        print(err.detail)
    else:
        raise
```

`RewloyError` şunları taşır:
- `status`: HTTP durumu;
- `code`: API'nin sabit kodu ([hata kodları](https://rewloy.com/gelistiriciler/hatalar));
  kodunuz buna göre davranmalı (`rewloy.types.ErrorCode` bugünkü kodları sayar);
- `title`: kodun katalogdaki başlığı;
- `detail`: API'nin açıklaması (Türkçe, değişebilir);
- `details`: varsa ayrıntı; doğrulama hatasında `[{"field", "rule", "message"}]`;
- `request_id`: `x-request-id`; destek talebinde bunu verin;
- `body`, `headers`, `docs` ve `operation`.

Alt sınıflar:
- `RateLimitError`: `429`; `retry_after` saniye. Her hata (bu dahil) yanıtın
  `RateLimit-*` başlıklarını `err.rate_limit` olarak taşır
  (`RateLimit(limit, remaining, reset)`; başlık yoksa `None`);
- `RewloyConnectionError`: yanıt gelmedi (`status` 0, `code`
  `CONNECTION_ERROR`);
- `RewloyTimeoutError`: zaman aşımı (`TIMEOUT`).

Rewloy'un olmayan bir hata gövdesi (örneğin bir vekil sunucunun 502 sayfası)
`HTTP_502` gibi bir kodla gelir. Yanlış kullanım (yanlış önekli bir anahtar,
eksik adres parametresi) Python'un `ValueError`ı ya da `TypeError`ıdır.

**Yeniden deneme.** Şunlar en çok `max_retries` kez (varsayılan 2) yeniden
denenir: bağlantı hatası, zaman aşımı, `429`, `502`, `503`, `504` ve
Cloudflare'in `520`–`524` hataları.
- **Bekleme:** üstel ve rastgele (0,5 sn, 1 sn, 2 sn… en çok 8 sn); yanıt
  `Retry-After` taşıyorsa o kadar. `Retry-After` 60 saniyeden uzunsa
  beklenmez, hata size gelir.
- **Yalnız tekrarı güvenli istekler:** `GET`, `PUT`, `DELETE` ve
  `Idempotency-Key` taşıyan `POST`. İlk istek hâlâ işlenirken gelen
  `409 IDEMPOTENCY_IN_PROGRESS` de beklenip yeniden denenir. Diğer `POST` ve
  `PATCH` istekleri hiç tekrar edilmez.
- **Süre:** her deneme `timeout` (varsayılan 60 sn) içinde bitmelidir; süre
  gövdenin tamamını kapsar.

## Kullanımdan kalkma

Kalkacak bir uç nokta en az 180 gün önceden duyurulur. O süre boyunca her
yanıtı `Deprecation`, `Sunset` ve `Link` başlıklarını taşır.

- **Uyarı.** Kütüphane her işlem için bir kez `warnings.warn` ile bir
  `DeprecationWarning` yayar. Uyarı işlemi, `Sunset` tarihini ve değişiklik
  günlüğündeki kaydı söyler; satır olarak sizin çağrınızı gösterir, bu yüzden
  Python'un varsayılan süzgeci onu `__main__` kodunda gösterir.
- **Tipler.** O metodun belge dizgisi (docstring) kaldırılacağını söyler;
  kalkacak yanıt alanları da metodun belgesinde ve tiplerde işaretlidir.
- **Yönetmek.** `python -W default` her yerde gösterir; `-W ignore::DeprecationWarning`
  ya da `warnings.filterwarnings` susturur. `-W error` ile uyarı istisna
  olurdu ama sunucu işi yapmış olurdu ve yanıt kaybolurdu: bu yüzden çağrı
  döner ve uyarı `rewloy` kaydedicisine (`logging`) yazılır.

## Yanıtın tamamı ve test modu

```python
yanit = rewloy.request(
    "sendCampaign",
    body={"body": "Bu hafta kahveler 2 damga!"},
    idempotency_key="kampanya-2026-10-03",
)
yanit.status       # 201
yanit.replayed     # True: aynı anahtarın ilk yanıtı yeniden döndü (Idempotent-Replayed)
yanit.request_id   # x-request-id
yanit.rate_limit   # RateLimit(limit=120, remaining=117, reset=41): RateLimit-* başlıkları, yoksa None
yanit.mode         # Rewloy-Mode
yanit.data         # kampanya
```

`request(işlem, …)` her işlemi çağırır (`operationId` ya da metot adıyla) ve
yanıtın tamamını döndürür: `data`, sayfalı listede `meta`, `status`,
`headers`, `request_id`, `rate_limit`, `mode` ve `replayed`. Adres parametreleri
`path={"serial": …}` ile verilir. `data` burada tipli değildir; `typing.cast`
ya da metodun kendisi.

`mode`, yanıtın `Rewloy-Mode` başlığıdır: `live` ya da `test`. Başlık yoksa
`None`. Canlı akışta aynı bilgi `akis.mode`dadır.

## Test modu

Gerçek müşterilere dokunmadan denemek için işletmenizin bir **test ortamı**
vardır: ona bağlı ayrı bir işletme (adı "· Test" ile biter); kendi
programları, müşterileri, kartları, anahtarları ve webhook'ları. Panel →
Geliştirici → "Test ortamını aç" ya da `POST /v1/test/environment`. Orada
oluşturulan anahtar `rwk_test_` ile başlar ve aynı adreste, aynı yollarla
çalışır:

```python
rewloy = Rewloy(api_key=os.environ["REWLOY_TEST_KEY"])   # rwk_test_…
yanit = rewloy.request("getPass", path={"serial": seri})
yanit.mode   # "test"
```

- Test ortamı hiçbir şey göndermez (e-posta, bildirim, SMS); kartlar
  cüzdanlara eklenmez. Gönderilmeyenler `GET /v1/test/messages` ile okunur.
- Webhook'lar teslim edilir ve `Rewloy-Test: 1` başlığıyla `"test": true`
  taşır.
- Gerçek müşteri verisini test ortamına girmeyin.
- `reset_test_environment` (1.2.0'dan beri) müşterileri, kartları, kodları ve
  kayıtları siler; ortamın kimliği, programları, şubeleri, anahtarları ve
  webhook'ları kalır, entegrasyonunuz aynı anahtarla sürer. Bir anahtar
  sızdıysa `body={"revokeKeys": True}` anahtarları da geçersiz kılar ve
  webhook'ları kapatır. Yanıt `deleted` ve `kept` sayılarını verir; `closed`
  artık hep `None`dır.
- POS için anahtar: `create_api_key(body={"kind": "pos", "locationId": sube_id, "register": "Kasa 1", "password": sifre})`
  hazır Kasa rolüyle yalnız o şubede çalışan bir anahtar oluşturur; yanıttaki
  `baseUrl` POS'a yazılacak adrestir.
- `list_all_batches` işletmenin bütün hediye kartı, kupon ve indirim kodlarını
  sayfalar (`status` süzgeci: `open`, `full`, `expired`, `closed` ya da
  `archived`; satırın `state`'i de bunlardan biri: `archived` kodun programı
  arşivde demektir, bağlantısı kart vermez). Arşivdeki bir programa kod
  oluşturmak `409 PROGRAM_ARCHIVED` verir.
- `send_batch_link` kodun bağlantısını yalnız kod kart verirken e-postayla
  gönderir: durdurulmuş kod `410 BATCH_CLOSED`, süresi dolmuş `410 BATCH_EXPIRED`,
  kartları bitmiş `410 BATCH_FULL`, programı arşivde olan `409 PROGRAM_ARCHIVED`
  verir ve e-posta gitmez (1.2.0'dan önce son üçünde de giderdi). Kodları
  `rewloy.types.ErrorCode` değerleri içindedir.

Ayrıntı: https://rewloy.com/gelistiriciler#test-ortamı

İşlem tablosu da dışa açıktır: `OPERATIONS["passAction"]` →
`OperationMeta(id, method_name, http_method, path, auth, merchant, idempotency, body, response, paged, stream, deprecated)`.

## Taşıma ve test etmek

Varsayılan taşıma `urllib`dir: bağımlılık yok, yönlendirme izlenmez (API
yönlendirmez; izlemek anahtarı başka yere taşıyabilir), yalnız `http` ve
`https`, ortam değişkenlerindeki vekil (`HTTPS_PROXY`) kullanılır. Her istek
yeni bir bağlantı açar. Bağlantı havuzu, HTTP/2 ya da kendi vekil ve sertifika
ayarlarınız için:

```python
import httpx
from rewloy import Rewloy
from rewloy.httpx_transport import HttpxTransport   # pip install "rewloy[httpx]"

rewloy = Rewloy(api_key=anahtar, transport=HttpxTransport(httpx.Client(http2=True)))
```

`transport=` aynı zamanda sahte bir API'dir: `send(request)`, `open_stream(request)`
ve `close()` olan her nesne olur. Kendi kodunuzu ağsız test etmek için:

```python
from rewloy import Headers, HttpRequest, HttpResponse, Rewloy

class SahteTasima:
    def send(self, request: HttpRequest) -> HttpResponse:
        return HttpResponse(200, "OK", Headers([("Content-Type", "application/json")]),
                            b'{"data": {"serial": "ABCD-EFGH-JKLM", "balance": 3}}')
    def open_stream(self, request: HttpRequest): raise NotImplementedError
    def close(self) -> None: pass

rewloy = Rewloy(api_key="rwk_test", transport=SahteTasima())
assert rewloy.get_pass("ABCD-EFGH-JKLM")["balance"] == 3
```

### asyncio

İstemci eşzamanlıdır (decision 18: [docs/DECISIONS.md](docs/DECISIONS.md)). Bir
`asyncio` uygulamasında iş parçacığına verin; istemci iş parçacığı güvenlidir:

```python
kart = await asyncio.to_thread(rewloy.get_pass, "ABCD-EFGH-JKLM")
```

## Geliştirme

```sh
python3 -m venv .venv && . .venv/bin/activate
pip install -e ".[dev]"
python scripts/generate.py                               # canlı belgeden: openapi/openapi.json ve src/rewloy/generated/
python scripts/generate.py --file openapi/openapi.json   # kayıtlı belgeden
mypy && pytest
```

- `src/rewloy/generated/` elle düzenlenmez; üreteç `scripts/generator.py`'dir.
- Testler ağa çıkmaz: yerel bir sahte API (`http.server`) ile çalışır.
- CI her gün canlı belgeyi okur ve bir değişiklik varsa bir pull request açar.
- Kararlar: [docs/DECISIONS.md](docs/DECISIONS.md).

### Canlı testler

Kütüphanenin gerçek bir Rewloy **DEV** sunucusuna karşı uçtan uca denemesi
(sürüm adayı yayına çıkmadan önce). Kütüphane üzerinden çalışır, ham HTTP
kullanmaz; normal `pytest` bunları listelemez bile.

```sh
REWLOY_BASE_URL=https://dev.ornek.com REWLOY_API_KEY=rwk_test_… REWLOY_STAFF_TOKEN=rws_… python -m pytest -m live
```

- `REWLOY_BASE_URL` ve `REWLOY_API_KEY` yoksa testler **atlanır** (hata değil), nedeni yazılır.
- `REWLOY_STAFF_TOKEN` (ekip oturumu) isteğe bağlıdır: `reset_test_environment` bir
  API anahtarıyla çağrılamaz. Yoksa yalnız sıfırlama atlanır; `REWLOY_MERCHANT_ID`
  oturumun birden fazla işletmesi varsa hangisi olduğunu söyler.
- **Güvenlik:** önce kimliksiz `GET /v1/meta` sorulur ve `"environment": "dev"`
  değilse (başka değer ya da alan yok) koşu hata koduyla (3) durur. Yalnız
  `rwk_test_` anahtarı kabul edilir; ilk yanıt `Rewloy-Mode: test` demelidir.
- Kapsam: meta ve işletme, programlar (damga, hediye kartı), kart verme, kasa
  görünümü, `record_sale`, `pass_action`, işlem listesi, `reverse_sale` ve
  `reverse_action`, müşteri arama, kod grupları (`list_all_batches`, bağlantı
  gönderme ret durumları, `update_batch`), webhook'lar (1.3.0 olaylarına abonelik
  dahil), `Idempotency-Key`, `RateLimit-*`, hata nesneleri, sayfalama, kazanım
  kuralları (ürün grubu, kurallar, `preview_earn`, `preview_sale`, fiş satırlı
  `record_sale` ve `earn` dökümü, satır iadesi), şube QR'ı (herkese açık sayfa,
  QR görüntüleri ve baskı, QR listesi, dondurma reddi, `copy_program`) ve en
  sonda test ortamını sıfırlama. Alanlara göre
  geçen/kalan özeti basılır; bir testin hata vermesi çıkış kodunu sıfırdan farklı yapar.
- Testler oluşturduklarını kaldırır (webhook silinir, kod kapatılır, programlar
  sıfırlamadan sonra silinir); sıfırlama günde en çok 5 kez yapılabilir.
- Henüz kapsanmayanlar: [tests/live/TODO.md](tests/live/TODO.md).

## Belgeler

| | |
|---|---|
| Başlarken | https://rewloy.com/gelistiriciler |
| API referansı | https://rewloy.com/gelistiriciler/api |
| OpenAPI 3.1 | https://app.rewloy.com/v1/openapi.json |
| Hata kodları | https://rewloy.com/gelistiriciler/hatalar |
| API'nin değişiklik günlüğü | https://rewloy.com/gelistiriciler/degisiklikler |
| Bu kütüphanenin değişiklikleri | [CHANGELOG.md](CHANGELOG.md) |

**Sürümler:**
- Kütüphane anlamsal sürümleme ([SemVer](https://semver.org)) kullanır. 1.0'a
  kadar arayüzü değişebilir.
- API'ye alan eklemek geriye uyumludur; kütüphanenin tipleri her gün
  güncellenir.
- Kalkacak bir uç nokta en az 180 gün önce duyurulur ve bu süre boyunca
  `Deprecation` ve `Sunset` başlıklarını taşır.

## Güvenlik

Bir güvenlik açığı bulursanız [SECURITY.md](SECURITY.md) dosyasındaki yoldan
özel olarak bildirin. Lütfen herkese açık issue açmayın.

## Lisans

[MIT](LICENSE)

---

## English

Developer docs (in Turkish): **https://rewloy.com/gelistiriciler**.

**The official Python library for the Rewloy API.**

> **Status: preview (0.x), published on PyPI. The API is stable; the
> library's interface may change until 1.0.**

The documentation of the API itself is in Turkish (links above). In short:

- Every operation of the API is a method named by its operationId in
  snake_case (`passAction` is `pass_action`), typed with `TypedDict`s from the
  OpenAPI document, which CI reads daily and regenerates from. `mypy --strict`
  passes.
- No dependencies: Python 3.9 or later and the standard library (`urllib`,
  `json`, `hmac`). An optional `httpx` transport adds pooling and HTTP/2.
- Safe retries, `Idempotency-Key` handling, pagination, server-sent events,
  webhook signature verification and deprecation warnings.

### Install

Python 3.9 or later:

```sh
pip install rewloy
```

### Use

```python
import os
from rewloy import Rewloy

rewloy = Rewloy(api_key=os.environ["REWLOY_API_KEY"])   # or staff_session=…, merchant=… or holder_session=…

created = rewloy.issue_pass(body={"programId": program_id, "email": email, "kvkkConsent": True})
sale = rewloy.record_sale(
    created["serial"],
    body={"locationId": location_id, "amountMinor": 4550, "reference": f"receipt-{receipt_no}"},   # amount in the card's currency, minor units
    idempotency_key=f"till3-z0187-r{receipt_no}",
)

# A gift-card spend rung up by mistake? Void it by the key it was sent with:
rewloy.pass_action(
    created["serial"],
    body={"action": "spend", "locationId": location_id, "amountMinor": 2500},
    idempotency_key=f"till3-z0187-s{receipt_no}",
)
voided = rewloy.reverse_action(created["serial"], body={"actionKey": f"till3-z0187-s{receipt_no}"})
print(voided["undone"], voided["restored"], voided["balance"])   # 'spend', 2500, the balance again
```

- **Till.** `record_sale` writes a completed sale to a card (the card type and
  the programme's own rule decide what is written); `get_pass` returns the
  card's structured fields (`programName`, `currency`, `stamps`, `points`,
  `money`, `customer`); `reverse_sale` takes a refunded sale back:
  `rewloy.reverse_sale(serial, body={"saleKey": key})`. A void is
  `reverse_action`: it takes back a `pass_action` that was a mistake (`spend`,
  `spend-points`, `redeem-stamps`, `redeem-reward`, `use`), found by the
  `Idempotency-Key` you sent with it (`actionKey`) or its `reference`; it needs
  no `Idempotency-Key` of its own, and a repeat answers `duplicate: True`:
  `rewloy.reverse_action(serial, body={"actionKey": key})`. A till that queues
  sales while offline sends `occurredAt` (ISO 8601 with the UTC offset, not in
  the future) with `record_sale`, so the card's history shows when the sale
  really happened; the queued `idempotency_key` makes the resend safe.
  `pass_action` takes an optional `reference` too, and its answer is a `Union`
  of two `TypedDict`s: the balance-card answer (`balance`) or the coupon /
  discount-card answer (`status`, `uses`, `usesLeft`); `"uses" in answer`
  narrows it for mypy and pyright.
- **`card` on write answers.** `record_sale`, `pass_action`, `reverse_sale` and
  `reverse_action` answer with `card`: the card after the write, the fields of
  `get_pass` except `customer`, read in the same transaction (on a replay,
  `duplicate: True`, it is the card's **current** state). A key without
  `passes.read` in the card's programme gets `card: None`. `record_sale`'s
  `reversed: True` (replays only) says the sale written under that key was
  taken back since: send a new key to write the receipt again. For "can I act
  now" read `card["actions"][i]["ready"]`; `rewardReady` means "reward ready"
  only for stamp and points cards (always `True` on VIP, any balance on cashback
  and gift cards).
- **Recent operations.** `list_pass_operations` lists a card's ledger
  operations, newest first and paged, for a till's "last operations" screen:
  `undoWith` (`"sale/reverse"` or `"actions/reverse"`), `reversible` and, for
  this credential's own operations, `saleKey` / `actionKey` to pass straight to
  `reverse_sale` / `reverse_action`.
- **Rejected `occurredAt`** is a `400 VALIDATION` whose
  `err.details[0]["reason"]` is `in_future`, `too_old`, `before_issue` or
  `invalid` (treat an unknown reason as `invalid`).
- **Idempotency keys.** `record_sale`, `pass_action`, `send_campaign` and
  `refund_shop_redemption` need an `Idempotency-Key`: the API's OpenAPI document
  marks the header required for them, so `idempotency_key` is a required
  keyword argument (leave it out and you get a `TypeError`, or a `ValueError`
  through `request()`, before anything is sent). The client never makes one up
  for you (a generated key would not survive a restart of your app). The key
  must be 8–64 printable ASCII characters (0x21–0x7E); a non-ASCII key such as
  `fiş-0042` is refused client-side with a `ValueError` before anything is
  sent. Where the header is optional (for example `issue_pass`) the client
  still generates a UUID and reuses it on every retry of the call. A key is unique **for good per credential**: do not use the
  receipt number alone (fiscal receipt numbers restart after the Z report) but
  register + Z number + receipt number, or a UUID stored with the sale. The
  receipt number goes in `reference`.
- **Receipt lines and earn rules (API 1.3.0).** `record_sale` takes `lines`
  (up to 500) and, with them, `receiptDiscountMinor`. A programme with **earn
  rules** classifies the lines into product groups and applies the rules;
  a programme without rules, and a sale without lines, earn as before. The
  answer of a sale sent with lines carries `earn`: each line's `status` and
  share, what each rule did and the total step by step. `preview_sale` returns
  what `record_sale` would answer now (`preview: True`) and writes nothing;
  `preview_earn` does the same without a card, also with unsaved draft rules
  (`ruleSet`). Groups and rules: `create_earn_group`, `put_earn_rules` (send the
  `revision` you read, or `409 REVISION_CONFLICT`), `get_earn_rules`,
  `list_earn_templates`, `list_seen_lines`. `reverse_sale(serial, body={"saleKey": key, "lines": [{"lineId": "1", "quantity": 1}]}, idempotency_key=…)`
  refunds some lines and takes back only the difference (needs an
  `Idempotency-Key`; the answer has `linesLeft`); `LINE_NOT_FOUND` and
  `LINE_ALREADY_REFUNDED` refuse. The Turkish section above has a worked example.
- **Branch QR and freeze (API 1.3.0).** Every branch has a permanent QR
  (`branch["qr"]`: `code`, `url`, `state`). `public_branch(code)` needs no
  credential; `location_qr_svg`, `location_qr_png` (`query={"size": 1024}`),
  `location_qr_sheet_pdf` and `location_qr_sheet_svg` return `bytes`;
  `get_location_qr_items` / `put_location_qr_items` (with the `version` you read:
  `409 QR_LIST_CHANGED`) manage what the QR offers. `freeze_location` needs a
  team session and the person's password (a key gets
  `403 CREDENTIAL_NOT_ALLOWED`); a frozen branch's till refuses new work with
  `409 LOCATION_FROZEN`, and a business whose every branch is frozen with
  `BUSINESS_FROZEN`. `update_batch` edits a code after it was made;
  `copy_program` copies a gift card, coupon or discount card with another value
  (a loyalty card: `422 NOT_AN_INSTRUMENT`); `extend_program_cards` extends the
  existing cards.
- **Base URL.** `Rewloy(api_key=key, base_url="https://staging.example.com")`
  or `base_url="https://staging.example.com/v1"`: with or without a trailing
  `/v1` (and trailing slashes), the client appends `/v1/...` itself. Default
  `https://app.rewloy.com`.
- **Test mode.** Open the test environment (panel → Developer, or
  `POST /v1/test/environment`) and use its `rwk_test_` key at the same address:
  a separate test business that sends nothing and never reaches real
  customers. Webhooks are delivered with `Rewloy-Test: 1`.
- **Arguments.** Path parameters are positional. `query`, `body`, `merchant`,
  `idempotency_key`, `timeout` (seconds) and `max_retries` are keywords. The
  dicts use the API's own key names.
- **Results.** A method returns the answer's `data`: a `TypedDict` or list, a
  `Page` (`.data`, `.meta`) for paged lists, `None` for 204, `bytes` for files.
  Types are in `rewloy.types` (`from rewloy.types import IssuePassBody`).
- **The whole answer.** `rewloy.request("sendCampaign", body=…)` returns
  `status`, `headers`, `request_id`, `rate_limit` (`RateLimit(limit, remaining, reset)` from the `RateLimit-*` headers, `None` when absent), `mode` (the `Rewloy-Mode` header: `live` or `test`) and `replayed` (`Idempotent-Replayed`) with `data`.
- **Pagination.** `rewloy.paginate("listCustomers", query=…)` iterates the
  items of every page, lazily.
- **Streams.** `with rewloy.live_feed() as stream: for event in stream: …`
  iterates server-sent events (`event`, `data`, `id`, `json()`). It reconnects
  with `Last-Event-ID` unless `reconnect=False`; `close()` works from another
  thread.
- **Threads and asyncio.** The client is thread-safe. It is synchronous:
  in async code use `await asyncio.to_thread(rewloy.get_pass, serial)`.
- **Testing.** `transport=` takes anything with `send()`, `open_stream()` and
  `close()`; the README above has a fake.

### Webhooks

Verify the **raw** body (`request.get_data()` in Flask, `await request.body()`
in FastAPI, `request.body` in Django) with the secret shown when the webhook
was created:

```python
event = verify_webhook(raw_body, headers.get("Rewloy-Signature"), secret)
```

- **Check.** `Rewloy-Signature: t=<unix seconds>,v1=<hex HMAC-SHA256(secret,
  "<t>.<raw body>")>` is compared with `hmac.compare_digest`, and `t` must be
  within 300 seconds.
- **Refusal.** On failure it raises `WebhookSignatureError`: answer 400.
- **Headers.** `Rewloy-Event` is the event type. `Rewloy-Delivery` is the
  same on every retry of a delivery: deduplicate on it. Delivery is at least
  once.

`rotate_webhook_secret` gives a webhook a new secret (returned only in that
answer); the old one keeps signing for 24 hours, so `Rewloy-Signature` carries
two `v1` values and the delivery has `Rewloy-Signature-Rotating: 1`.
`verify_webhook` tries every `v1` and every secret you pass:
`[new_secret, old_secret]`. `delete_webhook` removes a webhook and its delivery
history for good.

A webhook object (`list_webhooks`, `get_webhook`, `set_webhook_status`, and the
`webhook` of `create_webhook` and `rotate_webhook_secret`) always carries two
fields, each `str | None` (a date-time), null when it does not apply:

- `pausedUntil`: your receiver failed twice in a row (`5xx`, `429`, a connection
  error or no answer), so the open webhook's deliveries wait until this moment
  and are then retried on their own (60 seconds). Null when it is not paused
  or the webhook is off.
- `resumableUntil`: the **rules** turned the webhook off and its pending
  deliveries are kept (until 24 hours after it closed). Turn it on before this
  moment (`set_webhook_status(id, body={"active": True})`) and it
  carries on where it stopped: the kept deliveries go at once and the events
  that happened meanwhile arrive too. Null while it is on, when a person or a
  key turned it off, or once the time has passed.

Also in Rewloy 1.2.0 (library 0.2.4): `create_api_key(body={"kind": "pos", "locationId": …, "register": …, "password": …})`
(a till key bound to one branch); `reset_test_environment(body={"revokeKeys": True})`
(keeps the test business, programmes and keys; revokes keys only when asked);
`list_all_batches` (every gift-card, coupon and discount code of the business,
with the `archived` state); `409 PROGRAM_ARCHIVED` when creating a code for an
archived programme.
`send_batch_link` e-mails a code's link only while the code issues a card:
`410 BATCH_CLOSED` (stopped), `410 BATCH_EXPIRED` (past its date),
`410 BATCH_FULL` (every card given) and `409 PROGRAM_ARCHIVED` (its programme is
archived) refuse it and no mail goes; before 1.2.0 the last three were sent
anyway. The codes are in the `rewloy.types.ErrorCode` values.

Also in Rewloy 1.3.0 (library 0.3.0): the events `pass.extended` (a card's end
moved later: `reason` `merchant` or `branch_frozen`, `from`, `to`: read `data`
as `PassExtendedData`), `location.frozen`, `location.unfrozen`,
`business.paused` and `business.resumed` (not about a card: `card` and
`customer_id` are `None`: `BranchEvent`). An existing webhook gets them only if
it selects them. `WebhookEvent` is now `PassEvent | BranchEvent | WebhookTestEvent`,
so narrow on `event["type"]` before reading `event["data"]["unit"]`.

### Errors, retries, deprecations

- **Errors.** Failures raise `RewloyError` with `status`, `code` (the API's
  stable code), `title`, `detail`, `details`, `request_id`, `rate_limit` and `body`.
  Subclasses: `RateLimitError` (`retry_after`), `RewloyConnectionError` and
  `RewloyTimeoutError`. Misuse raises `ValueError` or `TypeError`.
- **What is retried.** Network errors, timeouts, 429, 502–504 and
  Cloudflare's 520–524, up to `max_retries` (default 2), with exponential
  backoff and jitter, honouring `Retry-After`. The timeout covers a whole
  attempt, body included.
- **Only when safe.** Only GET, PUT, DELETE, and POST with an
  `Idempotency-Key`, are retried.
- **Deprecations.** A deprecated operation's answers carry `Deprecation`,
  `Sunset` and `Link`. The client issues one `DeprecationWarning` per
  operation, attributed to your calling line. Under `-W error` the call still
  returns and the notice goes to the `rewloy` logger.

### Live tests

An end-to-end run of the library against a real Rewloy **DEV** server, for release candidates. It goes through the
library, never raw HTTP; a normal `pytest` does not even list these tests.

```sh
REWLOY_BASE_URL=https://dev.example.com REWLOY_API_KEY=rwk_test_… REWLOY_STAFF_TOKEN=rws_… python -m pytest -m live
```

- Without `REWLOY_BASE_URL` and `REWLOY_API_KEY` the tests are **skipped** with the reason, not failed.
- `REWLOY_STAFF_TOKEN` (a team session) is optional: `reset_test_environment` cannot be called with an API key. Without it only the
  reset is skipped. `REWLOY_MERCHANT_ID` names the business if the session has several.
- **Safety:** the run first asks `GET /v1/meta` without credentials and stops with exit code 3 unless it says `"environment": "dev"`
  (another value or a missing field stops it). Only an `rwk_test_` key is accepted, and the first answer must carry `Rewloy-Mode: test`.
- Covers: meta and business, programs (stamp, gift card), issuing, the till view, `record_sale`, `pass_action`, the operations
  list, `reverse_sale` and `reverse_action`, customer search, batches (`list_all_batches`, `update_batch`, `send_batch_link`
  refusals), webhooks (also subscribing to the 1.3.0 events), `Idempotency-Key`, `RateLimit-*`, error objects, pagination,
  earn rules (a product group, saved rules and a stale revision, `preview_earn`, `preview_sale`, `record_sale` with
  receipt lines and its `earn` explanation, a line refund and its refusals), the branch QR (public page, QR images and
  print sheet, the QR list, the freeze refusal for a key, `copy_program`), and last the test reset. A passed/failed summary per area is
  printed, and any failure makes the exit code non-zero.
- It removes what it creates (webhooks deleted, codes closed, programs deleted after the reset); a business may reset five times a day.
- Not covered yet: [tests/live/TODO.md](tests/live/TODO.md).

### Security and licence

Report vulnerabilities privately, as [SECURITY.md](SECURITY.md) says.
[MIT](LICENSE) licensed.

## Yeni sürüm yayımlamak / Releasing

`src/rewloy/_version.py`'deki sürümü ve CHANGELOG'u güncelleyin, commit'leyin, `v<sürüm>` etiketini gönderin. `release.yml` PyPI'a güvenilir yayıncı (trusted publishing) yoluyla, jetonsuz yayımlar.

Bump the version in `src/rewloy/_version.py` and the changelog, commit, and push a `v<version>` tag. `release.yml` publishes to PyPI through trusted publishing, with no token.
