15 KiB
15 KiB
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:
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
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
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
Button("Manage Subscription") {
Task {
if let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene {
try? await AppStore.showManageSubscriptions(in: windowScene)
}
}
}
Handle Subscription Renewal
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
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
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:
- File > New > File > StoreKit Configuration File
- Add products matching your App Store Connect configuration
- Run with: Edit Scheme > Run > Options > StoreKit Configuration
Test Purchase Scenarios
#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
func purchase(_ product: Product) async throws -> PurchaseResult {
let result = try await product.purchase()
// ...
await updatePurchasedProducts() // Always update
return result
}
Handle Grace Period
if purchaseService.subscriptionStatus == .inGracePeriod {
// Show warning but allow access
showGracePeriodBanner()
}
Finish Transactions Promptly
// 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.