Tüm Yazılar
KategoriiOS
Okuma Süresi
22 dk
Yayın Tarihi
2024-02-20
Kelime Sayısı
3.137kelime

Kahveni hazırla - bu içerikli bir makale!

StoreKit 2 ile Modern In-App Purchase

Özet

Modern StoreKit API ile subscription ve one-time purchase implementasyonu. Transaction handling, receipt validation ve testing.

  • StoreKit 2 delegate yerine async/await kullanıyor; kod miktarı ~500 satırdan ~100 satıra iniyor.
  • Transaction.updates ile background işlemler dinlenir, currentEntitlements ile aktif haklar kontrol edilir.
  • Transaction.finish() çağrılmazsa kullanıcı aynı ürünü tekrar satın alamıyor (kritik hata kaynağı).
  • Server-side validation için jwsRepresentation backend'e gönderilip App Store Server API ile doğrulanıyor.
StoreKit 2 ile Modern In-App Purchase

Merhaba değerli iOS geliştiricisi! Bugün seninle Apple'ın en güçlü monetizasyon aracı olan StoreKit 2'yi derinlemesine inceleyeceğiz. Bu rehber sonunda, profesyonel düzeyde in-app purchase sistemi kurmayı öğreneceksin.


İçindekiler


🎯 Bu Yazıda Öğreneceklerin

  • StoreKit 2'nin temelleri ve StoreKit 1'den farkları
  • Product configuration ve App Store Connect entegrasyonu
  • Transaction handling ve async/await kullanımı
  • Subscription lifecycle management
  • Receipt validation (server-side & on-device)
  • Sandbox ve production testing
  • Real-world best practices

📚 StoreKit 2 Nedir ve Neden Önemli?

StoreKit 2, iOS 15+ ile tanıtılan modern in-app purchase framework'üdür. StoreKit 1'e göre dramatik iyileştirmeler sunar:

Özellik
StoreKit 1
StoreKit 2
API Stili
Delegate-based
async/await
Transaction Handling
SKPaymentQueue
Transaction.updates
Receipt Validation
Receipt parsing gerekli
JWS otomatik doğrulama
Testing
Sandbox account zorunlu
StoreKit Configuration File
Code Complexity
500+ satır
~100 satır
💡 Altın İpucu: StoreKit 2, iOS 15+ gerektirir ama App Store Server API ile iOS 7+'ya kadar geriye dönük destek sağlayabilirsin!

Dış Kaynaklar:


🏗️ Proje Kurulumu

1. Product ID'leri Tanımla

İlk adım olarak product ID'lerini enum ile type-safe hale getir:

swift
1import StoreKit
2 
3// MARK: - Product IDs - Type-Safe Tanımlama
4enum ProductID: String, CaseIterable {
5 // ═══════════════════════════════════════════
6 // 🪙 CONSUMABLES - Her seferinde tekrar satın alınabilir
7 // ═══════════════════════════════════════════
8 case coins100 = "com.myapp.coins.100"
9 case coins500 = "com.myapp.coins.500"
10 case coins1000 = "com.myapp.coins.1000"
11
12 // ═══════════════════════════════════════════
13 // 🔓 NON-CONSUMABLES - Bir kez satın alınır
14 // ═══════════════════════════════════════════
15 case premiumUnlock = "com.myapp.premium"
16 case removeAds = "com.myapp.removeads"
17 case allThemes = "com.myapp.themes.all"
18
19 // ═══════════════════════════════════════════
20 // 📅 AUTO-RENEWABLE SUBSCRIPTIONS
21 // ═══════════════════════════════════════════
22 case weeklyPro = "com.myapp.pro.weekly"
23 case monthlyPro = "com.myapp.pro.monthly"
24 case yearlyPro = "com.myapp.pro.yearly"
25 case lifetimePro = "com.myapp.pro.lifetime"
26
27 // Product type helper
28 var type: Product.ProductType {
29 switch self {
30 case .coins100, .coins500, .coins1000:
31 return .consumable
32 case .premiumUnlock, .removeAds, .allThemes, .lifetimePro:
33 return .nonConsumable
34 case .weeklyPro, .monthlyPro, .yearlyPro:
35 return .autoRenewable
36 }
37 }
38
39 // 🐣 Easter Egg: Özel debug product ID
40 // EASTER_EGG_2024: Bu ID ile test modunda sınırsız coins!
41 static var debugAllAccess: String { "com.myapp.debug.allaccess" }
42}

