Selamlar, bu blog yazısında flutterda in-app-purchase’i nasıl güvenli ve stabil şekilde yapılacağını ele alacağım. Bu yazıyı yazmamdaki amaç kendim uygularken epey zorlandım ve birçok yabancı kaynaktan araştırma yaptım. Ayrıca uygulamada kullandığım paket eski sürümde kaldığı için yeni sürümde deprecate olmuş fonksiyonlar vardı ve bunları düzeltmem gerekiyordu dolayısıyla türkçe kaynak olması adına best practiceleriyle bu konuyu anlatmaya karar verdim. Ödeme sürecinin kritik olduğundan bahsetmeyeceğim zaten bu kısmın doğru yönetilmesinin hem kullanıcı geri dönüşleri hem de uygulama imajı açısından ne kadar önemli olduğunun farkındasınızdır.
Flutterda satın alma işlemleri için kullanılan paket: in_app_purchase: 3.2.3
Backend doğrulaması için node.js kullanıyorum fakat siz herhangi bir programlama dilinde uygulayabilirsiniz. IOS satın alımlarında token doğrularken “@apple/app-store-server-library” kütüphanesini kullanıyorum. İlgili kütüphane Swift, python ve javada da mevcut. Ayrıca official olmayan farklı programlama dillerinde de bulabilirsiniz diye tahmin ediyorum kendimde php tarafında doğrulama için “hoels/app-store-server-library-php” kullanıyorum. Şuanki anlatacağım kısımda satın alımın yönetilmesi var. İade edilen ürünlerin iptalleri için gpt’den destek alabilirsiniz. Keyifli okumalar dilerim.
Neden Backend Doğrulaması Gerekli:
- Güvenlik: Client-side doğrulama tek başına yeterli değil çünkü client tarafında yapılan işlemler manipüle edilebilir dolayısıyla bu manipülasyonu uygulayan kişi kendini ürünü satın alan biri gibi gösterebilir.
- Sahte satın alım koruması: Satın alımda verilen receipt veya token’i backend tarafında doğrulayarak kullanıcının gerçekten satın alım yapıp yapmadığını kontrol etmek.
- Abone takibi: Abonelik durumlarının merkezi yönetimini sağlamak, request’i manipüle ederek çeşitli bilgilerin eklenmesi.
Flutter Tarafı: Ürün yönetimi
Öncelikle veritabanımızda mağazalarda oluşturduğumuz ürünleri tutacağımız bir tablo oluşturuyoruz. Bunun sebebi hem ürünlerin metadata’larını saklayabilmek hem de ürünün aktifliği, sıralaması, satın alım tarihi gibi client tarafında işimize yarayacak bilgileri tutabilmek. Örnek olması açısından benim mysqlde oluşturduğum tabloyu paylaşıyorum.
CREATE TABLE `IapProducts` (
`Id` tinyint(4) NOT NULL AUTO_INCREMENT,
`StoreProductId` varchar(100) DEFAULT NULL,
`WebsiteProductId` varchar(100) DEFAULT NULL,
`Name` varchar(1000) DEFAULT NULL,
`Description` varchar(1000) DEFAULT NULL,
`Price` int(11) DEFAULT NULL,
`IsCurrentPremium` tinyint(1) DEFAULT NULL,
`IsActive` tinyint(1) DEFAULT 1,
`EndDate` date DEFAULT NULL,
`OrderIndex` tinyint(4) DEFAULT NULL,
`CreatedDate` timestamp NOT NULL DEFAULT current_timestamp(),
PRIMARY KEY (`Id`)
) ENGINE=InnoDB AUTO_INCREMENT=1 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
StoreProductId ile WebsiteProductId tutulmasının sebebi, sadece mobil değil website üzerinde de satış yapıyor olmak. Böylece ürünlerin websitesi ile mağazadaki ürün kodlarını maple yerek ihtiyacımız olduğunda kullanabiliyoruz. İhtiyacınıza göre düzenleyebilirsiniz. Flutter tarafındaki initialize kısmı:
Future<void> initialize(BuildContext context) async {
try {
// Önce backend'den aktif ürünleri al
final response = await _paymentProvider.getIapProducts();
if (response.data == null) {
return showSnackBar(
context: context,
message: response.errorMessage!,
type: ContentType.failure,
);
}
iapProducts = response.data!;
// Purchase stream dinleyicisini başlat
purchaseSubscription = inAppPurchase.purchaseStream.listen(
(purchaseDetailsList) {
_handlePurchaseUpdates(purchaseDetailsList);
},
onDone: () {
debugPrint('Purchase stream done');
purchaseSubscription?.cancel();
},
onError: _handlePurchaseStreamError,
);
await _checkStoreAvailability();
} catch (e) {
debugPrint('PaymentViewModel initialization error: $e');
_setAvailable(false);
}
}
Mağaza uygunluk kontrolü ve ürünlerin yüklenmesi
Future<void> _checkStoreAvailability() async {
final isStoreAvailable = await inAppPurchase.isAvailable();
if (isStoreAvailable) {
await _loadProducts();
await _restorePurchases();
}
_setAvailable(isStoreAvailable);
}
Future<void> _loadProducts() async {
try {
// Backend'den gelen aktif ürünlerin store ID'lerini kullan
final response = await inAppPurchase.queryProductDetails(
iapProducts.map((e) => e.storeProductId!).toSet(),
);
if (response.error != null) {
throw Exception('Ürünler yüklenemedi: ${response.error}');
}
_products = response.productDetails;
// Backend'deki orderIndex'e göre sırala
_products.sort((a, b) {
final iapProductA = iapProducts.firstWhere(
(iap) => iap.storeProductId == a.id,
orElse: () => IapProductModel(
name: '', description: '', price: 0, orderIndex: 999),
);
final iapProductB = iapProducts.firstWhere(
(iap) => iap.storeProductId == b.id,
orElse: () => IapProductModel(
name: '', description: '', price: 0, orderIndex: 999),
);
return (iapProductA.orderIndex ?? 999)
.compareTo(iapProductB.orderIndex ?? 999);
});
notifyListeners();
} catch (e) {
Alerts().showWarningDialog(context, "Ürünler yüklenemedi!", e.toString());
rethrow;
}
}
Bu yaklaşımın avantajları merkezi ürün yönetimini sağlayabilmek, orderIndex’e göre ui tarafında sıralama yapabilmek, ürün açıklama ve fiyat bilgilerini yönetebilmek ve store’dan bağımsız ürün durumunu yönetebilmek olduğunu söyleyebiliriz.
Purchase stream ve satın alma yönetimi
Bu oluşturduğumuz stream satın alma durumundaki değişiklikleri dinler. Pending durumundan completed durumuna kadar tüm değişiklikleri yakalayabilmeyi sağlar.
// Purchase stream dinleyicisini başlat
purchaseSubscription = inAppPurchase.purchaseStream.listen(
(purchaseDetailsList) {
_handlePurchaseUpdates(purchaseDetailsList);
},
onDone: () {
debugPrint('Purchase stream done');
purchaseSubscription?.cancel();
},
onError: _handlePurchaseStreamError,
);
Ürün satın alma süreci
Frontend tarafında kullanıcı satın almak için butona tıkladığında buyProduct metotunu çağırıyoruz. in-app-purchase paketinde bulunan buyConsumable ve buyNonConsumable metotlarıyla her platform için satın alma sürecini yönetiyor (ilgili ekranın açılması ve mağaza bilgilerinin girildiği/ödemenin gerçekleştirildiği kısım bkz: bir sonraki gif). Eğer satın alım gerçekleşirse yukardaki listen metotuyla dinlediğimiz _handlePurchaseUpdates(purchaseDetailsList); metotunun çağrıldığını görebiliriz. Not: Ios simülatörde in-app-purchase’in çalışmadığını unutmayın. Test etmek için gerçek cihaz ya da uygulamanızın macos buildi varsa orada deneyebilirsiniz. Ayrıca Apple cihazlarda ücretsiz deneme yapabilmek için sandbox account oluşturmanız gerekiyor.
Future<void> buyProduct(ProductDetails productDetails) async {
try {
final purchaseParam = PurchaseParam(productDetails: productDetails);
if (productDetails.id.contains('consumable')) {
await inAppPurchase.buyConsumable(purchaseParam: purchaseParam);
} else {
await inAppPurchase.buyNonConsumable(purchaseParam: purchaseParam);
}
} catch (e) {
debugPrint('Satın alınamadı: $e');
}
}
.
Satın alma durumlarının yönetimi ve işlenmesi
Bu noktada List<PurchaseDetails> formatında gelen içeriklerimiz mevcut. Liste şeklinde olmasının sebebi daha önce satın alınmış birden fazla ürün bulunabilir ve bu ürünleri yönetmek isteyebiliriz.
void _handlePurchaseUpdates(List<PurchaseDetails> purchaseDetailsList) async {
for (final purchaseDetails in purchaseDetailsList) {
await _processPurchase(purchaseDetails);
// Complete the purchase if needed
if (purchaseDetails.pendingCompletePurchase) {
await inAppPurchase.completePurchase(purchaseDetails);
}
}
notifyListeners();
}
Future<void> _processPurchase(PurchaseDetails purchaseDetails) async {
switch (purchaseDetails.status) {
case PurchaseStatus.purchased:
case PurchaseStatus.restored:
await _handleSuccessfulPurchase(purchaseDetails);
break;
case PurchaseStatus.error:
_handlePurchaseError(purchaseDetails.error);
break;
default:
break;
}
}
Başarılı satın alma işlemi
in_app_purchase paketinin 3.2.3 versiyonunda ufak bir bug var. Ios satın alımlarında status purchaseStatus.restored olarak geliyor dolayısıyla ios için özel bir case yazabilirsiniz. Ben önce kullanıcıda ilgili üyeliğin daha önce eklenip eklenmediğini kontrol ediyorum. Daha sonra veritabanından aldığımız listeyle satın alımdan gelen purchaseDetails’i filtreliyoruz. Böylece ürünün aktif olduğundan emin oluyoruz. _runPurchaseSingleFlight() metotunu kullanmamızdaki amaç; eğer satın alım sırasında jwt tokenin süresi biterse ve refresh token ile yeni token almaya giderse bütün purchased/restored ürünler için tek bir refresh token ile yeni jwt token alsın. Böylece race condition kaynaklı unauthorized hatalarının önüne geçmek. Eğer farklı bir auth yöntemiyle kullanıcıyı kontrol ediyorsanız buna ihtiyacınız yok.
Future<void> _handleSuccessfulPurchase(
PurchaseDetails purchaseDetails) async {
if (!_pastPurchases.any((p) => p.productID == purchaseDetails.productID)) {
_pastPurchases.add(purchaseDetails);
}
final iapProduct = iapProducts
.firstWhereOrNull((i) => i.storeProductId == purchaseDetails.productID);
// in_app_purchase 3.2.3 sürümünde ios için yeni satın alımlarda
// purchaseDetails.status = PurchaseStatus.purchased değil PurchaseStatus.restored olarak geliyor
// bu yüzden ios, macos ise PurchaseStatus.purchased kontrolü yapılmayacak
if (iapProduct != null &&
user?.userProducts?.contains(iapProduct.websiteProductId) == false) {
final String? websiteProductId = iapProduct.websiteProductId;
if (websiteProductId != null) {
// Aynı ürün için yarış durumunu önlemek üzere single flight'e ekle
await _runPurchaseSingleFlight(
websiteProductId,
() => _completeUserPurchase(purchaseDetails, websiteProductId),
);
}
}
}
Backend doğrulama süreci
Ödemenin backendde doğrulanma kısmında yaygın kullanımlardan biri kuyruk kullanarak ödeme detayını iletmek ve asenkron biçimde süreci yönetmek. Benim şuan göstereceğim yapı senkron biçimde ödemenin backende gönderilmesi ve cevap gelmesini beklemek devamında süreci gelen cevaba göre ilerletmek. Burada dikkat edilmesi gereken bir nokta da request ve responseların loglanması. Böylece hata durumlarında hangi kullanıcının hangi requestle gidip hangi response’u aldığını görebilmek. İhtiyaç halinde müşteriyi bilgilendirmek. Dolayısıyla hem client tarafında hemde backendde tarafındaki işlemler için log tutabilirsiniz elbetteki bu yapı sizin ihtiyacınıza göre belirlemeniz gerekiyor. Backendde satın alım doğrulanırsa kullanıcının tokenine bu bilgiyi işlemeniz gerekebilir çünkü tokendaki user_product’ları okuyarak işlem yapıyor olabilirsiniz. Ayrıca backend tarafında da route erişimlerini sınırlandırırken de bu tokendeki bilgileri alma ihtiyacınız olabilir. Satın alım sonrası kullanıcıya daha iyi bir deneyim yaşatabilmek adına kullanıcının tokenini yenilenemek gerekir. Diğer türlü çıkış yaptırıp tekrar giriş yaptırmanız gerekirki tokeni yeniden oluşsun ve ürünlerini görebilsin. Bu noktada refresh token kullanarak yeni bir token verebilirsiniz bunun kod mantığını da paylaşıyorum.
Not: Kod tarafında yaptığınız işlemler için componsation transaction yaptığınıza emin olmalısınız. Şuanki kodda ekstra veritabanı işlemi (fatura kesme, mail gönderme gibi) olmadığı için ekstra mantık uygulanmadı.
// Veritabanına kaydet
final databaseResult = await _paymentProvider.saveDatabase(
receiptData: purchaseDetails.verificationData.serverVerificationData,
platform: purchaseDetails.verificationData.source,
productId: purchaseDetails.productID,
purchaseId: purchaseDetails.purchaseID,
);
if (databaseResult.data == null) {
_clientLogProvider.insertClientLog(ClientLogModel(
requestData: databaseResult.requestData?.toString(),
response: databaseResult.response?.data.toString(),
errorMessage: databaseResult.errorMessage,
));
showSnackBar(
context: context,
message: databaseResult.errorMessage!,
type: ContentType.failure,
);
_setSavingTransaction(false);
return;
}
final userNotifier = Provider.of<UserNotifier>(context, listen: false);
final refreshToken = userNotifier.refreshToken;
final response = await _wordpressAuthProvider.refreshToken(
refreshToken: refreshToken,
);
if (response.data == null) {
// refresh token response gelmediği durum yönetilir
}
await userNotifier.setToken(
token: newUser.token!,
refreshToken: newUser.refreshToken,
);
await userNotifier.setUser(
newUser.copyWith(
token: null,
refreshToken: null,
),
);
Node.js Backend: Platform-Specific Doğrulama
App store tarafında doğrulama yaparken verifyReceipt metotunun deprecate olduğunu görebilirsiniz. Dolayısıyla developer apple’da da önerdiği gibi App Store Server API‘yi kullanarak doğrulama yapmalıyız. in_app_purchase 3.2.3 changelog‘da görebildiğimiz üzere in_app_purchase_storekit versiyonu 0.4.0 yani Storekit 2 kullanmakta. Dolayısıyla satın alımlardaki receiptData artık base64 değil JWS formatında. Receipt datayı istesekte https://buy.itunes.apple.com/verifyReceipt adresine istek atarak doğrulayamıyoruz. Play store tarafında ise googleapis paketini kullanarak doğrulama yapabiliriz. Nasıl kullanılacağı hakkında fikir sahibi olmak için ilgili linkleri kullanabilirsiniz. Apple tarafında doğrulama servislerini kullanabilmek için Apple key id, issuer id, bundle id, subscription key, environment ve sertifika gibi çeşitli ihtiyaçlarımız var. Node.js için yazının başında bahsettiğim paketi (@apple/app-store-server-library) kullanabilirsiniz. Farklı programlama dilleri için de alternatifleri mevcut internette araştırarak bulabilirsiniz.
iOS Doğrulama Endpoint’i
router.post("/payment/ios", [verifyUserToken, validatePaymentInfo()], async (req, res) => {
const { receiptData, platform, appVersion } = req.body;
// Apple Store Server Library ile doğrulama
const transactionResult = await appleStoreService.verifyAndDecodeTransaction(receiptData);
if (!transactionResult.success) {
return res.status(400).json({
message: `Transaction doğrulama başarısız`,
});
}
// Veritabanına kaydet
const query = `CALL ${insSubscriptionProcName}(?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`;
await connection.query(query, values);
});
Apple Store Service Sınıfı
class AppleStoreService {
constructor() {
this.keyId = process.env.APPLE_KEY_ID;
this.issuerId = process.env.APPLE_ISSUER_ID;
this.bundleId = process.env.APPLE_BUNDLE_ID;
// Apple Store Server API Client
this.client = new AppStoreServerAPIClient(
this.privateKey,
this.keyId,
this.issuerId,
this.bundleId,
this.environment
);
// SignedDataVerifier - Apple Root Certificate gerekli
this.verifier = new SignedDataVerifier(
rootCerts,
true,
this.environment,
this.bundleId,
this.appAppleId
);
}
async verifyAndDecodeTransaction(signedTransactionInfo) {
const decodedTransaction = await this.verifier.verifyAndDecodeTransaction(
signedTransactionInfo
);
return {
success: true,
transaction: decodedTransaction,
};
}
}
Android Doğrulama Endpoint’i
router.post("/payment/android", [verifyUserToken, validatePaymentInfo()], async (req, res) => {
const { productId, purchaseId, receiptData } = req.body;
// Google Play Developer API ile doğrulama
const response = await playDeveloperApi.purchases.products.get({
packageName: packageName,
productId: productId,
token: receiptData,
});
// Purchase state kontrolü (0 = purchased)
if (response.data.purchaseState !== 0) {
return res.status(400).json({
message: "Google Play satın alma doğrulanamadı",
});
}
});
// googlePlayService.js
const { google } = require("googleapis");
const path = require("path");
const keyFilePath = path.join(__dirname, "./xx-project-xx.json");
const playDeveloperApi = google.androidpublisher({
version: "v3",
auth: new google.auth.GoogleAuth({
keyFile: keyFilePath,
scopes: ["https://www.googleapis.com/auth/androidpublisher"],
}),
});
module.exports = { playDeveloperApi };
Güvenlik Önemleri ve Best Practices
- Apple Root Certificate: Mutlaka güncel certificate kullanın
- Environment Kontrolü: Production/Sandbox ayırımı yapın
- Duplicate Transaction: Aynı transaction’ın birden fazla işlenmesini engelleyin
- Error Logging: Tüm hataları detaylı loglayın
- Token Validation: Her request’te user token’ını doğrulayın
- Token Yönetimi: Kullanıcı durumu değişikliklerinin token’a yansıtılması
- Hata İzleme: Olası hataları önceden farkedin ve önlem alın
Kapanış
Çok detaya girmeden özet bir şekilde süreci anlatmaya çalıştım umarım faydalı bir yazı olmuştur. Aklınıza takılan ve size doğru gelmeyen kısımlar olabilir yorum yaparak belirtirseniz yazıyı güncellerim. İyi çalışmalar kolay gelsin.