7. Adım 4 — Kasada kupon kullanımı
Bu, her gün çalışan kısımdır. Tam olarak bu sırayla yapın.
7.1 Kasiyer kodu girer
Müşteri kuponu Fırsat uygulamasında iki şekilde gösterir:
- 6 haneli kod (ör.
482913). Kasiyer elle yazar. - QR kod. Kasiyer okutur. İçinde ya bir link (
https://ile başlar) ya da uzun bir token vardır.
Yazılan veya okutulanı aynen code alanında gönderin. Kısaltmayın, linkin bir parçasını almayın, büyük/küçük harfini değiştirmeyin. Üç biçimi de biz çözeriz; boşlukları da yok sayarız: 482 913 ile 482913 aynı koddur.
Ayrıca bu kasanın hangi şubeye ait olduğunu (5.3'te sakladığınız branchId) bilmeniz ve masa ya da sipariş için açık bir fiş olması gerekir.
7.2 Önizleme
curl -X POST https://partner-api.firsat.dev/api/partner/pos/v1/redemptions/preview \
-H 'Authorization: Bearer fpt_Xk3...' \
-H 'X-Firsat-Pos: adisyo/k7qm2x' \
-H 'X-Firsat-Connection: 0199a3c2-...' \
-H 'Content-Type: application/json' \
-d '{ "branchId": "0199a1b1-...", "code": "482913" }'Olumlu cevap:
{
"previewId": "0199c7d4-…",
"state": "valid",
"coupon": { "id": "0199c001-…", "dealId": "0199d001-…", "remaining": 1 },
"lines": [
{ "menuItemId": "0199b001-…", "externalItemId": "ADS-1042", "name": "Latte", "quantity": 2, "unitPrice": 95 },
{ "menuItemId": "0199b002-…", "externalItemId": null, "name": "Günün Tatlısı", "quantity": 1, "unitPrice": 140 }
],
"saleUnit": 250,
"menuUnit": 330,
"expiresAt": "2026-10-08T12:40:00.000Z"
}Cevaplar bu rehberde anlatılmayan alanlar taşıyabilir; onları yok sayın.
Alanların anlamı:
| Alan | Anlamı |
|---|---|
previewId | Onayda kullanacağınız id. null ise: devam etmeyin. |
state | valid = devam edin. Başka her değer kasiyere gösterilecek bir sebeptir (aşağıdaki tablo). |
coupon.remaining | Kuponun bu kullanımdan önce kaç hakkı olduğu. 1 ise bu son kullanımdır. |
lines | Fişe eklenecek ürünler. |
saleUnit | Fırsat'ın bu kullanım için işletmeye ödediği tutar. Kasiyer bu tutarı "Fırsat" ödeme tipiyle kapatır; kupon satırları fişinizde daha az tutuyorsa o kadarını (7.3). |
menuUnit | Ürünlerin tam menü fiyatı (unitPrice × quantity toplamı). Sadece gösterim için. |
expiresAt | Bu saatten önce onaylayın. İlk okutmadan itibaren en fazla 10 dakika. |
Her state ve ne yapılmalı:
state | previewId | Anlamı | Kasiyere gösterilecek (önerilen metin) |
|---|---|---|---|
valid | dolu | Kupon kullanılabilir. | Ürünleri gösterin ve 7.3'e geçin. |
used | null | Daha önce kullanılmış. | "Bu kupon daha önce kullanılmış." |
expired | null | Kuponun geçerliliği bitmiş. | "Kuponun süresi dolmuş." |
not_yet_redeemable | null | Henüz kullanılamaz (daha sonra başlıyor). | "Kupon henüz kullanılamaz." |
out_of_hours | null | Bu saatte kullanılamaz. | "Kupon bu saatte kullanılamaz." |
branch_invalid | null | Bu şubede geçerli değil. | "Kupon bu şubede geçerli değil." |
not_found | null | Bu işletmede bu koda ait kupon yok. | "Kod bulunamadı. Kodu kontrol edip tekrar deneyin." |
open_elsewhere | null | Kupon şu anda bu işletmenin başka bir şubesinde işlemde. | "Bu kupon şu anda başka bir şubede işlemde. Birkaç dakika sonra tekrar okutun." |
previewId null ise durun. Fişe hiçbir şey yazmayın.
Önemli davranışlar:
- Önizleme hiçbir şey harcamaz. Kasiyer vazgeçerse önizlemeyi unutun; kendi kendine sona erer.
- Aynı kuponu aynı şubede iki kez okutmak (örneğin iki farklı kasada) iki seferde de aynı
previewId'yi döner. Sadece bir onay başarılı olur; diğeri409 POS_REDEMPTION_PREVIEW_USEDalır. - Hatalı kodlar sayılır. Çözülemeyen çok fazla kod (bilinmeyen 6 haneli kodlar, bozuk QR kodlar) önizlemeyi bir süreliğine engeller (bölüm 10).
Önizleme hataları (state yerine HTTP hatası):
| Cevap | Anlamı | Ne gösterilmeli |
|---|---|---|
400 VALIDATION ve details.code: "QR_TOKEN_EXPIRED" | Eski bir QR kod (ör. ekran görüntüsü). Uygulamadaki QR kendini yeniler. | "Kodun süresi dolmuş. Müşteriden uygulamada kodu yenilemesini isteyin." |
400 VALIDATION (diğer) | Kod 6 haneli sayı, Fırsat linki veya Fırsat token'ı değil (ör. rastgele bir barkod okutuldu). | "Geçersiz kod. Müşterinin Fırsat uygulamasındaki QR'ı okutun veya 6 haneli kodu girin." |
404 NOT_FOUND | branchId bu bağlantıya ait değil (veya artık değil). | GET /branches ile listeyi yenileyin. "Bu şube Fırsat bağlantısında yok. İşletme sahibi şubeyi Fırsat Patron'dan eklemeli." gösterin. |
429 REDEEM_LOCKED | Bu şubede çok fazla hatalı kod; birkaç dakikalığına kilitli. | "Çok fazla hatalı deneme. Biraz sonra tekrar deneyin." |
429 RATE_LIMITED | Hesabınız çok fazla hatalı kod veya çok fazla istek gönderdi (bölüm 10). Retry-After saniye bekleyin. | "Çok fazla deneme. Biraz sonra tekrar deneyin." |
7.3 Fişe yazma
Sadece previewId doluysa.
Hedef: işiniz bittiğinde fişteki kupon satırları saleUnit eder ve kasiyer bu tutarı "Fırsat" ödeme tipiyle kapatır. Kupon satırları fişinizde zaten saleUnit'ten az tutuyorsa indirim eklemeyin ve tuttukları kadarını kapatın: "Fırsat" ile asla kupon satırlarının fişte bıraktığından fazlasını kapatmayın.
-
linesiçindeki her satırı ekleyin:externalItemIddoluysa sizin eşlenen ürününüzü,nullise adıname, fiyatıunitPriceolan özel bir ürünü (POS'unuzda özel ürün yoksa 6.4'e bakın). Adet olarakquantity'yi kullanın. -
Bu satırlara bir indirim ekleyin, kalan tutar
saleUnitolsun (satırlar zaten daha az tutuyorsa indirim yok):satirToplami = eklenen satırların, fişinizin gerçekte uyguladığı fiyatlarla toplamıindirim = max(0, satirToplami − saleUnit) -
Kasiyer Fırsat tutarını "Fırsat" ödeme tipiyle kapatır (bu ödeme tipini işletme sizin POS'unuzda tanımlar):
firsatOdemesi = min(satirToplami, saleUnit)Müşterinin ayrıca sipariş ettiği her şey normal ödenir (nakit, kart…).
Sayılarla örnek 1. Önizleme 7.2'deki: 2 × Latte (95 TL), 1 × Günün Tatlısı (140 TL), saleUnit 250. Bu restoranın POS'unda Latte 95 değil 100 TL. Müşteri ayrıca bir su (20 TL) söylüyor.
| Fiş satırı | Tutar |
|---|---|
Latte × 2 (sizin ADS-1042 ürününüz, sizin fiyatınızla 100) | 200,00 |
| Günün Tatlısı × 1 (özel ürün, 140) | 140,00 |
| Üstteki iki satıra Fırsat indirimi: (200 + 140) − 250 | −90,00 |
| Su × 1 (ayrıca sipariş) | 20,00 |
| Fiş toplamı | 270,00 |
| "Fırsat" ödeme tipiyle ödenen | 250,00 |
| Müşterinin ödediği (nakit/kart) | 20,00 |
Kupon satırları (200 + 140 − 90) tam olarak 250 = saleUnit bırakır. Onayda (7.4) Latte fiyatınızın 100 olduğunu bildirebilirsiniz; 10 TL'lik fark işletmenin raporları için kaydedilir (priceDrift). Fırsat'ın ödediği tutarı değiştirmez.
Sayılarla örnek 2: satırlar saleUnit'ten ucuz. Aynı önizleme, ama bu POS Latte'yi 50 TL'ye satıyor. Kupon satırları 2 × 50 + 140 = 240 tutar; saleUnit 250'den az.
| Fiş satırı | Tutar |
|---|---|
Latte × 2 (sizin ADS-1042 ürününüz, sizin fiyatınızla 50) | 100,00 |
| Günün Tatlısı × 1 (özel ürün, 140) | 140,00 |
| Fırsat indirimi | yok |
| Fiş toplamı | 240,00 |
| "Fırsat" ödeme tipiyle ödenen: min(240, 250) | 240,00 |
Fırsat işletmeye yine saleUnit (250) tutarını kendi hesaplaşmasıyla öder; 10 TL'lik fark işletmenindir.
7.4 Onay
Fiş yazıldıktan sonra onaylayın:
curl -X POST https://partner-api.firsat.dev/api/partner/pos/v1/redemptions \
-H 'Authorization: Bearer fpt_Xk3...' \
-H 'X-Firsat-Pos: adisyo/k7qm2x' \
-H 'X-Firsat-Connection: 0199a3c2-...' \
-H 'Idempotency-Key: 6f1c2b8e-4c1d-4a8b-9e3f-2a7d5c9b1e04' \
-H 'Content-Type: application/json' \
-d '{
"previewId": "0199c7d4-...",
"externalOrderRef": "ADS-CHECK-88412",
"lines": [
{ "menuItemId": "0199b001-...", "unitAmount": 100, "quantity": 2 },
{ "menuItemId": "0199b002-...", "unitAmount": 140, "quantity": 1 }
]
}'| Alan | Zorunlu mu? | Anlamı |
|---|---|---|
previewId | evet | Önizlemeden gelen. |
externalOrderRef | evet | Sizin fiş / sipariş numaranız (en fazla 128 karakter). Fırsat bunu işletmeye gösterir, fişi bulabilsinler diye. |
lines | hayır | Kupon satırları, fişinizin gerçekte uyguladığı fiyatlarla (en fazla 100). Sadece fiyat farkı raporu için. |
Idempotency-Key, adım adım
Bu header, bir kuponu asla iki kez harcamadan güvenle tekrar denemenizi sağlar. previewId başına tek Idempotency-Key. O onayın her tekrar denemesi aynı key'i ve aynı gövdeyi gönderir.
- Bir
previewIdiçin ilk onayı göndermeden önce yeni rastgele bir key üretin (UUID v4 idealdir) ve fişle birlikte,previewId'nin yanında saklayın (veritabanınızda veya yerel depolamada). - Onayı bu key ile gönderin.
- Bu onayı tekrar denemeniz gerekirse (yalnızca 7.6'daki geçici cevaplarda), aynı key'i ve birebir aynı gövdeyi gönderin.
- Yeni bir
previewId(sonraki kupon ya da410'dan sonra yapılan yeni önizleme) yeni bir key alır. Bir key'i asla tekrar kullanmayın, günler sonra bile. Kaydı tuttuğumuz sürece her key'i hatırlarız; eski bir key eski cevabı tekrar döner.
Kurallar: 1–128 karakter, sadece yazdırılabilir ASCII (boşluk ve Türkçe karakter yok). UUID her zaman uyar.
Makbuz
{
"redemptionId": "0199e9f0-…",
"previewId": "0199c7d4-…",
"couponId": "0199c001-…",
"dealId": "0199d001-…",
"externalOrderRef": "ADS-CHECK-88412",
"state": "used",
"remaining": 0,
"saleUnit": 250,
"priceDrift": 10,
"redeemedAt": "2026-10-08T12:31:04.000Z"
}| Alan | Anlamı |
|---|---|
redemptionId | Bu kullanımın Fırsat'taki id'si. Fişle birlikte saklayın. Destek ekibi buna ihtiyaç duyar. |
state | Kuponun son kullanımıysa used, hakkı kaldıysa active. |
remaining | Bu kullanımdan sonra kalan hak. |
saleUnit | Fırsat'ın bu kullanım için işletmeye ödediği tutar. Fırsat ödemesi min(satirToplami, saleUnit) olur (7.3). |
priceDrift | Bildirdiğiniz satırlar üzerinden (sizin fiyatınız − Fırsat menü fiyatı) × adet toplamı; lines göndermediyseniz null. |
Başarılı olursa kasiyere şunu gösterin: "Fırsat kuponu eklendi. <tutar> TL'yi Fırsat ödeme tipiyle kapatın." Buradaki <tutar>, 7.3'teki min(satirToplami, saleUnit) değeridir (ör. "Fırsat kuponu eklendi. 250 TL'yi Fırsat ödeme tipiyle kapatın."). Mümkünse redemptionId'yi fişe yazdırın.
7.5 Onay reddedilirse
Makbuz olmayan ve 7.6'daki geçici cevaplardan biri de olmayan her cevap kesindir. 7.3'te eklediğiniz satırları ve indirimi fişten kaldırın, sebebi gösterin ve correlationId'yi loglayın:
| Cevap | Anlamı | Ne gösterilmeli |
|---|---|---|
409 POS_REDEMPTION_FAILED | Kullanımı reddettik. details.reason sebebi söyler (aşağıdaki tablo). | Sebebin metni. |
409 POS_REDEMPTION_PREVIEW_USED | Bu önizleme başka bir key ile zaten onaylandı (örneğin başka bir kasada) veya zaten reddedildi. | "Bu kupon başka bir işlemde kullanıldı." |
410 POS_REDEMPTION_PREVIEW_EXPIRED | expiresAt'ten sonra onayladınız. Hiçbir şey harcanmadı. | "Onay süresi doldu. Kodu tekrar okutun." Sonra önizlemeden baştan başlayın. |
404 NOT_FOUND | previewId bu bağlantıya ait değil. Sizin tarafınızda bir hata. | "İşlem tamamlanamadı. Kodu tekrar okutun." Doğru bağlantıyı ve önizlemeyi gönderdiğinizi kontrol edin. |
400 (herhangi bir kod) | İstek hatalı (eksik veya bozuk Idempotency-Key, eksik alan, eksik header). Sizin tarafınızda bir hata. | "İşlem tamamlanamadı. Kodu tekrar okutun." |
403 POS_CONNECTION_REVOKED | İşletme bağlantıyı iptal etmiş (5.5). | 5.5'teki iptal metni. |
403 (diğer kodlar) | Bir header hatası, ör. POS_SOLUTION_HEADER_MISMATCH. | "İşlem tamamlanamadı. Kodu tekrar okutun." |
409 POS_IDEMPOTENCY_KEY_CONFLICT | Bir key'i farklı bir gövdeyle tekrar kullandınız. Sizin tarafınızda bir hata (bölüm 11). | "İşlem tamamlanamadı. Kodu tekrar okutun." |
| Bu rehberde listelenmeyen her durum kodu veya kod | Ret olarak kabul edin. | "İşlem tamamlanamadı. Kodu tekrar okutun." |
POS_REDEMPTION_FAILED'da görebileceğiniz details.reason değerleri:
reason | Anlamı | Önerilen metin |
|---|---|---|
used | Bu arada kullanılmış. | "Bu kupon daha önce kullanılmış." |
expired | Bu arada süresi dolmuş. | "Kuponun süresi dolmuş." |
out_of_hours | Kuponun saatleri dışında. | "Kupon bu saatte kullanılamaz." |
branch_invalid | Bu şubede geçerli değil. | "Kupon bu şubede geçerli değil." |
not_yet_redeemable | Henüz kullanılamaz. | "Kupon henüz kullanılamaz." |
branch_link_revoked | İşletme önizlemeden sonra bu şubeyi bağlantıdan çıkarmış. | "Bu şubenin Fırsat bağlantısı kaldırılmış. İşletme sahibi şubeyi Fırsat Patron'dan yeniden eklemeli." GET /branches ile listeyi yenileyin. |
confirm_rejected | Diğer tüm retler. | "İşlem tamamlanamadı. Kodu tekrar okutun." |
İleride başka değerler eklenebilir. Tanımadığınız her sebep için genel metni gösterin.
Reddedilen bir onay aynı key ile tekrar gönderilebilir; aynı reddi tekrar döner.
7.6 Timeout ve tekrar deneme
Yalnızca aşağıda listelenen geçici cevaplarda tekrar deneyin. Diğer her cevapta durun ve fişi geri alın. Bu karar tablosu onaydan gelebilecek her cevabı kapsar:
| Gelen cevap | Kupon harcandı mı? | Ne yapılmalı |
|---|---|---|
200 + makbuz | Evet | Bitti. redemptionId'yi saklayın. |
409 POS_REDEMPTION_IN_PROGRESS | Henüz belli değil | Geçici. 1–2 saniye bekleyip aynı key ve gövdeyle tekrar deneyin. |
Timeout, bağlantı hatası, 502, 503, 504, diğer 5xx | Bilinmiyor | Geçici. Aynı key ve gövdeyle tekrar deneyin. Fişi geri almayın. |
401 POS_PARTNER_TOKEN_INVALID | Hayır (istek işlenmedi) | Geçici. Bir kez yeni token alıp aynı key ve gövdeyle tekrar deneyin (4.3). |
429 RATE_LIMITED | Hayır | Geçici. Retry-After saniye bekleyip aynı key ve gövdeyle tekrar deneyin. |
409 POS_REDEMPTION_FAILED / PREVIEW_USED, 410 PREVIEW_EXPIRED | Hayır | Durun. Fiş satırlarını geri alın (7.5). |
400 (herhangi bir kod) | Hayır | Durun. Fiş satırlarını geri alın; bir entegrasyon hatası (7.5). |
403 POS_CONNECTION_REVOKED, diğer 403 | Hayır | Durun. Fiş satırlarını geri alın (7.5). |
404 NOT_FOUND | Hayır | Durun. Fiş satırlarını geri alın (7.5). |
409 POS_IDEMPOTENCY_KEY_CONFLICT | Bu istek değil | Durun. Fiş satırlarını geri alın. Bir key'i farklı bir gövdeyle tekrar kullandınız; bir entegrasyon hatası (bölüm 11). |
| Bu tabloda olmayan her durum kodu veya kod | Hayır sayın | Durun. Fiş satırlarını geri alın, genel metni gösterin (7.5), correlationId'yi loglayın. |
Sonucu bilinmeyen bir gönderimden sonra yalnızca kuponla ilgili bir cevapta geri alın. Bu onayın daha önceki bir gönderimi timeout'a düştüyse veya 5xx aldıysa kupon zaten harcanmış olabilir. Aynı key ile sonradan gelen 409 POS_REDEMPTION_FAILED, 409 POS_REDEMPTION_PREVIEW_USED veya 410 POS_REDEMPTION_PREVIEW_EXPIRED yine de kuponla ilgili gerçek bir cevaptır: geri alın. Diğer her "durun" cevabı (400, iki kez 401, 403, 404, tanımadığınız bir kod) kupon hakkında hiçbir şey söylemez: satırları fişte bırakın, "İşlem doğrulanamadı. Fişi olduğu gibi bırakın; işletme Fırsat desteğine başvursun." gösterin ve correlationId ile dev@firsat.com adresine yazın.
Tekrar deneme takvimi: 1 sn, 2 sn, 4 sn, 8 sn sonra, ardından her 10 sn'de bir deneyin. Yaklaşık 2 dakika sonra hâlâ belirsizse fişi olduğu gibi bırakın ve şunu gösterin: "Fırsat yanıt vermiyor, işlem doğrulanamadı. Lütfen birkaç dakika sonra 'Tekrar dene'ye basın." Saklanan aynı key ile tekrar deneyen bir "Tekrar dene" butonu bırakın. Birkaç dakika içinde aynı key her zaman kesin bir cevap alır: makbuz veya ret. Kupon asla iki kez harcanmaz.
7.7 Kasa akışının tamamı (pseudo-code)
function kuponKullan(fis, yazilanKod):
branchId = buKasa.firsatBranchId
connectionId = isletme.firsatConnectionId
onizleme = POST /redemptions/preview { branchId, code: yazilanKod }
if onizleme bir HTTP hatasıysa: hata metnini göster (7.2); return
if onizleme.previewId null ise: state metnini göster (7.2); return
if posOzelUrunEkleyemiyor and onizleme.lines içinde externalItemId == null olan satır varsa:
"Bu kupon bu kasada eşleşmemiş bir ürün içeriyor. İşletme sahibi ürünü eşlemeli." göster; return # 6.4
eklenenler = []
for satir in onizleme.lines:
if satir.externalItemId != null:
eklenenler += fis.urunEkle(satir.externalItemId, satir.quantity)
else:
eklenenler += fis.ozelUrunEkle(satir.name, satir.unitPrice, satir.quantity)
satirToplami = toplam(eklenenler.tutar) # fişinizin uyguladığı fiyatlarla
indirim = max(0, satirToplami - onizleme.saleUnit)
firsatOdemesi = min(satirToplami, onizleme.saleUnit) # kasiyerin "Fırsat" ile kapattığı tutar
if indirim > 0:
fis.indirimEkle(uzerine: eklenenler, tutar: indirim, etiket: "Fırsat")
key = yeniUuid() # previewId başına tek key
fis.kaydet(firsatIdempotencyKey: key, firsatPreviewId: onizleme.previewId) # GÖNDERMEDEN ÖNCE
govde = { previewId: onizleme.previewId, externalOrderRef: fis.numara,
lines: eklenenler {menuItemId, unitAmount, quantity} olarak }
loop, beklemeli tekrar deneme (7.6):
cevap = POST /redemptions (Idempotency-Key: key, govde) # her seferinde aynı key, aynı gövde
if cevap 200 ise:
fis.kaydet(firsatRedemptionId: cevap.redemptionId)
"Fırsat kuponu eklendi. {firsatOdemesi} TL'yi Fırsat ödeme tipiyle kapatın." göster; return
if cevap 401 ise: token'ı yenile; continue # bir kez; art arda ikinci 401 aşağıya düşer (4.3)
if cevap timeout / ağ hatası / 5xx / 409 POS_REDEMPTION_IN_PROGRESS / 429 ise:
bekle; continue # burada asla geri alma
# diğer her şey: 400, 403, 404, 409 FAILED / PREVIEW_USED / KEY_CONFLICT, 410
# ve tanımadığınız her kod
fis.kaldir(Fırsat satırları ve indirim)
hata metnini göster (7.5); cevap.correlationId'yi logla; return