2. Ana Store Manager Sınıfı

swift
1import StoreKit
2import Combine
3 
4// MARK: - Store Manager - Ana Yönetici Sınıfı
5@MainActor
6final class StoreManager: ObservableObject {
7 // ═══════════════════════════════════════════
8 // Published Properties - SwiftUI Binding
9 // ═══════════════════════════════════════════
10 @Published private(set) var products: [Product] = []
11 @Published private(set) var purchasedProductIDs: Set<String> = []
12 @Published private(set) var isLoading = false
13 @Published private(set) var error: StoreError?
14
15 // Subscription specific
16 @Published private(set) var subscriptionStatus: SubscriptionStatus = .notSubscribed
17 @Published private(set) var subscriptionGroupStatus: [Product.SubscriptionInfo.Status]?
18
19 // ═══════════════════════════════════════════
20 // Private Properties
21 // ═══════════════════════════════════════════
22 private var transactionListener: Task<Void, Error>?
23 private var cancellables = Set<AnyCancellable>()
24
25 // Singleton (opsiyonel - DI tercih edilebilir)
26 static let shared = StoreManager()
27
28 // ═══════════════════════════════════════════
29 // Lifecycle
30 // ═══════════════════════════════════════════
31 init() {
32 // Transaction listener'ı başlat
33 transactionListener = listenForTransactions()
34
35 // İlk yükleme
36 Task {
37 await loadProducts()
38 await updatePurchasedProducts()
39 }
40 }
41
42 deinit {
43 transactionListener?.cancel()
44 }
45}
46 
47// MARK: - Error Types
48enum StoreError: LocalizedError {
49 case failedVerification
50 case productNotFound
51 case purchaseFailed(Error)
52 case networkError
53 case userCancelled
54 case pending
55
56 var errorDescription: String? {
57 switch self {
58 case .failedVerification:
59 return "İşlem doğrulanamadı. Lütfen tekrar deneyin."
60 case .productNotFound:
61 return "Ürün bulunamadı."
62 case .purchaseFailed(let error):
63 return "Satın alma başarısız: \(error.localizedDescription)"
64 case .networkError:
65 return "İnternet bağlantınızı kontrol edin."
66 case .userCancelled:
67 return "Satın alma iptal edildi."
68 case .pending:
69 return "Satın alma onay bekliyor."
70 }
71 }
72}
73 
74// MARK: - Subscription Status
75enum SubscriptionStatus: Equatable {
76 case notSubscribed
77 case subscribed(tier: SubscriptionTier, expiresAt: Date?)
78 case expired(tier: SubscriptionTier)
79 case inGracePeriod(tier: SubscriptionTier)
80 case inBillingRetry(tier: SubscriptionTier)
81
82 var isActive: Bool {
83 switch self {
84 case .subscribed, .inGracePeriod:
85 return true
86 default:
87 return false
88 }
89 }
90}
91 
92enum SubscriptionTier: String, Comparable {
93 case weekly
94 case monthly
95 case yearly
96 case lifetime
97
98 static func < (lhs: SubscriptionTier, rhs: SubscriptionTier) -> Bool {
99 let order: [SubscriptionTier] = [.weekly, .monthly, .yearly, .lifetime]
100 return order.firstIndex(of: lhs)! < order.firstIndex(of: rhs)!
101 }
102}

🛒 Product Loading ve Purchase Flow

3. Ürünleri Yükleme

