Tüm yazılar Ürün

Kimse İlk Adımı Atmak İstemiyor: Emanet Akışı

Alıcı parayı, satıcı bileti önce vermek istemiyor. 2.El Bilet'te bu kilitlenmeyi tek bir düğmeyle değil, birbirini bekleyen durumlardan oluşan bir emanet akışıyla çözdüm.

DT
Demir Taşdemir Mobil Uygulama & Web Geliştirici
— dk okuma

2.El Bilet'in ilk sürümünde tek bir düğme vardı: “Satın Al”. Alıcı basıyor, satıcıya bildirim gidiyor, gerisi birbirini hiç tanımayan iki insanın iyi niyetine kalıyordu. Çalışmadı — çünkü kimse ilk adımı atmak istemiyor.

Sorun koddaki bir hata değildi. Alıcı “önce bileti devret” diyordu, satıcı “önce parayı yatır” diyordu. İkisi de haklıydı ve ikisi de bekliyordu. Bu bir kilitlenme ve tek bir düğmeyle çözülmüyor.

İki tarafın da haklı olduğu kilitlenme

İkinci el bilette risk simetrik. Alıcı parayı gönderdiği anda elinde hiçbir şey kalmıyor; satıcı bileti devrettiği anda geri alamıyor. İki taraf da karşısındakinin ortadan kaybolabileceğini biliyor.

Bunu tasarım aşamasında değil, ilk sürüm yayındayken gelen mesajlardan öğrendim. Gelen soruların çoğu teknik değildi: “Parayı yatırdım, karşı taraf bileti göndermezse ne olacak?” Uygulamanın bu soruya verecek bir cevabı yoktu.

Cevabın adı emanet: parayı ortada tutmak. Ama emanet bir düğme değil, bir akış. Ödemenin alındığı anla satıcıya aktarıldığı an arasında saatler, bazen günler geçiyor ve bu süre boyunca uygulamanın iki tarafa da “şu an ne oluyor, sıra kimde” sorusunu net biçimde anlatabilmesi gerekiyor.

Satın alma bir olay değil, bir süreç

İlk veri modelinde işlemi tek bir alanla temsil etmiştim: satildi. Bu alan yalnızca iki şey söyleyebiliyor — oldu ya da olmadı. Oysa gerçek hayatta aralarda duran bir sürü “yarı olmuş” hâl var: ödeme alındı ama bilet devredilmedi, bilet devredildi ama alıcı henüz kontrol etmedi, alıcı itiraz etti.

İlk refleksim yeni bayraklar eklemek oldu: odendiMi, teslimEdildiMi, onaylandiMi, iptalMi. Dört boolean on altı kombinasyon demek ve bunların çoğu anlamsız. “İptal edilmiş ama onaylanmış” gibi bir durumun oluşmasını engelleyen hiçbir şey yok; kontrolü her ekranda ayrı ayrı if yazarak yapmaya çalışıyorsunuz.

Bayrakları silip tek bir durum alanına geçtim. Yanına da bir liste koydum: hangi durumdan hangisine, kim tarafından geçilebilir. Uygulamanın geri kalanı bu iki şeyden türedi.

Kod yazmadan önce bir kâğıda dört sütun çizdim: durum, sıra kimde, beklenen adım ve süre dolarsa ne olacak. Dördüncü sütunu doldururken akışın yarısındaki boşlukları fark ettim — çünkü cevabı olmayan satırlar, sonradan destek mesajı olarak geri geliyor.

Durum Sıra kimde Beklenen adım Süre dolarsa
odeme_bekleniyor Alıcı Ödemeyi tamamlar İşlem iptal, bilet yeniden satışa çıkar
odeme_alindi Satıcı Bileti devreder, belgesini yükler İptal ve iade
bilet_iletildi Alıcı Onaylar ya da itiraz eder Otomatik onay
itiraz_acildi Ben İnceler, karar veririm
tamamlandi, iptal_edildi, iade_edildi Kapalı

Bu tabloyu doldurduktan sonra ekran tasarımı neredeyse kendiliğinden çıktı: her durum bir ekran hâli, “beklenen adım” sütunu da o ekrandaki tek birincil düğme.

Durumu istemci yazamaz

En önemli kural bu. Uygulama hiçbir zaman doğrudan durum alanına yazmıyor; yalnızca bir Cloud Functions çağrısı yapıp “şu geçişi yapmak istiyorum” diyor. Geçişin geçerli olup olmadığına sunucu karar veriyor. Firestore kurallarında bu alan istemci yazımına tamamen kapalı.

İzinli geçişler tek bir yerde duruyor. Böylece “satıcı ödeme alınmadan teslim işaretleyebiliyor mu” sorusunun cevabı, üç ayrı ekranın koduna dağılmış hâlde değil, tek bir haritada duruyor.

