Initial commit
This commit is contained in:
553
skills/expertise/iphone-apps/references/storekit.md
Normal file
553
skills/expertise/iphone-apps/references/storekit.md
Normal file
@@ -0,0 +1,553 @@
|
||||
# StoreKit 2
|
||||
|
||||
In-app purchases, subscriptions, and paywalls for iOS apps.
|
||||
|
||||
## Basic Setup
|
||||
|
||||
### Product Configuration
|
||||
|
||||
Define products in App Store Connect, then load in app:
|
||||
|
||||
```swift
|
||||
import StoreKit
|
||||
|
||||
@Observable
|
||||
class PurchaseService {
|
||||
private(set) var products: [Product] = []
|
||||
private(set) var purchasedProductIDs: Set<String> = []
|
||||
private(set) var subscriptionStatus: SubscriptionStatus = .unknown
|
||||
|
||||
private var transactionListener: Task<Void, Error>?
|
||||
|
||||
enum SubscriptionStatus {
|
||||
case unknown
|
||||
case subscribed
|
||||
case expired
|
||||
case inGracePeriod
|
||||
case notSubscribed
|
||||
}
|
||||
|
||||
init() {
|
||||
transactionListener = listenForTransactions()
|
||||
}
|
||||
|
||||
deinit {
|
||||
transactionListener?.cancel()
|
||||
}
|
||||
|
||||
func loadProducts() async throws {
|
||||
let productIDs = [
|
||||
"com.app.premium.monthly",
|
||||
"com.app.premium.yearly",
|
||||
"com.app.lifetime"
|
||||
]
|
||||
products = try await Product.products(for: productIDs)
|
||||
.sorted { $0.price < $1.price }
|
||||
}
|
||||
|
||||
func purchase(_ product: Product) async throws -> PurchaseResult {
|
||||
let result = try await product.purchase()
|
||||
|
||||
switch result {
|
||||
case .success(let verification):
|
||||
let transaction = try checkVerified(verification)
|
||||
await updatePurchasedProducts()
|
||||
await transaction.finish()
|
||||
return .success
|
||||
|
||||
case .userCancelled:
|
||||
return .cancelled
|
||||
|
||||
case .pending:
|
||||
return .pending
|
||||
|
||||
@unknown default:
|
||||
return .failed
|
||||
}
|
||||
}
|
||||
|
||||
func restorePurchases() async throws {
|
||||
try await AppStore.sync()
|
||||
await updatePurchasedProducts()
|
||||
}
|
||||
|
||||
private func checkVerified<T>(_ result: VerificationResult<T>) throws -> T {
|
||||
switch result {
|
||||
case .unverified(_, let error):
|
||||
throw StoreError.verificationFailed(error)
|
||||
case .verified(let safe):
|
||||
return safe
|
||||
}
|
||||
}
|
||||
|
||||
func updatePurchasedProducts() async {
|
||||
var purchased: Set<String> = []
|
||||
|
||||
// Check non-consumables and subscriptions
|
||||
for await result in Transaction.currentEntitlements {
|
||||
guard case .verified(let transaction) = result else { continue }
|
||||
purchased.insert(transaction.productID)
|
||||
}
|
||||
|
||||
purchasedProductIDs = purchased
|
||||
await updateSubscriptionStatus()
|
||||
}
|
||||
|
||||
private func updateSubscriptionStatus() async {
|
||||
// Check subscription group status
|
||||
guard let groupID = products.first?.subscription?.subscriptionGroupID else {
|
||||
subscriptionStatus = .notSubscribed
|
||||
return
|
||||
}
|
||||
|
||||
do {
|
||||
let statuses = try await Product.SubscriptionInfo.status(for: groupID)
|
||||
guard let status = statuses.first else {
|
||||
subscriptionStatus = .notSubscribed
|
||||
return
|
||||
}
|
||||
|
||||
switch status.state {
|
||||
case .subscribed:
|
||||
subscriptionStatus = .subscribed
|
||||
case .expired:
|
||||
subscriptionStatus = .expired
|
||||
case .inGracePeriod:
|
||||
subscriptionStatus = .inGracePeriod
|
||||
case .revoked:
|
||||
subscriptionStatus = .notSubscribed
|
||||
default:
|
||||
subscriptionStatus = .unknown
|
||||
}
|
||||
} catch {
|
||||
subscriptionStatus = .unknown
|
||||
}
|
||||
}
|
||||
|
||||
private func listenForTransactions() -> Task<Void, Error> {
|
||||
Task.detached {
|
||||
for await result in Transaction.updates {
|
||||
guard case .verified(let transaction) = result else { continue }
|
||||
await self.updatePurchasedProducts()
|
||||
await transaction.finish()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum PurchaseResult {
|
||||
case success
|
||||
case cancelled
|
||||
case pending
|
||||
case failed
|
||||
}
|
||||
|
||||
enum StoreError: LocalizedError {
|
||||
case verificationFailed(Error)
|
||||
case productNotFound
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .verificationFailed:
|
||||
return "Purchase verification failed"
|
||||
case .productNotFound:
|
||||
return "Product not found"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Paywall UI
|
||||
|
||||
```swift
|
||||
struct PaywallView: View {
|
||||
@Environment(PurchaseService.self) private var purchaseService
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@State private var selectedProduct: Product?
|
||||
@State private var isPurchasing = false
|
||||
@State private var error: Error?
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
ScrollView {
|
||||
VStack(spacing: 24) {
|
||||
headerSection
|
||||
featuresSection
|
||||
productsSection
|
||||
termsSection
|
||||
}
|
||||
.padding()
|
||||
}
|
||||
.navigationTitle("Go Premium")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .cancellationAction) {
|
||||
Button("Close") { dismiss() }
|
||||
}
|
||||
}
|
||||
.task {
|
||||
try? await purchaseService.loadProducts()
|
||||
}
|
||||
.alert("Error", isPresented: .constant(error != nil)) {
|
||||
Button("OK") { error = nil }
|
||||
} message: {
|
||||
Text(error?.localizedDescription ?? "")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var headerSection: some View {
|
||||
VStack(spacing: 8) {
|
||||
Image(systemName: "crown.fill")
|
||||
.font(.system(size: 60))
|
||||
.foregroundStyle(.yellow)
|
||||
|
||||
Text("Unlock Premium")
|
||||
.font(.title.bold())
|
||||
|
||||
Text("Get access to all features")
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.padding(.top)
|
||||
}
|
||||
|
||||
private var featuresSection: some View {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
FeatureRow(icon: "checkmark.circle.fill", title: "Unlimited items")
|
||||
FeatureRow(icon: "checkmark.circle.fill", title: "Cloud sync")
|
||||
FeatureRow(icon: "checkmark.circle.fill", title: "Priority support")
|
||||
FeatureRow(icon: "checkmark.circle.fill", title: "No ads")
|
||||
}
|
||||
.padding()
|
||||
.background(.background.secondary)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 12))
|
||||
}
|
||||
|
||||
private var productsSection: some View {
|
||||
VStack(spacing: 12) {
|
||||
ForEach(purchaseService.products) { product in
|
||||
ProductButton(
|
||||
product: product,
|
||||
isSelected: selectedProduct == product,
|
||||
action: { selectedProduct = product }
|
||||
)
|
||||
}
|
||||
|
||||
Button {
|
||||
Task {
|
||||
await purchase()
|
||||
}
|
||||
} label: {
|
||||
if isPurchasing {
|
||||
ProgressView()
|
||||
} else {
|
||||
Text("Subscribe")
|
||||
}
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.controlSize(.large)
|
||||
.disabled(selectedProduct == nil || isPurchasing)
|
||||
|
||||
Button("Restore Purchases") {
|
||||
Task {
|
||||
try? await purchaseService.restorePurchases()
|
||||
}
|
||||
}
|
||||
.font(.caption)
|
||||
}
|
||||
}
|
||||
|
||||
private var termsSection: some View {
|
||||
VStack(spacing: 4) {
|
||||
Text("Subscription automatically renews unless canceled.")
|
||||
HStack {
|
||||
Link("Terms", destination: URL(string: "https://example.com/terms")!)
|
||||
Text("•")
|
||||
Link("Privacy", destination: URL(string: "https://example.com/privacy")!)
|
||||
}
|
||||
}
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
private func purchase() async {
|
||||
guard let product = selectedProduct else { return }
|
||||
|
||||
isPurchasing = true
|
||||
defer { isPurchasing = false }
|
||||
|
||||
do {
|
||||
let result = try await purchaseService.purchase(product)
|
||||
if result == .success {
|
||||
dismiss()
|
||||
}
|
||||
} catch {
|
||||
self.error = error
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct FeatureRow: View {
|
||||
let icon: String
|
||||
let title: String
|
||||
|
||||
var body: some View {
|
||||
HStack {
|
||||
Image(systemName: icon)
|
||||
.foregroundStyle(.green)
|
||||
Text(title)
|
||||
Spacer()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct ProductButton: View {
|
||||
let product: Product
|
||||
let isSelected: Bool
|
||||
let action: () -> Void
|
||||
|
||||
var body: some View {
|
||||
Button(action: action) {
|
||||
HStack {
|
||||
VStack(alignment: .leading) {
|
||||
Text(product.displayName)
|
||||
.font(.headline)
|
||||
if let subscription = product.subscription {
|
||||
Text(subscription.subscriptionPeriod.debugDescription)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
Spacer()
|
||||
Text(product.displayPrice)
|
||||
.font(.headline)
|
||||
}
|
||||
.padding()
|
||||
.background(isSelected ? Color.accentColor.opacity(0.1) : Color.clear)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 12)
|
||||
.stroke(isSelected ? Color.accentColor : Color.secondary.opacity(0.3), lineWidth: isSelected ? 2 : 1)
|
||||
)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Subscription Management
|
||||
|
||||
### Check Subscription Status
|
||||
|
||||
```swift
|
||||
extension PurchaseService {
|
||||
var isSubscribed: Bool {
|
||||
subscriptionStatus == .subscribed || subscriptionStatus == .inGracePeriod
|
||||
}
|
||||
|
||||
func checkAccess(for feature: Feature) -> Bool {
|
||||
switch feature {
|
||||
case .basic:
|
||||
return true
|
||||
case .premium:
|
||||
return isSubscribed || purchasedProductIDs.contains("com.app.lifetime")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum Feature {
|
||||
case basic
|
||||
case premium
|
||||
}
|
||||
```
|
||||
|
||||
### Show Manage Subscriptions
|
||||
|
||||
```swift
|
||||
Button("Manage Subscription") {
|
||||
Task {
|
||||
if let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene {
|
||||
try? await AppStore.showManageSubscriptions(in: windowScene)
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Handle Subscription Renewal
|
||||
|
||||
```swift
|
||||
extension PurchaseService {
|
||||
func getSubscriptionRenewalInfo() async -> RenewalInfo? {
|
||||
for await result in Transaction.currentEntitlements {
|
||||
guard case .verified(let transaction) = result,
|
||||
transaction.productType == .autoRenewable else { continue }
|
||||
|
||||
guard let renewalInfo = try? await transaction.subscriptionStatus?.renewalInfo,
|
||||
case .verified(let info) = renewalInfo else { continue }
|
||||
|
||||
return RenewalInfo(
|
||||
willRenew: info.willAutoRenew,
|
||||
expirationDate: transaction.expirationDate,
|
||||
isInBillingRetry: info.isInBillingRetry
|
||||
)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
struct RenewalInfo {
|
||||
let willRenew: Bool
|
||||
let expirationDate: Date?
|
||||
let isInBillingRetry: Bool
|
||||
}
|
||||
```
|
||||
|
||||
## Consumables
|
||||
|
||||
```swift
|
||||
extension PurchaseService {
|
||||
func purchaseConsumable(_ product: Product, quantity: Int = 1) async throws {
|
||||
let result = try await product.purchase()
|
||||
|
||||
switch result {
|
||||
case .success(let verification):
|
||||
let transaction = try checkVerified(verification)
|
||||
|
||||
// Grant content
|
||||
await grantConsumable(product.id, quantity: quantity)
|
||||
|
||||
// Must finish transaction for consumables
|
||||
await transaction.finish()
|
||||
|
||||
case .userCancelled, .pending:
|
||||
break
|
||||
|
||||
@unknown default:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
private func grantConsumable(_ productID: String, quantity: Int) async {
|
||||
// Add to user's balance (e.g., coins, credits)
|
||||
// This should be tracked in your own storage
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Promotional Offers
|
||||
|
||||
```swift
|
||||
extension PurchaseService {
|
||||
func purchaseWithOffer(_ product: Product, offerID: String) async throws -> PurchaseResult {
|
||||
// Generate signature on your server
|
||||
guard let keyID = await fetchKeyID(),
|
||||
let nonce = UUID().uuidString.data(using: .utf8),
|
||||
let signature = await generateSignature(productID: product.id, offerID: offerID) else {
|
||||
throw StoreError.offerSigningFailed
|
||||
}
|
||||
|
||||
let result = try await product.purchase(options: [
|
||||
.promotionalOffer(
|
||||
offerID: offerID,
|
||||
keyID: keyID,
|
||||
nonce: UUID(),
|
||||
signature: signature,
|
||||
timestamp: Int(Date().timeIntervalSince1970 * 1000)
|
||||
)
|
||||
])
|
||||
|
||||
// Handle result same as regular purchase
|
||||
switch result {
|
||||
case .success(let verification):
|
||||
let transaction = try checkVerified(verification)
|
||||
await updatePurchasedProducts()
|
||||
await transaction.finish()
|
||||
return .success
|
||||
case .userCancelled:
|
||||
return .cancelled
|
||||
case .pending:
|
||||
return .pending
|
||||
@unknown default:
|
||||
return .failed
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Testing
|
||||
|
||||
### StoreKit Configuration File
|
||||
|
||||
Create `Configuration.storekit` for local testing:
|
||||
|
||||
1. File > New > File > StoreKit Configuration File
|
||||
2. Add products matching your App Store Connect configuration
|
||||
3. Run with: Edit Scheme > Run > Options > StoreKit Configuration
|
||||
|
||||
### Test Purchase Scenarios
|
||||
|
||||
```swift
|
||||
#if DEBUG
|
||||
extension PurchaseService {
|
||||
func simulatePurchase() async {
|
||||
purchasedProductIDs.insert("com.app.premium.monthly")
|
||||
subscriptionStatus = .subscribed
|
||||
}
|
||||
|
||||
func clearPurchases() async {
|
||||
purchasedProductIDs.removeAll()
|
||||
subscriptionStatus = .notSubscribed
|
||||
}
|
||||
}
|
||||
#endif
|
||||
```
|
||||
|
||||
### Transaction Manager (Testing)
|
||||
|
||||
Use Transaction Manager in Xcode to:
|
||||
- Clear purchase history
|
||||
- Simulate subscription expiration
|
||||
- Test renewal scenarios
|
||||
- Simulate billing issues
|
||||
|
||||
## App Store Server Notifications
|
||||
|
||||
Configure in App Store Connect to receive:
|
||||
- Subscription renewals
|
||||
- Cancellations
|
||||
- Refunds
|
||||
- Grace period events
|
||||
|
||||
Handle on your server to update user access accordingly.
|
||||
|
||||
## Best Practices
|
||||
|
||||
### Always Update UI After Purchase
|
||||
|
||||
```swift
|
||||
func purchase(_ product: Product) async throws -> PurchaseResult {
|
||||
let result = try await product.purchase()
|
||||
// ...
|
||||
await updatePurchasedProducts() // Always update
|
||||
return result
|
||||
}
|
||||
```
|
||||
|
||||
### Handle Grace Period
|
||||
|
||||
```swift
|
||||
if purchaseService.subscriptionStatus == .inGracePeriod {
|
||||
// Show warning but allow access
|
||||
showGracePeriodBanner()
|
||||
}
|
||||
```
|
||||
|
||||
### Finish Transactions Promptly
|
||||
|
||||
```swift
|
||||
// Always finish after granting content
|
||||
await transaction.finish()
|
||||
```
|
||||
|
||||
### Test on Real Device
|
||||
|
||||
StoreKit Testing is great for development, but always test with sandbox accounts on real devices before release.
|
||||
Reference in New Issue
Block a user