swift
1// MARK: - Product Loading
2extension StoreManager {
3 /// App Store'dan ürünleri yükle
4 func loadProducts() async {
5 isLoading = true
6 defer { isLoading = false }
7
8 do {
9 // Tüm product ID'lerini al
10 let productIDs = Set(ProductID.allCases.map { $0.rawValue })
11
12 // App Store'dan ürünleri fetch et
13 let storeProducts = try await Product.products(for: productIDs)
14
15 // Fiyata göre sırala (düşükten yükseğe)
16 products = storeProducts.sorted { $0.price < $1.price }
17
18 #if DEBUG
19 print("📦 Yüklenen ürün sayısı: \(products.count)")
20 products.forEach { product in
21 print(" - \(product.displayName): \(product.displayPrice)")
22 }
23 #endif
24
25 } catch {
26 self.error = .networkError
27 print("❌ Ürün yükleme hatası: \(error)")
28 }
29 }
30
31 /// Belirli bir ürünü ID ile bul
32 func product(for id: ProductID) -> Product? {
33 products.first { $0.id == id.rawValue }
34 }
35
36 /// Subscription ürünlerini filtrele
37 var subscriptionProducts: [Product] {
38 products.filter { $0.type == .autoRenewable }
39 .sorted { ($0.subscription?.subscriptionPeriod.value ?? 0) <
40 ($1.subscription?.subscriptionPeriod.value ?? 0) }
41 }
42
43 /// Non-consumable ürünleri filtrele
44 var nonConsumableProducts: [Product] {
45 products.filter { $0.type == .nonConsumable }
46 }
47
48 /// Consumable ürünleri filtrele
49 var consumableProducts: [Product] {
50 products.filter { $0.type == .consumable }
51 }
52}

4. Satın Alma İşlemi

swift
1// MARK: - Purchase Flow
2extension StoreManager {
3 /// Ana satın alma fonksiyonu
4 @discardableResult
5 func purchase(_ product: Product) async throws -> Transaction? {
6 isLoading = true
7 defer { isLoading = false }
8
9 // Satın alma isteği gönder
10 let result: Product.PurchaseResult
11
12 do {
13 // 💡 Pro Tip: appAccountToken ile kendi user ID'ni bağla
14 let options: Set<Product.PurchaseOption> = [
15 .appAccountToken(UUID()) // Kendi user ID sisteminle değiştir
16 ]
17 result = try await product.purchase(options: options)
18 } catch {
19 throw StoreError.purchaseFailed(error)
20 }
21
22 // Sonucu işle
23 switch result {
24 case .success(let verification):
25 // Transaction'ı doğrula
26 let transaction = try checkVerified(verification)
27
28 // Entitlements'ı güncelle
29 await updatePurchasedProducts()
30
31 // Transaction'ı bitir (ÇOK ÖNEMLİ!)
32 await transaction.finish()
33
34 // Analytics event gönder
35 await trackPurchase(product: product, transaction: transaction)
36
37 return transaction
38
39 case .userCancelled:
40 throw StoreError.userCancelled
41
42 case .pending:
43 // Ask to Buy veya SCA (Strong Customer Authentication)
44 throw StoreError.pending
45
46 @unknown default:
47 return nil
48 }
49 }
50
51 /// Transaction doğrulama
52 private func checkVerified<T>(_ result: VerificationResult<T>) throws -> T {
53 switch result {
54 case .unverified(_, let error):
55 print("⚠️ Doğrulama hatası: \(error)")
56 throw StoreError.failedVerification
57 case .verified(let safe):
58 return safe
59 }
60 }
61
62 /// Analytics tracking
63 private func trackPurchase(product: Product, transaction: Transaction) async {
64 // Firebase Analytics, Mixpanel vs. entegrasyonu
65 let params: [String: Any] = [
66 "product_id": product.id,
67 "price": product.price,
68 "currency": product.priceFormatStyle.currencyCode,
69 "transaction_id": String(transaction.id)
70 ]
71 // Analytics.logEvent("purchase", parameters: params)
72 }
73}

🔄 Transaction Listener ve Entitlements

5. Background Transaction Handling