// Cloud Functions — izin verilen geçişler tek dosyada
const GECISLER = {
  odeme_bekleniyor: {
    odeme_alindi: ["odeme_saglayici"],
    iptal_edildi: ["alici", "zaman_asimi"]
  },
  odeme_alindi: {
    bilet_iletildi: ["satici"],
    iade_edildi:    ["satici", "zaman_asimi"]
  },
  bilet_iletildi: {
    tamamlandi:    ["alici", "zaman_asimi"],  // sessiz kalırsa otomatik onay
    itiraz_acildi: ["alici"]
  },
  itiraz_acildi: {
    tamamlandi:  ["yonetici"],
    iade_edildi: ["yonetici"]
  }
};

function gecisGecerliMi(mevcut, hedef, aktor) {
  const izinliler = GECISLER[mevcut] && GECISLER[mevcut][hedef];
  return Boolean(izinliler && izinliler.includes(aktor));
}

Geçişin kendisi de bir işlem (transaction) içinde yapılıyor ve istemci “ben şu anda hangi durumda olduğumu sanıyorum” bilgisini de gönderiyor. Bu küçük ayrıntı, çift dokunuşu ve arka planda açık kalmış eski ekranı tek hamlede çözüyor.

await db.runTransaction(async (t) => {
  const snap  = await t.get(islemRef);
  const islem = snap.data();

  if (islem.durum !== istek.beklenenDurum) {
    throw new Error("durum-degisti");     // çift dokunuş ya da eski ekran
  }
  if (!gecisGecerliMi(islem.durum, istek.hedefDurum, aktor)) {
    throw new Error("izinsiz-gecis");
  }

  t.update(islemRef, {
    durum:      istek.hedefDurum,
    siraKimde:  SIRA[istek.hedefDurum],
    sonTarih:   sonTarihHesapla(istek.hedefDurum),
    guncellendi: FieldValue.serverTimestamp()
  });

  // denetim izi: her geçiş ayrı bir kayıt
  t.set(islemRef.collection("gecmis").doc(), {
    onceki: islem.durum,
    sonraki: istek.hedefDurum,
    aktor,
    zaman: FieldValue.serverTimestamp()
  });
});
Geçmişi ayrı koleksiyonda tutun

İşlem dokümanının altında bir gecmis alt koleksiyonu tutmak, sonradan gelen “ben iptal etmedim” mesajlarında hayat kurtarıyor. Kim, ne zaman, hangi geçişi yaptı — tek bakışta görünüyor. Yazma maliyeti bir kayıt, karşılığı ise huzur.

Ekrandaki tek soru: sıra kimde?

Aynı işlem dokümanı iki kişiye açılıyor ama iki kişinin gördüğü cümle farklı olmalı. odeme_alindi durumunda satıcının ekranında “Sıra sende: bileti devret” yazıyor; alıcının ekranında ise “Ödemen bizde bekliyor, satıcının devretmesi bekleniyor”.

Bunun için dokümana siraKimde diye ayrı bir alan koydum. Durumdan türetilebilirdi ama iki yerde ayrı ayrı türetmek yerine tek yerde hesaplayıp yazmak daha güvenli oldu — özellikle bildirim metnini kurarken.

Ekran kuralı basit: her durumda tek bir birincil düğme var. Sırası kendisinde olmayan taraf hiçbir eylem düğmesi görmüyor, sadece bir bekleme metni ve kalan süre görüyor. Kullanıcının “şimdi ne yapmam gerekiyor” diye düşünmesi gereken bir an bırakmamak, bu akıştaki en değerli tasarım kararıydı.

Her bekleyen durumun bir son kullanma tarihi var

Emanet akışının en sinsi tarafı şu: karşı taraf hiçbir şey yapmazsa akış olduğu yerde donuyor. Para ortada, bilet satıcıda, iki taraf da bekliyor. Bu yüzden bekleyen her durumun bir sonTarih alanı var ve o alan geçişin yapıldığı anda sunucuda hesaplanıyor.

Süreyi cihazda saymak işe yaramıyor — kullanıcı uygulamayı kapatıyor, telefonun saati yanlış olabiliyor. Bunun yerine zamanlanmış bir fonksiyon periyodik olarak süresi dolmuş işlemleri topluyor ve her biri için normal geçiş fonksiyonunu çağırıyor. Zaman aşımı da bir aktör; haritada zaman_asimi olarak duruyor.

const dolanlar = await db.collectionGroup("islemler")
  .where("durum", "in", ["odeme_bekleniyor", "odeme_alindi", "bilet_iletildi"])
  .where("sonTarih", "<=", Timestamp.now())
  .limit(200)
  .get();

