These links will explain everything:
Explainer: https://developer.apple.com/in-app-purchase/
IAPs with StoreKit: https://developer.apple.com/documentation/storekit/in-app-purchase
Create a Sandbox Apple Account: https://developer.apple.com/help/app-store-connect/test-in-app-purchases/create-a-sandbox-apple-account/
Product information for an IAP: https://developer.apple.com/help/app-store-connect/reference/in-app-purchases-and-subscriptions/in-app-purchase-information
Overview for configuring IAPs: https://developer.apple.com/help/app-store-connect/configure-in-app-purchase-settings/overview-for-configuring-in-app-purchases
View and edit In-App Purchase information: https://developer.apple.com/help/app-store-connect/manage-in-app-purchases/view-and-edit-in-app-purchase-information
Testing refund requests: https://developer.apple.com/documentation/storekit/testing-refund-requests
Note: You must test this on a physical device. In other words, create your sandbox Apple Account and sign in on a physical device. Deploy your app to that device to test the IAPs. You cannot test them in the Simulator.
This code might help you to start:
// StoreProductController.swift
import Combine
import Foundation
import StoreKit
@MainActor
public final class StoreProductController: ObservableObject {
@Published public internal(set) var product: Product?
@Published public private(set) var isEntitled: Bool = false
@Published public private(set) var purchaseError: Error?
private let productID: String
internal nonisolated init(identifiedBy productID: String) {
self.productID = productID
Task(priority: .background) {
await self.updateEntitlement()
}
}
public func purchase() async {
guard let product = product else {
return
}
do {
let result = try await product.purchase()
switch result {
case .success(let verificationResult):
switch(verificationResult) {
case .verified(let transaction):
// Give the user access to purchased content
self.isEntitled = true
await transaction.finish()
case .unverified(let transaction, let verificationError):
// Handle unverified transactions based on your business model
print("purchase(): Unverified transaction: \(transaction.debugDescription), error: \(verificationError.localizedDescription)")
}
case .pending:
// The purchase requires action from the customer. If the transaction completes, it's available through Transaction.updates
break
case .userCancelled:
// The user cancelled the purchase
break
@unknown default:
// Unknown result
break
}
} catch {
purchaseError = error
print("purchase(): Error: \(error.localizedDescription)")
}
}
internal func set(isEntitled: Bool) {
self.isEntitled = isEntitled
}
private func updateEntitlement() async {
switch await StoreKit.Transaction.currentEntitlement(for: productID) {
case .verified:
isEntitled = true
case .unverified(_, let error):
logger.info("Unverified entitlement for \(self.productID): \(error)")
fallthrough
case .none:
isEntitled = false
}
}
}
// These can go anywhere
enum ProductNames: String, Codable {
case free = "feature.free"
case pro = "feature.pro" // This must match the IAP in App Store Connect
}
extension ProductNames {
func humanReadablePurchaseLevel() -> String {
switch self {
case .free:
return "Free"
case .pro:
return "Pro"
}
}
}
// StoreActor.swift
import Foundation
import StoreKit
@globalActor public actor StoreActor {
public static let featurePro = ProductNames.pro.rawValue
static let allProductIDs: Set<String> = [featurePro]
public static let shared = StoreActor()
private var loadedProducts: [String: Product] = [:]
private var lastLoadError: Error?
private var productLoadingTask: Task<Void, Never>?
private var transactionUpdatesTask: Task<Void, Never>?
private var storefrontUpdatesTask: Task<Void, Never>?
public nonisolated let productController: StoreProductController
init() {
self.productController = StoreProductController(identifiedBy: Self.featurePro)
Task(priority: .background) {
await self.setupListenerTasksIfNecessary()
await self.loadProducts()
}
}
public func product(identifiedBy productID: String) async -> Product? {
await waitUntilProductsLoaded()
return loadedProducts[productID]
}
private func setupListenerTasksIfNecessary() {
if(transactionUpdatesTask == nil) {
transactionUpdatesTask = Task(priority: .background) {
for await update in StoreKit.Transaction.updates {
await self.handle(transaction: update)
}
}
}
if(storefrontUpdatesTask == nil) {
storefrontUpdatesTask = Task(priority: .background) {
for await update in Storefront.updates {
self.handle(storefrontUpdate: update)
}
}
}
}
private func waitUntilProductsLoaded() async {
if let task = productLoadingTask {
await task.value
} else if(loadedProducts.isEmpty) {
// You load all the products at once, so you can skip this if the dictionary is empty
let newTask = Task {
await loadProducts()
}
productLoadingTask = newTask
await newTask.value
}
}
private func loadProducts() async {
do {
let products = try await Product.products(for: Self.allProductIDs)
try Task.checkCancellation()
loadedProducts = products.reduce(into: [:]) { $0[$1.id] = $1 }
let premiumProduct = loadedProducts[Self.featurePro]
Task(priority: .utility) { @MainActor in
self.productController.product = premiumProduct
}
} catch {
lastLoadError = error
}
productLoadingTask = nil
}
private func handle(transaction: VerificationResult<StoreKit.Transaction>) async {
guard case .verified(let transaction) = transaction else {
print("Received unverified transaction: \(transaction)")
return
}
if(transaction.productType == .nonConsumable && transaction.productID == Self.featurePro) {
await productController.set(isEntitled: !transaction.isRevoked)
if(transaction.isRevoked) {
// Refund has been issued; disable pro features
}
}
await transaction.finish()
}
private func handle(storefrontUpdate newStorefront: Storefront) {
// Cancel existing loading task if necessary
if let task = productLoadingTask {
task.cancel()
}
// Load products again
productLoadingTask = Task(priority: .utility) {
await self.loadProducts()
}
}
}
public extension StoreKit.Transaction {
var isRevoked: Bool {
// The revocation date is never in the future
revocationDate != nil
}
}