swift
1// MARK: - Transaction Listener
2extension StoreManager {
3 /// Background'da transaction'ları dinle
4 private func listenForTransactions() -> Task<Void, Error> {
5 Task.detached { [weak self] in
6 // Transaction.updates sonsuz bir async sequence
7 for await result in Transaction.updates {
8 do {
9 let transaction = try await self?.checkVerified(result)
10
11 // Ana thread'de UI güncelle
12 await MainActor.run { [weak self] in
13 Task {
14 await self?.updatePurchasedProducts()
15 }
16 }
17
18 // Transaction'ı bitir
19 await transaction?.finish()
20
21 print("✅ Transaction güncellendi: \(transaction?.productID ?? "unknown")")
22
23 } catch {
24 print("❌ Transaction listener hatası: \(error)")
25 }
26 }
27 }
28 }
29
30 /// Mevcut entitlement'ları güncelle
31 func updatePurchasedProducts() async {
32 var purchased: Set<String> = []
33
34 // Tüm aktif entitlement'ları kontrol et
35 for await result in Transaction.currentEntitlements {
36 do {
37 let transaction = try checkVerified(result)
38
39 // Revoked değilse ekle
40 if transaction.revocationDate == nil {
41 purchased.insert(transaction.productID)
42 }
43
44 } catch {
45 print("⚠️ Entitlement doğrulama hatası: \(error)")
46 }
47 }
48
49 // UI güncelle
50 await MainActor.run {
51 self.purchasedProductIDs = purchased
52 self.updateSubscriptionStatus()
53 }
54 }
55
56 /// Subscription durumunu güncelle
57 private func updateSubscriptionStatus() {
58 // En yüksek tier'ı bul
59 if purchasedProductIDs.contains(ProductID.lifetimePro.rawValue) {
60 subscriptionStatus = .subscribed(tier: .lifetime, expiresAt: nil)
61 } else if purchasedProductIDs.contains(ProductID.yearlyPro.rawValue) {
62 subscriptionStatus = .subscribed(tier: .yearly, expiresAt: getExpirationDate(for: .yearlyPro))
63 } else if purchasedProductIDs.contains(ProductID.monthlyPro.rawValue) {
64 subscriptionStatus = .subscribed(tier: .monthly, expiresAt: getExpirationDate(for: .monthlyPro))
65 } else if purchasedProductIDs.contains(ProductID.weeklyPro.rawValue) {
66 subscriptionStatus = .subscribed(tier: .weekly, expiresAt: getExpirationDate(for: .weeklyPro))
67 } else {
68 subscriptionStatus = .notSubscribed
69 }
70 }
71
72 private func getExpirationDate(for productID: ProductID) -> Date? {
73 // Transaction'dan expiration date al
74 Task {
75 for await result in Transaction.currentEntitlements {
76 if case .verified(let transaction) = result,
77 transaction.productID == productID.rawValue {
78 return transaction.expirationDate
79 }
80 }
81 return nil
82 }
83 return nil
84 }
85}

📱 SwiftUI Entegrasyonu

6. Paywall View