Süreler her durum için farklı. Ödeme beklerken kısa tutmak gerekiyor, çünkü bilet o süre boyunca satıştan çekiliyor. Satıcının devretmesi için daha uzun bir pencere mantıklı, alıcının kontrol etmesi için de öyle. Sürenin dolmasına yaklaşırken bir hatırlatma bildirimi gönderiyorum; bildirimi ayrı bir yerden değil, durum değişikliğinin kendisinden tetiklemek gerekiyor.

Otomatik onay bir karar, kaza değil

Alıcı bileti aldıktan sonra hiç dokunmazsa ne olacak? Sonsuza kadar beklemek satıcıyı cezalandırır. Süre dolunca otomatik onaya geçmeyi seçtim, ama bunu kullanıcıya baştan, bekleme ekranında açıkça yazıyorum. Sessiz kalan bir kurala kimse razı olmuş sayılmaz.

İptal ve itiraz da birer geçiş

İlk sürümde iptali ayrı bir kod yolu gibi düşünmüştüm: bir düğme, bir silme işlemi. Yanlıştı. İptal de diğerleri gibi bir geçiş; kimin, hangi durumdayken iptal edebileceği aynı haritada duruyor.

Bunu net kurala bağlamak gerekiyor: alıcı, satıcı henüz bileti devretmemişken iptal edebilir; satıcı devrettikten sonra alıcının iptal düğmesi yok, yerine itiraz var. İtiraz açıldığında işlem terminal duruma gitmiyor, benim önüme geliyor ve karar mercii insana devroluyor.

İtiraz durumunun varlığı, akışı kurtaran şey. Onsuz her anlaşmazlık ya alıcıyı ya satıcıyı otomatik olarak haksız çıkarıyor. İnsan kararı gerektiren bir kapıyı akışın içine koymak, kalan her şeyi otomatikleştirebilmenizi sağlıyor.

Parayı bekletmek teknik bir konu değil

Bu yazı akışın kendisini anlatıyor. Ödemenin nerede, ne kadar süre bekleyeceği ise ödeme sağlayıcınızın izin verdiği çerçeveye ve mevzuata bağlı. Akışı tasarlarken bu çerçeveyi baştan öğrenin; durum makinesini ona göre kurmak, sonradan araya sıkıştırmaya çalışmaktan çok daha kolay.

Küçük ama pahalı ayrıntılar

  • Aynı isteğin iki kez gelmesi. Zayıf bağlantıda kullanıcı düğmeye iki kez basıyor. Beklenen durumu isteğe koymak, ikinci isteğin sessizce reddedilmesini sağlıyor.
  • Bildirimi geçişten tetiklemek. Bildirimi düğmenin altına yazarsanız, zaman aşımıyla oluşan geçişlerde kimse haber alamıyor. Bildirim, durum değişikliğini dinleyen tek bir yerden gitmeli.
  • Terminal durumları kilitlemek. tamamlandi, iptal_edildi ve iade_edildi haritada hiçbir çıkışı olmayan durumlar. Haritada satırı yoksa geçiş de yok; ayrıca kontrol yazmaya gerek kalmıyor.
  • Durum adlarını arayüz metniyle karıştırmamak. Alan değeri sabit ve teknik kalıyor; kullanıcıya görünen cümle ayrı bir çeviri tablosundan geliyor. Metni değiştirmek veri modelini bozmuyor.

Geriye kalan ders

Bu akışı kurduktan sonra fark ettim ki asıl yaptığım şey ödeme entegrasyonu değil, güven eksikliğini adım adım küçük parçalara bölmek. Kimse tanımadığı birine önden para göndermek istemiyor; ama “ödemen bizde bekliyor, satıcının bir gün içinde devretmesi gerekiyor, devretmezse iade edilecek” cümlesini okuyunca bir adım atabiliyor.

Aynı sorun iki tarafın sırayla adım attığı her üründe var. Tek bir düğme yerine birbirini bekleyen durumlar kurmak, kodu da uzatmıyor — aksine, dağınık if yığınını tek bir haritaya indiriyor. Yeni bir pazar yeri akışına başlarken artık ilk yaptığım şey ekran çizmek değil, o dört sütunlu tabloyu doldurmak.

  • Ürün
  • Durum Makinesi
  • Pazar Yeri
  • Firebase
  • Ödeme Akışı
Paylaş: LinkedIn X WhatsApp
DT

Demir Taşdemir

Mobil Uygulama & Web Geliştirici

2018'den beri yazılım geliştiriyorum. App Store ve Google Play'de 11 uygulama yayınladım; şu an 6 mobil uygulama, 1 e-ticaret platformu ve 1 masaüstü oyun üzerinde çalışıyorum.

Benzer bir akış mı kuruyorsunuz?

İki tarafın sırayla adım attığı bir ürün tasarlıyorsanız, durumları birlikte çıkaralım — bana yazın.