Mobil uygulama altyapısı
React Native’de abonelik (IAP) nasıl yayına alınır? FitZen’den dersler
FitZen’in iOS ve Android aboneliklerini RevenueCat ve Firebase ile nasıl kurduğumuz: ürünler, sunucu otoriteli premium aynası ve denetimlerin yakaladığı hatalar.


KISA CEVAP
FitZen’de mağaza ürünleri RevenueCat’te tek bir "premium" yetkisine bağlanır; RevenueCat tek doğruluk kaynağıdır, Firebase yalnızca bir ayna tutar. Bu aynayı istemci değil sunucu yazar: imzalı webhook, uygulama açılışında RevenueCat REST API’sine soran bir callable ve her gece 03:15’te çalışan bir süpürme görevi. Firestore kuralları 18 yetki alanına istemci yazımını reddeder.
Mağazalarda hangi ürünler, hangi kimliklerle tanımlandı?
FitZen’in yayın kontrol listesi (IAP_PRODUCTION_CHECKLIST.md) iki mağazada da birebir aynı kimlikle açılmış altı ürünle başlar:
- Abonelikler:
com.fitzen4.weekly,com.fitzen4.monthly,com.fitzen4.yearly - Diyetisyen paketleri:
com.fitzen4.dietitian.single,.four,.eight
App Store Connect’te abonelikler aynı abonelik grubunda durur ve grup sıralaması yıllık > aylık > haftalık olarak ayarlanır. Diyetisyen paketleri App Store’da tüketilebilir (consumable), Google Play’de tek seferlik ürün olarak açılır. RevenueCat tarafında bir varsayılan offering ve "premium" adlı bir yetki (entitlement) vardır; üç abonelik bu yetkiyi verir.
Liste, sorunların çoğunun kodda değil yapılandırmada çıktığını kabul eder: ürün kimliklerinde büyük-küçük harf ve sondaki boşluk farkı, "Missing Metadata" durumunda kalan ürünler, imzalanmamış Paid Applications sözleşmesi, eksik banka ve vergi bilgisi. Anahtarların yalnızca .env içinde değil EAS ortamında da tanımlı olması, iOS ve Android anahtarlarının farklı olması ve sandbox değil üretim anahtarı kullanılması ayrı maddeler olarak yazılmıştır.
Daha sonra ürün kararı değişti: aylık ürün mağazada kalıyor, mevcut aboneler etkilenmiyor, ama ana ödeme ekranı yalnızca haftalık ve yıllık planı gösteriyor.
Premium bilgisini kim yazar? Sunucu otoriteli ayna
functions/subscription.js dosyasının başındaki tasarım notu kuralı açıkça koyar: tek doğruluk kaynağı RevenueCat’tir, Firebase yalnızca ayna tutar ve yetki alanlarını yalnızca sunucu yazar. users/{uid} belgesindeki blok şu alanlardan oluşur: isPremium, premiumSource (revenuecat, manual veya none), rcPremium, rcProductId, rcStore, rcPeriodType, rcEnvironment, rcExpiresAt, rcWillRenew, rcLastEventId, rcLastEventAtMs, manualPremium, manualPremiumUntil.
Etkin sonuç tek bir fonksiyonda hesaplanır: isPremium = rcActive || manActive. Süresi geçmiş bir kayıt, hiçbir olay gelmemiş olsa bile premium sayılmaz.
Firestore kurallarında entitlementFieldsUntouched() fonksiyonu, istemcinin güncellemede bu alanlara ve diyetisyen hakları ile çark bonusu alanlarına dokunmasını reddeder. Oluşturma kuralı daha ince: kayıt akışı varsayılan profili isPremium: false gibi değerlerle yazdığı için alan yasağı kaydı bozardı, bu yüzden yalnızca zararsız değere izin verilir.
Kuraldaki yorum nedenini de yazıyor: kullanıcı kendi belgesini silip yeniden yaratabildiği için create kuralı update kadar sıkı olmalı. Aksi hâlde biri rcLastEventAtMs alanına çok büyük bir sayı yazıp bütün webhook olaylarını sıra bekçisine takılarak kalıcı olarak engelleyebilirdi. Kural testlerinde users create: grace / entitlement fields denied, signup shape ok testi tam bu durumu dener.
Ayna hangi üç yoldan güncelleniyor?
Kodun ilkesi "biri kaçarsa diğeri yakalar". Üç yol var:
- Webhook (
revenuecatWebhook): yalnızca POST kabul eder,Authorizationbaşlığınıcrypto.timingSafeEqualile sabit zamanlı karşılaştırır, olayıtransactions/{eventId}belgesine yazar. Belge kimliği olay kimliği olduğu için aynı olay ikinci kez gelirse çift sayım olmaz. - Callable (
syncMyEntitlement): giriş, uygulamanın öne gelmesi ve satın alımları geri yüklemede çağrılır.uidistemcinin gönderdiğinden değilcontext.authüzerinden alınır, sunucu RevenueCat REST API’sinden abonenin gerçek durumunu sorar. - Zamanlanmış görev (
reconcileExpiredPremium): her gün 03:15’te, Europe/Istanbul saatiyle çalışır ve süresi dolanları kapatır.
İki ayrıntı önemli. Webhook, ayna yazımı başarısız olsa bile RevenueCat’e 200 döner; aksi hâlde RevenueCat olayı tekrar tekrar gönderir, kaçan durumu diğer iki yol zaten yakalar. Callable ise RevenueCat’e ulaşamazsa mevcut aynayı bozmaz ve verified: false döner.
Callable istemcinin cihazdaki SDK’dan okuduğu durumu da alır, ama bu veri yetki için kullanılmaz. Webhook gecikince yönetim panelinde "0 premium" görünüyordu; çözüm olarak istemci raporu ayrı rcClient* alanlarına, doğrulanmamış etiketiyle yazılır. Değiştirilmiş bir uygulama yalan söyleyebileceği için isPremium bu alanlardan asla türetilmez.
RevenueCat olayları premium durumunu nasıl değiştiriyor?
Olaylar üç kümeye ayrılır. Yetki verenler: INITIAL_PURCHASE, RENEWAL, UNCANCELLATION, PRODUCT_CHANGE, SUBSCRIPTION_EXTENDED, NON_RENEWING_PURCHASE, TEMPORARY_ENTITLEMENT_GRANT. Erişimi kesmeyenler: CANCELLATION, BILLING_ISSUE, SUBSCRIPTION_PAUSED. Kesen: EXPIRATION.
SUBSCRIPTION_PAUSED başta erişimi hemen kesiyordu. Kod yorumu bunu düzeltme nedeniyle birlikte yazıyor: Play Store duraklatması planlıdır ve kullanıcı ödediği dönemin sonuna kadar erişimini korumalıdır. Bitişi zaten rcExpiresAt yönetir.
İade ayrı bir dal. RevenueCat "REFUND" adında bir olay tipi göndermez; iadeler CANCELLATION ile birlikte cancel_reason: CUSTOMER_SUPPORT olarak gelir. Yalnızca tipe bakan ilk sürümde yıllık abonelik alıp iade alan kullanıcı bir yıl premium kalıyordu. Artık isRefundEvent tipten önce kontrol edilir.
Yazım bir transaction içinde yapılır ve iki koruması vardır. Sıra bekçisi: olay zamanı rcLastEventAtMs değerinden eskiyse yok sayılır, böylece gecikmiş bir EXPIRATION yeni bir RENEWAL’ı bozamaz. Hayalet kullanıcı koruması: yazım merge: true ile yapıldığı için belge yoksa onu yaratırdı. Abonelik hesaptan uzun yaşadığından, hesabını silen kullanıcıya gelen bir olay onu yalnızca yetki alanlarıyla geri diriltirdi. Belge yoksa olay atlanır. TRANSFER olayında yetki eski kimlikten kaldırılıp yenisine verilir; anonim RevenueCat kimlikleri ($RCAnonymousID) Firebase kullanıcısı sayılmaz.
Denetimler hangi abonelik hatalarını yakaladı?
Eylül 2026’daki güvenlik ve performans taramasında (SECURITY_PERFORMANCE_AUDIT_2026-09-24.md) ve sonraki düzeltmelerde abonelikle ilgili şu bulgular kapatıldı:
- Diyetisyen paketleri premium açıyordu. Tek seferlik paketler de
NON_RENEWING_PURCHASEüretir ve bitiş tarihi taşımaz. İlk sürümde burcExpiresAt: null, yani süresiz premium demekti; ara düzeltmede 48 saatlik güvenli varsayılan geldi, ama en ucuz paketi alan yine 48 saat premium oluyordu. Son hâlindecom.fitzen4.dietitian.önekiyle başlayan ürünler aynaya hiç dokunmaz. Önek eşleşmesi, Android’in:basegibi base-plan eklerini de kapsar. - Çark bonusu tekrar kullanılabiliyordu. Artık istemciye tamamen kapalı
wheelGrace/{uid}işareti tek kullanımı garanti eder, bonus yalnızca daha önce abone olmuş ve şu an premium olmayan kullanıcıya verilir, gün sayısı sunucuda en fazla 15 ile sınırlanır. - Yönetici sırrı RevenueCat webhook sırrına geri düşüyordu; artık ayrı bir değişken kullanılıyor.
En öğretici olan ise bir kazaydı. 2026-08-06 gecesi süpürme görevi, ayna kurulmadan önce premium olan 82 kullanıcıyı tek seferde kapattı. Bu kayıtlarda rc* alanları hiç yoktu ve kod bunu "yetki yok" diye okudu. Koddaki yorum dersi tek cümleyle veriyor: kanıt yokluğu, süresinin dolduğunun kanıtı değildir. Artık hiç iz taşımayan kayıtlar yalnızca sayılıp loglanıyor; gerçek durumu kullanıcı uygulamayı açtığında callable belirliyor.
Ödeme ekranında fiyat ve deneme bilgisi nereden gelmeli?
Build 91 (1.2.3) için yapılan App Review denetimi iki engel buldu: ödeme ekranı uygunluk kontrolü yapmadan "3 gün ücretsiz" gösteriyordu, fiyatlar ve üstü çizili "indirimli" fiyatlar mağaza vitrinine göre değil arayüz diline göre koda gömülüydü. Kalıcı bir "sınırlı süreli teklif" şeridi de vardı.
Yeni usePaywall kancasının başındaki not kuralı koyuyor: fiyat ve deneme bilgisi doğrudan RevenueCat offering’inden gelir, kodda sabit fiyat, sahte indirim ya da geri sayım yoktur. Ekranda priceString, pricePerMonthString gibi mağaza alanları kullanılıyor. Deneme bilgisi iki platformda farklı okunuyor:
- iOS:
introPricefiyatı 0 ise deneme sunuluyor demektir,checkTrialOrIntroductoryPriceEligibilityuygun değil derse ekranda "bu hesapta deneme kullanıldı" yazar. - Android: Play yalnızca uygun teklifleri döndürdüğü için
defaultOption.freePhasevarsa deneme gösterilir.
Uygunluk bilinmiyorsa deneme vaadi hiç gösterilmez. Yıllık plandaki tasarruf yüzdesi gerçek fiyatlardan, aylık ürünün 12 katına ya da haftalığın 52 katına göre hesaplanır ve yüzde 5’in altındaysa gösterilmez. Offering’de diyetisyen paketi gibi abonelik olmayan ürünler de bulunabildiği için paketler productCategory ile süzülür.
"Deneme bitmeden hatırlatırız" vaadi de gerçeğe bağlandı: bildirim izni varsa scheduleTrialReminder deneme bitiminden bir gün önceye, en az bir saat sonraya yerel bir bildirim kurar.
İstemci tarafında neler sorun çıkardı?
RevenueCat kullanıcı kimliği Firebase uid ile eşlenir: istemci Purchases.logIn(firebaseUid) çağırır, webhook da app_user_id alanından kullanıcıyı bulur. Durum tazelenirken RevenueCat’teki kimlik Firebase kimliğinden farklıysa, anonim kimlik dahil, istemci yeniden giriş yapar; yorumdaki gerekçe, cihazda kalan eski kimliğin başka bir hesabın aboneliğini taşıyabilmesi.
Performans denetimi açılışta ve her sekme odağında 4 ile 8 arasında gereksiz premium kontrolü saydı; ödeme ekranı açılırken sessizce satın alım geri yükleme de bunlardan biriydi. Düzeltmede eşzamanlı çağrılar tek isteğe birleştirildi ve zorunlu olmayan çağrılar 60 saniye boyunca son sonucu kullanıyor (STATUS_CACHE_MS = 60_000). Paywall artık sessizce geri yükleme yapmıyor.
İki paket sorunu daha var. react-native-purchases, native pakete yaklaşık 880 KB’lık web yedeğini de taşıyordu; metro.config.js bu modülü native derlemede boş bir taslakla değiştiriyor. RevenueCat hata ayıklama logu da üretimde açıktı; artık __DEV__ dışında LOG_LEVEL.ERROR.
En sinsi sorun Ekim 2026’da Android 2.0.2 sürümünde çıktı. Yerel EAS derlemesi eas.json içindeki $DEGISKEN yer tutucularını çözmeden pakete koyuyordu ve RevenueCat, Firebase ve Google girişi anahtarları pakete yer tutucu metin olarak giriyordu. Çözüm app.config.js içinde $ ile başlayan değerleri boş saymak ve istemci anahtarları için varsayılan tanımlamak oldu. Bunlar zaten pakette açık duran istemci anahtarları; RevenueCat gizli anahtarı yalnızca sunucuda durur.
Benzer bir abonelik altyapısı kuracaklar için kontrol listesi
FitZen’de işe yarayan sıra şuydu:
- Ürün kimliklerini iki mağazada ve RevenueCat’te birebir aynı yazın, abonelikleri tek bir yetkiye bağlayın, tek seferlik ürünleri bu yetkiden açıkça ayırın.
- Premium alanını istemciye yazdırmayın. Kurallarla hem update hem create yolunu kapatın ve bunu bir kural testiyle sabitleyin.
- Aynayı en az üç yoldan güncelleyin: imzalı webhook, kimliği oturumdan alan bir doğrulama çağrısı ve süresi dolanları süpüren zamanlanmış bir görev.
- Olayları idempotent ve sıra bağımsız işleyin: olay kimliğiyle belge, olay zamanıyla sıra bekçisi, transaction.
- İadeyi olay tipinden değil iptal nedeninden tanıyın.
- Bitiş tarihi olmayan yetkiyi asla süresiz saymayın.
- Süpürme görevinde veri yokluğunu "süresi doldu" diye okumayın; önce sayın, sonra kaynağa sorun.
- Fiyatı, denemeyi ve indirimi yalnızca mağazadan okuyun; uygunluk bilinmiyorsa deneme vaat etmeyin.
- App Review için inceleme notu ve premium bir demo hesap hazırlayın; ödeme duvarlı bir uygulamada inceleyici aksi hâlde uygulamanın içine giremez.
- Yerel ve bulut derlemede ortam değişkenlerinin gerçekten çözüldüğünü paketin içinden doğrulayın.
AMG Yazılım olarak FitZen gibi abonelikli mobil ürünleri mağazadan sunucu tarafına kadar uçtan uca geliştiriyoruz.
PROJENİZE UYARLAYALIM
Mobil Uygulama Geliştirme
iOS native (Swift, SwiftUI) ve React Native ile çapraz platform. Fikirden mağazaya tam süreç.