swift
1import SwiftUI
2 
3// MARK: - Modern Paywall View
4struct PaywallView: View {
5 @StateObject private var store = StoreManager.shared
6 @State private var selectedProduct: Product?
7 @State private var isPurchasing = false
8 @State private var showError = false
9 @Environment(\.dismiss) private var dismiss
10
11 var body: some View {
12 NavigationStack {
13 ScrollView {
14 VStack(spacing: 32) {
15 // Hero Section
16 headerSection
17
18 // Features
19 featuresSection
20
21 // Products
22 productsSection
23
24 // Trust badges
25 trustSection
26
27 // Legal
28 legalSection
29 }
30 .padding()
31 }
32 .navigationTitle("Pro'ya Yükselt")
33 .navigationBarTitleDisplayMode(.inline)
34 .toolbar {
35 ToolbarItem(placement: .cancellationAction) {
36 Button("Kapat") { dismiss() }
37 }
38 ToolbarItem(placement: .primaryAction) {
39 Button("Geri Yükle") {
40 Task { try? await store.restorePurchases() }
41 }
42 .font(.footnote)
43 }
44 }
45 .alert("Hata", isPresented: $showError) {
46 Button("Tamam") { store.error = nil }
47 } message: {
48 Text(store.error?.localizedDescription ?? "Bir hata oluştu")
49 }
50 .onChange(of: store.error) { _, error in
51 showError = error != nil
52 }
53 .overlay {
54 if isPurchasing {
55 purchaseOverlay
56 }
57 }
58 }
59 }
60
61 // MARK: - Header Section
62 private var headerSection: some View {
63 VStack(spacing: 16) {
64 Image(systemName: "crown.fill")
65 .font(.system(size: 60))
66 .foregroundStyle(.linearGradient(
67 colors: [.yellow, .orange],
68 startPoint: .topLeading,
69 endPoint: .bottomTrailing
70 ))
71
72 Text("Tüm Özelliklerin Kilidini Aç")
73 .font(.title.bold())
74
75 Text("Sınırsız erişim, reklamsız deneyim")
76 .foregroundStyle(.secondary)
77 }
78 }
79
80 // MARK: - Features Section
81 private var featuresSection: some View {
82 VStack(alignment: .leading, spacing: 12) {
83 FeatureRow(icon: "infinity", title: "Sınırsız Kullanım", subtitle: "Hiçbir limit yok")
84 FeatureRow(icon: "bolt.fill", title: "Tüm Özellikler", subtitle: "Premium araçlar")
85 FeatureRow(icon: "xmark.circle.fill", title: "Reklamsız", subtitle: "Temiz deneyim")
86 FeatureRow(icon: "icloud.fill", title: "Cloud Sync", subtitle: "Tüm cihazlarda")
87 FeatureRow(icon: "heart.fill", title: "Öncelikli Destek", subtitle: "7/24 yardım")
88 }
89 .padding()
90 .background(.ultraThinMaterial)
91 .clipShape(RoundedRectangle(cornerRadius: 16))
92 }
93
94 // MARK: - Products Section
95 private var productsSection: some View {
96 VStack(spacing: 12) {
97 ForEach(store.subscriptionProducts) { product in
98 ProductCard(
99 product: product,
100 isSelected: selectedProduct?.id == product.id,
101 isPurchased: store.purchasedProductIDs.contains(product.id)
102 ) {
103 selectedProduct = product
104 }
105 }
106
107 // Purchase button
108 if let product = selectedProduct {
109 Button {
110 Task { await purchase(product) }
111 } label: {
112 HStack {
113 Text("Devam Et")
114 .fontWeight(.semibold)
115 Text("- \(product.displayPrice)")
116 }
117 .frame(maxWidth: .infinity)
118 .padding()
119 }
120 .buttonStyle(.borderedProminent)
121 .disabled(isPurchasing || store.purchasedProductIDs.contains(product.id))
122 }
123 }
124 }
125
126 // MARK: - Trust Section
127 private var trustSection: some View {
128 HStack(spacing: 24) {
129 TrustBadge(icon: "lock.fill", text: "Güvenli Ödeme")
130 TrustBadge(icon: "arrow.counterclockwise", text: "İstediğin Zaman İptal")
131 TrustBadge(icon: "star.fill", text: "4.9 ★")
132 }
133 .font(.caption)
134 .foregroundStyle(.secondary)
135 }
136
137 // MARK: - Legal Section
138 private var legalSection: some View {
139 VStack(spacing: 8) {
140 Text("Abonelik otomatik olarak yenilenir. Dönem sonundan en az 24 saat önce iptal edilmezse ücretlendirilirsiniz.")
141
142 HStack {
143 Link("Kullanım Şartları", destination: URL(string: "https://myapp.com/terms")!)
144 Text("•")
145 Link("Gizlilik Politikası", destination: URL(string: "https://myapp.com/privacy")!)
146 }
147 }
148 .font(.caption2)
149 .foregroundStyle(.secondary)
150 .multilineTextAlignment(.center)
151 }
152
153 // MARK: - Purchase Overlay
154 private var purchaseOverlay: some View {
155 ZStack {
156 Color.black.opacity(0.5)
157 .ignoresSafeArea()
158
159 VStack(spacing: 16) {
160 ProgressView()
161 .scaleEffect(1.5)
162 Text("İşleniyor...")
163 .font(.headline)
164 }
165 .padding(32)
166 .background(.ultraThinMaterial)
167 .clipShape(RoundedRectangle(cornerRadius: 16))
168 }
169 }
170
171 // MARK: - Purchase Action
172 private func purchase(_ product: Product) async {
173 isPurchasing = true
174 defer { isPurchasing = false }
175
176 do {
177 let transaction = try await store.purchase(product)
178 if transaction != nil {
179 dismiss()
180 }
181 } catch StoreError.userCancelled {
182 // Kullanıcı iptal etti, sessizce devam et
183 } catch {
184 // Hata store.error'a yazılacak
185 }
186 }
187}
188 
189// MARK: - Supporting Views
190struct FeatureRow: View {
191 let icon: String
192 let title: String
193 let subtitle: String
194
195 var body: some View {
196 HStack(spacing: 16) {
197 Image(systemName: icon)
198 .font(.title2)
199 .foregroundStyle(.green)
200 .frame(width: 32)
201
202 VStack(alignment: .leading) {
203 Text(title).fontWeight(.medium)
204 Text(subtitle).font(.caption).foregroundStyle(.secondary)
205 }
206
207 Spacer()
208
209 Image(systemName: "checkmark.circle.fill")
210 .foregroundStyle(.green)
211 }
212 }
213}
214 
215struct ProductCard: View {
216 let product: Product
217 let isSelected: Bool
218 let isPurchased: Bool
219 let onTap: () -> Void
220
221 var body: some View {
222 Button(action: onTap) {
223 HStack {
224 VStack(alignment: .leading, spacing: 4) {
225 HStack {
226 Text(product.displayName)
227 .fontWeight(.semibold)
228
229 if isBestValue {
230 Text("EN POPÜLER")
231 .font(.caption2.bold())
232 .padding(.horizontal, 8)
233 .padding(.vertical, 2)
234 .background(.orange)
235 .foregroundStyle(.white)
236 .clipShape(Capsule())
237 }
238 }
239
240 Text(product.description)
241 .font(.caption)
242 .foregroundStyle(.secondary)
243 }
244
245 Spacer()
246
247 if isPurchased {
248 Image(systemName: "checkmark.circle.fill")
249 .foregroundStyle(.green)
250 } else {
251 VStack(alignment: .trailing) {
252 Text(product.displayPrice)
253 .fontWeight(.bold)
254 if let period = product.subscription?.subscriptionPeriod {
255 Text(periodText(period))
256 .font(.caption)
257 .foregroundStyle(.secondary)
258 }
259 }
260 }
261 }
262 .padding()
263 .background(isSelected ? Color.accentColor.opacity(0.1) : Color(.secondarySystemBackground))
264 .clipShape(RoundedRectangle(cornerRadius: 12))
265 .overlay(
266 RoundedRectangle(cornerRadius: 12)
267 .stroke(isSelected ? Color.accentColor : .clear, lineWidth: 2)
268 )
269 }
270 .buttonStyle(.plain)
271 }
272
273 private var isBestValue: Bool {
274 product.id.contains("yearly")
275 }
276
277 private func periodText(_ period: Product.SubscriptionPeriod) -> String {
278 switch period.unit {
279 case .day: return "/ gün"
280 case .week: return "/ hafta"
281 case .month: return "/ ay"
282 case .year: return "/ yıl"
283 @unknown default: return ""
284 }
285 }
286}
287 
288struct TrustBadge: View {
289 let icon: String
290 let text: String
291
292 var body: some View {
293 VStack(spacing: 4) {
294 Image(systemName: icon)
295 Text(text)
296 }
297 }
298}

🧪 Testing ve Sandbox

7. StoreKit Configuration File ile Test

swift
1// MARK: - Test Helpers
2#if DEBUG
3extension StoreManager {
4 /// Sandbox ortamında tüm subscription'ları hızlandır
5 /// Xcode > Product > Scheme > Edit Scheme > Run > Options >
6 /// StoreKit Configuration > Select your .storekit file
7
8 /// Test: Satın alma simülasyonu
9 func simulatePurchase(productID: ProductID) async {
10 guard let product = product(for: productID) else { return }
11
12 // Test modunda doğrudan entitlement ekle
13 purchasedProductIDs.insert(product.id)
14 updateSubscriptionStatus()
15
16 print("🧪 Simulated purchase: \(productID.rawValue)")
17 }
18
19 /// Test: Subscription expire simülasyonu
20 func simulateExpiration() async {
21 purchasedProductIDs.removeAll()
22 subscriptionStatus = .notSubscribed
23
24 print("🧪 Simulated expiration")
25 }
26
27 /// Test: Refund simülasyonu
28 func simulateRefund(productID: ProductID) async {
29 purchasedProductIDs.remove(productID.rawValue)
30 updateSubscriptionStatus()
31
32 print("🧪 Simulated refund: \(productID.rawValue)")
33 }
34}
35#endif
36 
37// MARK: - StoreKit Configuration Checklist
38/*
39 📋 STOREKIT TEST CHECKLIST:
40
41 1. ☐ File > New > File > StoreKit Configuration File
42 2. ☐ Tüm product'ları ekle (consumable, non-consumable, subscription)
43 3. ☐ Subscription group oluştur
44 4. ☐ Price tier'ları ayarla
45 5. ☐ Scheme > Edit Scheme > Run > Options > StoreKit Configuration
46 6. ☐ Simulator'da test et
47
48 SANDBOX TEST CHECKLIST:
49
50 1. ☐ App Store Connect'te Sandbox Tester oluştur
51 2. ☐ Cihazda Settings > App Store > Sandbox Account
52 3. ☐ Gerçek cihazda test et
53 4. ☐ Subscription renewal hızlandırma aktif (5 dk = 1 ay)
54 5. ☐ Tüm durumları test et:
55 - ☐ Başarılı satın alma
56 - ☐ İptal
57 - ☐ Ask to Buy (pending)
58 - ☐ Restore
59 - ☐ Subscription expiration
60 - ☐ Grace period
61 - ☐ Billing retry
62*/

🔐 Server-Side Receipt Validation

8. Backend Entegrasyonu

swift
1// MARK: - Server-Side Validation (Recommended for Production)
2extension StoreManager {
3 /// Server-side receipt validation
4 func validateReceiptOnServer(transaction: Transaction) async throws -> Bool {
5 // JWS formatında signed transaction
6 guard let jwsRepresentation = transaction.jwsRepresentation else {
7 return false
8 }
9
10 // Backend'e gönder
11 let url = URL(string: "https://api.myapp.com/validate-receipt")!
12 var request = URLRequest(url: url)
13 request.httpMethod = "POST"
14 request.setValue("application/json", forHTTPHeaderField: "Content-Type")
15
16 let body: [String: Any] = [
17 "signedTransaction": jwsRepresentation,
18 "environment": AppStore.environment.rawValue
19 ]
20 request.httpBody = try? JSONSerialization.data(withJSONObject: body)
21
22 let (data, response) = try await URLSession.shared.data(for: request)
23
24 guard let httpResponse = response as? HTTPURLResponse,
25 httpResponse.statusCode == 200 else {
26 return false
27 }
28
29 // Backend response'u parse et
30 let result = try JSONDecoder().decode(ValidationResponse.self, from: data)
31 return result.isValid
32 }
33
34 /// App Store environment kontrolü
35 var currentEnvironment: String {
36 switch AppStore.environment {
37 case .production: return "Production"
38 case .sandbox: return "Sandbox"
39 case .xcode: return "Xcode"
40 @unknown default: return "Unknown"
41 }
42 }
43}
44 
45struct ValidationResponse: Codable {
46 let isValid: Bool
47 let expirationDate: Date?
48 let productID: String?
49}

Okuyucu Ödülü

markdown
# 🎁 STOREKIT 2 PRODUCTION CHECKLIST
 
## App Store Connect Setup
- [ ] Products oluşturuldu (Consumable, Non-Consumable, Subscription)
- [ ] Subscription Groups tanımlandı
- [ ] Price tiers ayarlandı (tüm bölgeler)
- [ ] Screenshots/Preview hazır
- [ ] Review notes yazıldı
- [ ] Tax & banking bilgileri tamamlandı
 
## Code Implementation
- [ ] Product loading
- [ ] Purchase flow
- [ ] Transaction listener (background)
- [ ] Entitlement check
- [ ] Restore purchases
- [ ] Error handling
- [ ] Offline support
 
## Testing
- [ ] StoreKit Configuration File test
- [ ] Sandbox account test
- [ ] Tüm purchase türleri test edildi
- [ ] Refund senaryosu test edildi
- [ ] Network error senaryosu test edildi
- [ ] Ask to Buy test edildi
 
## Analytics & Monitoring
- [ ] Purchase events tracked
- [ ] Revenue analytics entegre
- [ ] Error logging aktif
- [ ] Crash reporting aktif
 
## Legal & Compliance
- [ ] Terms of Service linki
- [ ] Privacy Policy linki
- [ ] Subscription disclosure metni
- [ ] Restore button mevcut
- [ ] Price displayed correctly
 
## Server-Side (Recommended)
- [ ] Receipt validation endpoint
- [ ] Webhook entegrasyonu
- [ ] User entitlement database
- [ ] Refund handling

<a id="storekit2-pro-tips"></a>

💡 Pro Tips ve Best Practices

  1. Transaction.finish() çağırmayı UNUTMA! - Aksi halde kullanıcı aynı ürünü tekrar satın alamaz.
  1. Offline desteği ekle - Transaction'ları local cache'le ve internet gelince sync et.
  1. Price'ı hardcode yapma - Her zaman product.displayPrice kullan.
  1. Introductory offers - product.subscription?.introductoryOffer ile free trial sun.
  1. Grace period handling - Subscription expired olsa bile grace period'da erişim ver.
  1. Promotional offers - Server-signed promotional offer'lar ile churn azalt.
  1. App Store Server Notifications v2 - Webhook ile real-time billing events al.
  1. StoreKit Testing in Xcode - Production'a çıkmadan önce tüm edge case'leri test et.

Easter Egg

Gizli bir bilgi buldun!

Bu bölümde gizli bir bilgi var. Keşfetmek ister misin?

📖 Sonuç

StoreKit 2, iOS'ta in-app purchase'ı devrimsel şekilde basitleştirdi. async/await, otomatik JWS doğrulama ve SwiftUI entegrasyonu ile artık daha az kod yazarak daha güvenli monetizasyon sistemleri kurabilirsin.

Unutma: Kullanıcı deneyimi her şeyden önemli. Paywall'ını A/B test et, pricing stratejini optimize et ve her zaman değer sun!

Bir sonraki yazıda görüşmek üzere! 🚀


*Bu yazıyı faydalı bulduysan, Twitter'da paylaş ve beni takip et!*

Easter Egg

Gizli bir bilgi buldun!

Bu bölümde gizli bir bilgi var. Keşfetmek ister misin?

ALTIN İPUCU

Bu yazının en değerli bilgisi

Bu ipucu, yazının en önemli çıkarımını içeriyor.

Etiketler

#StoreKit#In-App Purchase#iOS#Subscriptions#Monetization#SwiftUI
Muhittin Çamdalı

Muhittin Çamdalı

Lead Mobile Engineer

12+ yıllık deneyime sahip Lead Mobile Engineer. Swift, SwiftUI, Kotlin ve Flutter ile iOS, Android ve cross-platform mimarilerde uzman. Performanslı ve kullanıcı dostu mobil uygulamalar geliştiriyorum.

iOS Geliştirme Haberleri

Haftalık Swift tips, SwiftUI tricks ve iOS best practices. Spam yok, sadece değerli içerik.

Onay e-postasındaki bağlantıyı açıp “Aboneliğimi onayla” düğmesine bastığında aboneliğin başlar. Bültende açılma/tıklama istatistikleri tutulur; dilediğin an tek tıkla ayrılabilirsin. Gizlilik

Paylaş

İlgili İçerik