diff --git a/.github/workflows/swift.yml b/.github/workflows/swift.yml index 3587c31..7240c1b 100644 --- a/.github/workflows/swift.yml +++ b/.github/workflows/swift.yml @@ -69,6 +69,12 @@ jobs: fi done + - name: Build the example app + run: | + # Examples/PaywallDemo uses the kit only through its public API, as a client would. + # Building it catches a public API change that breaks clients but not the tests. + xcodebuild build -project Examples/PaywallDemo/PaywallDemo.xcodeproj -scheme PaywallDemo -destination generic/platform=iOS\ Simulator CODE_SIGNING_ALLOWED=NO + - name: Generate DocC run: | mkdir -p docs diff --git a/Examples/PaywallDemo/PaywallDemo.storekit b/Examples/PaywallDemo/PaywallDemo.storekit new file mode 100644 index 0000000..e7a0db1 --- /dev/null +++ b/Examples/PaywallDemo/PaywallDemo.storekit @@ -0,0 +1,146 @@ +{ + "identifier" : "8C1D5E07", + "nonRenewingSubscriptions" : [ + + ], + "products" : [ + { + "displayPrice" : "0.99", + "familyShareable" : false, + "internalID" : "6740000001", + "localizations" : [ + { + "description" : "A pouch of 100 coins to spend in the app", + "displayName" : "100 Coins", + "locale" : "en_US" + } + ], + "productID" : "dev.purchasekit.coins.100", + "referenceName" : "100 Coins", + "type" : "Consumable" + }, + { + "displayPrice" : "79.99", + "familyShareable" : true, + "internalID" : "6740000002", + "localizations" : [ + { + "description" : "Every Premium feature, paid once", + "displayName" : "Lifetime Unlock", + "locale" : "en_US" + } + ], + "productID" : "dev.purchasekit.lifetime", + "referenceName" : "Lifetime Unlock", + "type" : "NonConsumable" + } + ], + "settings" : { + "_applicationInternalID" : "6740000000", + "_askToBuyEnabled" : false, + "_billingGracePeriodEnabled" : true, + "_billingIssuesEnabled" : false, + "_developerTeamID" : "", + "_disableDialogs" : false, + "_failTransactionsEnabled" : false, + "_lastSynchronizedDate" : 0, + "_locale" : "en_US", + "_renewalBillingIssuesEnabled" : false, + "_storefront" : "USA", + "_storeKitErrors" : [ + + ] + }, + "subscriptionGroups" : [ + { + "id" : "21500001", + "localizations" : [ + + ], + "name" : "Premium", + "subscriptions" : [ + { + "adHocOffers" : [ + { + "displayPrice" : "0.99", + "internalID" : "6740000111", + "numberOfPeriods" : 1, + "offerID" : "dev.purchasekit.premium.monthly.retry", + "paymentMode" : "payAsYouGo", + "referenceName" : "First Month Back", + "subscriptionPeriod" : "P1M" + } + ], + "codeOffers" : [ + + ], + "displayPrice" : "4.99", + "familyShareable" : false, + "groupNumber" : 2, + "internalID" : "6740000011", + "localizations" : [ + { + "description" : "Premium, billed every month", + "displayName" : "Premium Monthly", + "locale" : "en_US" + } + ], + "productID" : "dev.purchasekit.premium.monthly", + "recurringSubscriptionPeriod" : "P1M", + "referenceName" : "Premium Monthly", + "subscriptionGroupID" : "21500001", + "type" : "RecurringSubscription", + "winbackOffers" : [ + { + "displayPrice" : "1.99", + "internalID" : "6740000112", + "isEligible" : true, + "numberOfPeriods" : 3, + "offerID" : "dev.purchasekit.premium.monthly.winback", + "paymentMode" : "payAsYouGo", + "referenceName" : "Welcome Back", + "subscriptionPeriod" : "P1M" + } + ] + }, + { + "adHocOffers" : [ + + ], + "codeOffers" : [ + + ], + "displayPrice" : "39.99", + "familyShareable" : false, + "groupNumber" : 1, + "internalID" : "6740000012", + "introductoryOffer" : { + "internalID" : "6740000121", + "numberOfPeriods" : 1, + "paymentMode" : "free", + "subscriptionPeriod" : "P3D" + }, + "localizations" : [ + { + "description" : "Premium, billed once a year", + "displayName" : "Premium Yearly", + "locale" : "en_US" + } + ], + "productID" : "dev.purchasekit.premium.yearly", + "recurringSubscriptionPeriod" : "P1Y", + "referenceName" : "Premium Yearly", + "subscriptionGroupID" : "21500001", + "type" : "RecurringSubscription", + "winbackOffers" : [ + + ] + } + ] + } + ], + "version" : { + "major" : 4, + "minor" : 0 + } +} diff --git a/Examples/PaywallDemo/PaywallDemo.xcodeproj/project.pbxproj b/Examples/PaywallDemo/PaywallDemo.xcodeproj/project.pbxproj new file mode 100644 index 0000000..4622851 --- /dev/null +++ b/Examples/PaywallDemo/PaywallDemo.xcodeproj/project.pbxproj @@ -0,0 +1,268 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 100; + objects = { + +/* Begin PBXBuildFile section */ + 7B0000000000000000000042 /* RKPurchaseKit in Frameworks */ = {isa = PBXBuildFile; productRef = 7B0000000000000000000041 /* RKPurchaseKit */; }; +/* End PBXBuildFile section */ + +/* Begin PBXFileReference section */ + 7B0000000000000000000006 /* PaywallDemo.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = PaywallDemo.app; sourceTree = BUILT_PRODUCTS_DIR; }; + 7B0000000000000000000007 /* PaywallDemo.storekit */ = {isa = PBXFileReference; lastKnownFileType = text; path = PaywallDemo.storekit; sourceTree = ""; }; + 7B0000000000000000000008 /* README.md */ = {isa = PBXFileReference; lastKnownFileType = net.daringfireball.markdown; path = README.md; sourceTree = ""; }; +/* End PBXFileReference section */ + +/* Begin PBXFileSystemSynchronizedRootGroup section */ + 7B0000000000000000000004 /* PaywallDemo */ = { + isa = PBXFileSystemSynchronizedRootGroup; + path = PaywallDemo; + sourceTree = ""; + }; +/* End PBXFileSystemSynchronizedRootGroup section */ + +/* Begin PBXFrameworksBuildPhase section */ + 7B0000000000000000000021 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 7B0000000000000000000042 /* RKPurchaseKit in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + 7B0000000000000000000002 = { + isa = PBXGroup; + children = ( + 7B0000000000000000000008 /* README.md */, + 7B0000000000000000000007 /* PaywallDemo.storekit */, + 7B0000000000000000000004 /* PaywallDemo */, + 7B0000000000000000000003 /* Products */, + ); + sourceTree = ""; + }; + 7B0000000000000000000003 /* Products */ = { + isa = PBXGroup; + children = ( + 7B0000000000000000000006 /* PaywallDemo.app */, + ); + name = Products; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + 7B0000000000000000000010 /* PaywallDemo */ = { + isa = PBXNativeTarget; + buildConfigurationList = 7B0000000000000000000053 /* Build configuration list for PBXNativeTarget "PaywallDemo" */; + buildPhases = ( + 7B0000000000000000000020 /* Sources */, + 7B0000000000000000000021 /* Frameworks */, + 7B0000000000000000000022 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + fileSystemSynchronizedGroups = ( + 7B0000000000000000000004 /* PaywallDemo */, + ); + name = PaywallDemo; + packageProductDependencies = ( + 7B0000000000000000000041 /* RKPurchaseKit */, + ); + productName = PaywallDemo; + productReference = 7B0000000000000000000006 /* PaywallDemo.app */; + productType = "com.apple.product-type.application"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + 7B0000000000000000000001 /* Project object */ = { + isa = PBXProject; + attributes = { + BuildIndependentTargetsInParallel = 1; + LastSwiftUpdateCheck = 2600; + LastUpgradeCheck = 2600; + TargetAttributes = { + 7B0000000000000000000010 = { + CreatedOnToolsVersion = 26.0; + }; + }; + }; + buildConfigurationList = 7B0000000000000000000050 /* Build configuration list for PBXProject "PaywallDemo" */; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = 7B0000000000000000000002; + minimizedProjectReferenceProxies = 1; + packageReferences = ( + 7B0000000000000000000040 /* XCLocalSwiftPackageReference "../.." */, + ); + preferredProjectObjectVersion = 100; + productRefGroup = 7B0000000000000000000003 /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + 7B0000000000000000000010 /* PaywallDemo */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + 7B0000000000000000000022 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + 7B0000000000000000000020 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin XCBuildConfiguration section */ + 7B0000000000000000000051 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = dwarf; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_OPTIMIZATION_LEVEL = 0; + IPHONEOS_DEPLOYMENT_TARGET = 26.0; + ONLY_ACTIVE_ARCH = YES; + SDKROOT = iphoneos; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 6.0; + }; + name = Debug; + }; + 7B0000000000000000000052 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + IPHONEOS_DEPLOYMENT_TARGET = 26.0; + SDKROOT = iphoneos; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 6.0; + VALIDATE_PRODUCT = YES; + }; + name = Release; + }; + 7B0000000000000000000054 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = ""; + ENABLE_PREVIEWS = YES; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_KEY_CFBundleDisplayName = "Paywall Demo"; + INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; + INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; + INFOPLIST_KEY_UILaunchScreen_Generation = YES; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = dev.purchasekit.PaywallDemo; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_EMIT_LOC_STRINGS = YES; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + 7B0000000000000000000055 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = ""; + ENABLE_PREVIEWS = YES; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_KEY_CFBundleDisplayName = "Paywall Demo"; + INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; + INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; + INFOPLIST_KEY_UILaunchScreen_Generation = YES; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = dev.purchasekit.PaywallDemo; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_EMIT_LOC_STRINGS = YES; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + 7B0000000000000000000050 /* Build configuration list for PBXProject "PaywallDemo" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 7B0000000000000000000051 /* Debug */, + 7B0000000000000000000052 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 7B0000000000000000000053 /* Build configuration list for PBXNativeTarget "PaywallDemo" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 7B0000000000000000000054 /* Debug */, + 7B0000000000000000000055 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + +/* Begin XCLocalSwiftPackageReference section */ + 7B0000000000000000000040 /* XCLocalSwiftPackageReference "../.." */ = { + isa = XCLocalSwiftPackageReference; + relativePath = ../..; + }; +/* End XCLocalSwiftPackageReference section */ + +/* Begin XCSwiftPackageProductDependency section */ + 7B0000000000000000000041 /* RKPurchaseKit */ = { + isa = XCSwiftPackageProductDependency; + productName = RKPurchaseKit; + }; +/* End XCSwiftPackageProductDependency section */ + }; + rootObject = 7B0000000000000000000001 /* Project object */; +} diff --git a/Examples/PaywallDemo/PaywallDemo.xcodeproj/xcshareddata/xcschemes/PaywallDemo.xcscheme b/Examples/PaywallDemo/PaywallDemo.xcodeproj/xcshareddata/xcschemes/PaywallDemo.xcscheme new file mode 100644 index 0000000..10b675e --- /dev/null +++ b/Examples/PaywallDemo/PaywallDemo.xcodeproj/xcshareddata/xcschemes/PaywallDemo.xcscheme @@ -0,0 +1,80 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Examples/PaywallDemo/PaywallDemo/Assets.xcassets/AccentColor.colorset/Contents.json b/Examples/PaywallDemo/PaywallDemo/Assets.xcassets/AccentColor.colorset/Contents.json new file mode 100644 index 0000000..3c1108c --- /dev/null +++ b/Examples/PaywallDemo/PaywallDemo/Assets.xcassets/AccentColor.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0.902", + "green" : "0.353", + "red" : "0.373" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "1.000", + "green" : "0.522", + "red" : "0.545" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Examples/PaywallDemo/PaywallDemo/Assets.xcassets/Contents.json b/Examples/PaywallDemo/PaywallDemo/Assets.xcassets/Contents.json new file mode 100644 index 0000000..73c0059 --- /dev/null +++ b/Examples/PaywallDemo/PaywallDemo/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Examples/PaywallDemo/PaywallDemo/Catalog.swift b/Examples/PaywallDemo/PaywallDemo/Catalog.swift new file mode 100644 index 0000000..e880f73 --- /dev/null +++ b/Examples/PaywallDemo/PaywallDemo/Catalog.swift @@ -0,0 +1,22 @@ +// +// Catalog.swift +// PaywallDemo +// + +/// The product identifiers this app sells. +/// +/// They match `PaywallDemo.storekit`, which the `PaywallDemo` scheme loads on Run, and they +/// would match App Store Connect in a shipping app. Keeping them in one place means a typo +/// fails to compile instead of silently producing a paywall that is one row short. +enum Catalog { + static let premiumMonthly = "dev.purchasekit.premium.monthly" + static let premiumYearly = "dev.purchasekit.premium.yearly" + static let coins = "dev.purchasekit.coins.100" + static let lifetime = "dev.purchasekit.lifetime" + + /// Everything handed to `PurchasesManager.configure(identifiers:)`, in display order. + static let all = [premiumYearly, premiumMonthly, lifetime, coins] + + /// How many coins one `coins` purchase credits. + static let coinsPerPurchase = 100 +} diff --git a/Examples/PaywallDemo/PaywallDemo/DemoPurchases.swift b/Examples/PaywallDemo/PaywallDemo/DemoPurchases.swift new file mode 100644 index 0000000..619ffcf --- /dev/null +++ b/Examples/PaywallDemo/PaywallDemo/DemoPurchases.swift @@ -0,0 +1,447 @@ +// +// DemoPurchases.swift +// PaywallDemo +// + +import Foundation +import RKPurchaseKit +import Synchronization +import UIKit + +/// The states demo mode can put the paywall in. +enum DemoScenario: String, CaseIterable, Identifiable, Sendable { + /// Real StoreKit, configured by `PaywallDemo.storekit` when run from Xcode. + case live + case newCustomer + case subscribed + case gracePeriod + case billingRetry + case expiredCancelled + case expiredBillingError + case revoked + + var id: String { rawValue } + + var title: String { + switch self { + case .live: "Live StoreKit" + case .newCustomer: "New Customer" + case .subscribed: "Subscribed" + case .gracePeriod: "Grace Period" + case .billingRetry: "Billing Retry" + case .expiredCancelled: "Expired: Cancelled" + case .expiredBillingError: "Expired: Billing Error" + case .revoked: "Revoked" + } + } + + var symbol: String { + switch self { + case .live: "bolt.horizontal.fill" + case .newCustomer: "person.crop.circle.badge.plus" + case .subscribed: "checkmark.seal" + case .gracePeriod: "creditcard.trianglebadge.exclamationmark" + case .billingRetry: "pause.circle" + case .expiredCancelled: "hand.wave" + case .expiredBillingError: "creditcard" + case .revoked: "xmark.seal" + } + } + + /// The store the paywall talks to in this scenario. + func makeBackend() -> any PurchasesBackend { + switch self { + case .live: PurchasesManager.shared + default: DemoPurchases(scenario: self) + } + } +} + +/// A stand-in store that answers like StoreKit would for a customer in one `DemoScenario`. +/// +/// Everything here is built from the kit's public initializers: `StoreProduct`, +/// `SubscriptionOffer`, `StoreTransaction` and `StoreSubscriptionStatus` all have one, so a +/// mock needs no StoreKit configuration file, no simulator account and no `@testable import`. +/// The same type drives demo mode, the SwiftUI previews, and would drive unit tests. +/// +/// Purchases succeed after a short delay and update the subscription state, so the live +/// update streams can be seen working. Showing "manage subscriptions" pretends the customer +/// fixed their payment method, which recovers a grace-period or billing-retry subscription. +final class DemoPurchases: PurchasesBackend { + static let groupID = "demo.premium" + + private struct State { + var status: StoreSubscriptionStatus? + var entitled: Set = [] + var hasUsedIntroOffer = false + var purchaseContinuations: [UUID: AsyncStream.Continuation] = [:] + var statusContinuations: [UUID: AsyncStream.Continuation] = [:] + } + + /// `Mutex` keeps the class `Sendable`, which `PurchasesProtocol` requires, without + /// turning it into an actor the previews would have to `await` into. + private let state: Mutex + private let delay: Duration + + init(scenario: DemoScenario, delay: Duration = .milliseconds(700)) { + let status = Self.status(for: scenario) + var initial = State(status: status, hasUsedIntroOffer: status != nil) + if status?.isActive == true, let productID = status?.transaction.productID { + initial.entitled.insert(productID) + } + state = Mutex(initial) + self.delay = delay + } + + // MARK: PurchasesProtocol + + func requestProducts(includingCache: Bool) async throws -> [StoreProduct] { + let entitled = state.withLock { $0.entitled } + return Self.products(entitled: entitled) + } + + func purchase( + productID: String, + options: PurchaseOptions + ) async throws -> (product: StoreProduct, transaction: StoreTransaction) { + try await Task.sleep(for: delay) + + // Mirror what StoreKit does with the same options, so the paywall's error handling + // is exercised for real. + if options.simulatesAskToBuyInSandbox { + throw PurchasesError(.purchasePending, productID: productID) + } + guard let product = Self.products(entitled: []).first(where: { $0.productID == productID }) else { + throw PurchasesError(.invalidProductID, productID: productID) + } + if let offerID = options.winBackOfferID, !product.winBackOffers.contains(where: { $0.id == offerID }) { + throw PurchasesError(.offerNotFound, productID: productID, offerID: offerID) + } + + let transaction = Self.transaction(for: product, purchased: .now) + let (purchaseListeners, statusListeners, status) = state.withLock { state in + if product.type != .consumable { + state.entitled.insert(productID) + } + if product.type.isAutoRenewableSubscription { + // A new subscription replaces whatever plan in the group came before. + state.entitled.subtract(Self.subscriptionIDs.subtracting([productID])) + state.hasUsedIntroOffer = true + state.status = StoreSubscriptionStatus( + state: .subscribed, + groupID: Self.groupID, + transaction: transaction, + willAutoRenew: true, + autoRenewProductID: productID, + renewalDate: transaction.expirationDate + ) + } + return ( + Array(state.purchaseContinuations.values), + Array(state.statusContinuations.values), + state.status + ) + } + let purchased = Self.products(entitled: [productID]).first { $0.productID == productID } ?? product + purchaseListeners.forEach { $0.yield(PurchasedProductEvent(product: purchased)) } + if product.type.isAutoRenewableSubscription, let status { + statusListeners.forEach { $0.yield(status) } + } + return (purchased, transaction) + } + + func restore() async throws { + try await Task.sleep(for: delay) + } + + func hasEntitlement(for productID: String) async -> Bool { + state.withLock { $0.entitled.contains(productID) } + } + + func entitlementProductIDs() async -> Set { + state.withLock { $0.entitled } + } + + func activeSubscriptions() async -> [StoreProduct] { + let entitled = state.withLock { $0.entitled } + return Self.products(entitled: entitled).filter { $0.isPurchased && $0.type.isAutoRenewableSubscription } + } + + func activeSubscription(inGroup groupID: String) async -> StoreProduct? { + await activeSubscriptions().first { $0.subscriptionGroupID == groupID } + } + + func subscriptionStatuses(inGroup groupID: String) async throws -> [StoreSubscriptionStatus] { + guard groupID == Self.groupID else { return [] } + return state.withLock { $0.status.map { [$0] } ?? [] } + } + + func isEligibleForIntroOffer(inGroup groupID: String) async -> Bool { + state.withLock { !$0.hasUsedIntroOffer } + } + + func appPurchaseInfo() async throws -> AppPurchaseInfo { + AppPurchaseInfo(originalAppVersion: "1.0", originalPurchaseDate: .now, appVersion: "1.0", environment: .xcode) + } + + // MARK: PurchasesBackend + + var purchasedProducts: AsyncStream { + // One stream per access, like `PurchasesManager`: a single `AsyncStream` iterated + // twice would split its events between the two loops. + let (stream, continuation) = AsyncStream.makeStream(of: PurchasedProductEvent.self) + let id = UUID() + state.withLock { $0.purchaseContinuations[id] = continuation } + continuation.onTermination = { [weak self] _ in + _ = self?.state.withLock { $0.purchaseContinuations.removeValue(forKey: id) } + } + return stream + } + + var subscriptionStatusUpdates: AsyncStream { + let (stream, continuation) = AsyncStream.makeStream(of: StoreSubscriptionStatus.self) + let id = UUID() + state.withLock { $0.statusContinuations[id] = continuation } + continuation.onTermination = { [weak self] _ in + _ = self?.state.withLock { $0.statusContinuations.removeValue(forKey: id) } + } + return stream + } + + @MainActor + func showManageSubscriptions(in scene: UIWindowScene, groupID: String?) async throws { + try await Task.sleep(for: delay) + // Pretend the customer fixed their card in Settings. A billing problem resolves into + // a renewed subscription, which arrives on `subscriptionStatusUpdates` exactly the way + // a real recovery does. + let (listeners, recovered) = state.withLock { state -> ([AsyncStream.Continuation], StoreSubscriptionStatus?) in + guard let current = state.status, + current.state == .inGracePeriod || current.state == .inBillingRetryPeriod + || (current.state == .subscribed && !current.willAutoRenew) + else { return ([], nil) } + let product = Self.products(entitled: []).first { $0.productID == current.transaction.productID } + let transaction = product.map { Self.transaction(for: $0, purchased: .now) } ?? current.transaction + let renewed = StoreSubscriptionStatus( + state: .subscribed, + groupID: Self.groupID, + transaction: transaction, + willAutoRenew: true, + autoRenewProductID: transaction.productID, + renewalDate: transaction.expirationDate + ) + state.status = renewed + state.entitled.insert(transaction.productID) + return (Array(state.statusContinuations.values), renewed) + } + if let recovered { + listeners.forEach { $0.yield(recovered) } + } + } + + deinit { + state.withLock { state in + state.purchaseContinuations.values.forEach { $0.finish() } + state.statusContinuations.values.forEach { $0.finish() } + } + } +} + +// MARK: - Fixtures + +extension DemoPurchases { + private static let subscriptionIDs: Set = [Catalog.premiumMonthly, Catalog.premiumYearly] + private static let month = StoreSubscriptionPeriod(value: 1, unit: .month) + private static let year = StoreSubscriptionPeriod(value: 1, unit: .year) + + /// The same catalog as `PaywallDemo.storekit`, built by hand. + static func products(entitled: Set) -> [StoreProduct] { + [ + StoreProduct( + productID: Catalog.premiumYearly, + type: .autoRenewable, + displayName: "Premium Yearly", + description: "Premium, billed once a year", + price: 39.99, + displayPrice: "$39.99", + currencyCode: "USD", + isPurchased: entitled.contains(Catalog.premiumYearly), + subscriptionGroupID: groupID, + subscriptionPeriod: year, + introductoryOffer: SubscriptionOffer( + id: nil, + type: .introductory, + paymentMode: .freeTrial, + price: 0, + displayPrice: "$0.00", + period: StoreSubscriptionPeriod(value: 3, unit: .day), + periodCount: 1 + ) + ), + StoreProduct( + productID: Catalog.premiumMonthly, + type: .autoRenewable, + displayName: "Premium Monthly", + description: "Premium, billed every month", + price: 4.99, + displayPrice: "$4.99", + currencyCode: "USD", + isPurchased: entitled.contains(Catalog.premiumMonthly), + subscriptionGroupID: groupID, + subscriptionPeriod: month, + promotionalOffers: [ + SubscriptionOffer( + id: "dev.purchasekit.premium.monthly.retry", + type: .promotional, + paymentMode: .payAsYouGo, + price: 0.99, + displayPrice: "$0.99", + period: month, + periodCount: 1 + ) + ], + winBackOffers: [ + SubscriptionOffer( + id: "dev.purchasekit.premium.monthly.winback", + type: .winBack, + paymentMode: .payAsYouGo, + price: 1.99, + displayPrice: "$1.99", + period: month, + periodCount: 3 + ) + ] + ), + StoreProduct( + productID: Catalog.lifetime, + type: .nonConsumable, + displayName: "Lifetime Unlock", + description: "Every Premium feature, paid once", + price: 79.99, + displayPrice: "$79.99", + currencyCode: "USD", + isFamilyShareable: true, + isPurchased: entitled.contains(Catalog.lifetime) + ), + StoreProduct( + productID: Catalog.coins, + type: .consumable, + displayName: "100 Coins", + description: "A pouch of 100 coins to spend in the app", + price: 0.99, + displayPrice: "$0.99", + currencyCode: "USD" + ), + ] + } + + private static func transaction(for product: StoreProduct, purchased: Date) -> StoreTransaction { + let calendar = Calendar.current + let expiration: Date? = switch product.subscriptionPeriod?.unit { + case .year?: calendar.date(byAdding: .year, value: 1, to: purchased) + case .month?: calendar.date(byAdding: .month, value: 1, to: purchased) + default: nil + } + return StoreTransaction( + id: UInt64(purchased.timeIntervalSince1970 * 1000), + productID: product.productID, + productType: product.type, + purchaseDate: purchased, + expirationDate: expiration, + subscriptionGroupID: product.subscriptionGroupID, + environment: .xcode + ) + } + + /// The renewal status a customer in `scenario` would have. + static func status(for scenario: DemoScenario) -> StoreSubscriptionStatus? { + let now = Date.now + let day: TimeInterval = 86_400 + func monthly(purchased: Date, expires: Date, revoked: Date? = nil) -> StoreTransaction { + StoreTransaction( + id: 2_000_000_001, + productID: Catalog.premiumMonthly, + productType: .autoRenewable, + purchaseDate: purchased, + originalPurchaseDate: purchased.addingTimeInterval(-90 * day), + expirationDate: expires, + revocationDate: revoked, + subscriptionGroupID: groupID, + environment: .xcode, + revocationReason: revoked == nil ? nil : .other, + revocationType: revoked == nil ? nil : .fullRefund + ) + } + + switch scenario { + case .live, .newCustomer: + return nil + + case .subscribed: + let transaction = monthly(purchased: now.addingTimeInterval(-10 * day), expires: now.addingTimeInterval(20 * day)) + return StoreSubscriptionStatus( + state: .subscribed, + groupID: groupID, + transaction: transaction, + willAutoRenew: true, + autoRenewProductID: Catalog.premiumMonthly, + renewalDate: transaction.expirationDate + ) + + case .gracePeriod: + // The renewal was due yesterday and failed. With a 16-day grace period the + // customer keeps access until the grace period expires. + return StoreSubscriptionStatus( + state: .inGracePeriod, + groupID: groupID, + transaction: monthly(purchased: now.addingTimeInterval(-31 * day), expires: now.addingTimeInterval(-1 * day)), + willAutoRenew: true, + autoRenewProductID: Catalog.premiumMonthly, + renewalDate: now.addingTimeInterval(-1 * day), + gracePeriodExpirationDate: now.addingTimeInterval(15 * day), + isInBillingRetry: true + ) + + case .billingRetry: + return StoreSubscriptionStatus( + state: .inBillingRetryPeriod, + groupID: groupID, + transaction: monthly(purchased: now.addingTimeInterval(-50 * day), expires: now.addingTimeInterval(-20 * day)), + willAutoRenew: true, + autoRenewProductID: Catalog.premiumMonthly, + renewalDate: now.addingTimeInterval(-20 * day), + isInBillingRetry: true + ) + + case .expiredCancelled: + return StoreSubscriptionStatus( + state: .expired, + groupID: groupID, + transaction: monthly(purchased: now.addingTimeInterval(-75 * day), expires: now.addingTimeInterval(-45 * day)), + autoRenewProductID: Catalog.premiumMonthly, + expirationReason: .autoRenewDisabled + ) + + case .expiredBillingError: + return StoreSubscriptionStatus( + state: .expired, + groupID: groupID, + transaction: monthly(purchased: now.addingTimeInterval(-95 * day), expires: now.addingTimeInterval(-65 * day)), + willAutoRenew: false, + autoRenewProductID: Catalog.premiumMonthly, + expirationReason: .billingError + ) + + case .revoked: + return StoreSubscriptionStatus( + state: .revoked, + groupID: groupID, + transaction: monthly( + purchased: now.addingTimeInterval(-12 * day), + expires: now.addingTimeInterval(18 * day), + revoked: now.addingTimeInterval(-2 * day) + ), + autoRenewProductID: Catalog.premiumMonthly + ) + } + } +} diff --git a/Examples/PaywallDemo/PaywallDemo/OfferText.swift b/Examples/PaywallDemo/PaywallDemo/OfferText.swift new file mode 100644 index 0000000..b400f17 --- /dev/null +++ b/Examples/PaywallDemo/PaywallDemo/OfferText.swift @@ -0,0 +1,79 @@ +// +// OfferText.swift +// PaywallDemo +// + +import Foundation +import RKPurchaseKit + +/// Turns `StoreSubscriptionPeriod` and `SubscriptionOffer` into the sentences a paywall shows. +/// +/// App Review expects the price after an offer to be as clear as the offer itself, so every +/// phrase here ends with what the customer pays afterwards: "3 days free, then $39.99/year". +/// +/// A shipping app would localize these through a String Catalog with plural rules. They are +/// spelled out in English here so the logic stays visible. +enum OfferText { + /// "month", "year", "3 months". + static func period(_ period: StoreSubscriptionPeriod) -> String { + duration(period.value, period.unit, singularDropsCount: true) + } + + /// "$4.99/month", "$39.99/year", "$9.99 every 3 months". + static func price(of product: StoreProduct) -> String { + guard let period = product.subscriptionPeriod else { return product.displayPrice } + return period.value == 1 + ? "\(product.displayPrice)/\(self.period(period))" + : "\(product.displayPrice) every \(self.period(period))" + } + + /// What one offer costs and for how long, followed by the regular price. + /// + /// - "3 days free, then $39.99/year" + /// - "$1.99/month for 3 months, then $4.99/month" + /// - "$0.99 for the first month, then $4.99/month" + /// - "$9.99 for the first 3 months, then $4.99/month" + static func describe(_ offer: SubscriptionOffer, then product: StoreProduct) -> String { + "\(summary(of: offer)), then \(price(of: product))" + } + + /// The offer on its own, for a badge: "3 days free", "$1.99/month for 3 months". + static func summary(of offer: SubscriptionOffer) -> String { + // `periodCount` periods of `period` each: a 1-week trial is one period of one week, + // "$1.99/month for 3 months" is three periods of one month. + let total = duration(offer.period.value * offer.periodCount, offer.period.unit) + + switch offer.paymentMode { + case .freeTrial: + return "\(total) free" + case .payAsYouGo where offer.periodCount == 1: + // "$0.99 for the first month" reads better than "$0.99/month for 1 month". + return "\(offer.displayPrice) for the first \(period(offer.period))" + case .payAsYouGo: + return "\(offer.displayPrice)/\(period(offer.period)) for \(total)" + case .payUpFront: + return "\(offer.displayPrice) for the first \(total)" + default: + // `PaymentMode` is extensible; never show a price we cannot explain. + return "\(offer.displayPrice) for \(total)" + } + } + + /// "7 days", "1 month", or with `singularDropsCount` just "month". + private static func duration( + _ count: Int, + _ unit: StoreSubscriptionPeriod.Unit, + singularDropsCount: Bool = false + ) -> String { + let word = switch unit { + case .day: "day" + case .week: "week" + case .month: "month" + case .year: "year" + } + if count == 1 { + return singularDropsCount ? word : "1 \(word)" + } + return "\(count) \(word)s" + } +} diff --git a/Examples/PaywallDemo/PaywallDemo/PaywallDemoApp.swift b/Examples/PaywallDemo/PaywallDemo/PaywallDemoApp.swift new file mode 100644 index 0000000..324bd96 --- /dev/null +++ b/Examples/PaywallDemo/PaywallDemo/PaywallDemoApp.swift @@ -0,0 +1,24 @@ +// +// PaywallDemoApp.swift +// PaywallDemo +// + +import RKPurchaseKit +import SwiftUI + +@main +struct PaywallDemoApp: App { + init() { + // Configure once, as early as possible. Besides naming the products, this starts the + // kit's transaction listener: purchases approved while the app was closed (Ask to + // Buy, renewals, purchases on another device) are delivered as soon as it launches. + // Calling it twice is a programmer error and traps. + PurchasesManager.configure(identifiers: Catalog.all) + } + + var body: some Scene { + WindowGroup { + RootView() + } + } +} diff --git a/Examples/PaywallDemo/PaywallDemo/PaywallModel.swift b/Examples/PaywallDemo/PaywallDemo/PaywallModel.swift new file mode 100644 index 0000000..0f357fb --- /dev/null +++ b/Examples/PaywallDemo/PaywallDemo/PaywallModel.swift @@ -0,0 +1,268 @@ +// +// PaywallModel.swift +// PaywallDemo +// + +import Foundation +import Observation +import RKPurchaseKit +import UIKit + +/// The paywall's state and the calls that change it. +/// +/// The model talks to `any PurchasesBackend`, never to `PurchasesManager.shared` directly, so +/// the exact same code path runs against StoreKit, against `DemoPurchases`, and in previews. +@MainActor +@Observable +final class PaywallModel { + enum Phase: Equatable { + case loading + case loaded + /// StoreKit returned no products. When run outside Xcode's Run action, the StoreKit + /// configuration file is not applied and this is what the customer would see. + case empty + case failed(String) + } + + /// A message that stays on screen, as opposed to an error alert. + struct Notice: Equatable { + var symbol: String + var title: String + var message: String + } + + let backend: any PurchasesBackend + + private(set) var phase: Phase = .loading + private(set) var plans: [StoreProduct] = [] + private(set) var extras: [StoreProduct] = [] + private(set) var status: StoreSubscriptionStatus? + private(set) var entitledProductIDs: Set = [] + private(set) var isEligibleForIntroOffer = false + /// The product a purchase is running for, so only its button shows progress. + private(set) var purchasingProductID: String? + private(set) var isRestoring = false + private(set) var coinBalance = 0 + + var selectedPlanID: String? + var notice: Notice? + var errorMessage: String? + /// Passed to every purchase as `PurchaseOptions.simulatesAskToBuyInSandbox`. + var simulatesAskToBuy = false + + init(backend: any PurchasesBackend) { + self.backend = backend + } + + // MARK: Derived state + + /// The subscription group, read from the products rather than hard-coded: the group ID + /// differs between the `.storekit` file, the sandbox and production. + var groupID: String? { plans.first?.subscriptionGroupID } + + /// The plan the current status belongs to, for its offers and its name. + var statusPlan: StoreProduct? { + guard let status else { return nil } + return plans.first { $0.productID == status.transaction.productID } + } + + var banner: SubscriptionBanner? { SubscriptionBanner(status: status, plan: statusPlan) } + + /// Premium access: an active subscription, which includes a grace period, or the + /// lifetime unlock. + var hasPremium: Bool { + (status?.isActive ?? false) || entitledProductIDs.contains(Catalog.lifetime) + } + + /// Whether `product` is the plan the customer currently pays for. + func isCurrentPlan(_ product: StoreProduct) -> Bool { + guard let status, status.isActive else { return false } + return status.transaction.productID == product.productID + } + + /// The introductory offer to advertise, or `nil` when this customer may not have one. + /// + /// `StoreProduct.introductoryOffer` says what is configured; only + /// `isEligibleForIntroOffer(inGroup:)` says whether *this* customer gets it. Advertising + /// a trial to someone who already used one is a broken promise at the payment sheet. + func introOffer(for product: StoreProduct) -> SubscriptionOffer? { + isEligibleForIntroOffer ? product.introductoryOffer : nil + } + + // MARK: Loading + + /// Loads everything, then keeps the screen in sync until the task is cancelled. + /// + /// Run it from `.task`: SwiftUI cancels it when the view goes away, which ends the + /// `for await` loops and drops the streams. + func run() async { + await load() + await observeChanges() + } + + func load() async { + do { + // `includingCache: false` asks StoreKit again instead of answering from the + // cache: win-back eligibility is decided when StoreKit returns the product. + let products = try await backend.requestProducts(includingCache: false) + plans = products.filter { $0.type.isAutoRenewableSubscription } + extras = products.filter { !$0.type.isAutoRenewableSubscription } + phase = products.isEmpty ? .empty : .loaded + } catch { + phase = .failed(error.localizedDescription) + } + await refreshStatus() + if selectedPlanID == nil { + // A returning customer sees the plan they had. A new one sees the plan with a + // free trial, usually the one worth recommending. + selectedPlanID = (statusPlan ?? plans.first { introOffer(for: $0) != nil } ?? plans.first)?.productID + } + } + + /// Re-reads entitlements and the group's status. Cheap enough to call after any event. + func refreshStatus() async { + entitledProductIDs = await backend.entitlementProductIDs() + guard let groupID else { + status = nil + return + } + // `subscriptionStatus(inGroup:)` picks the status that matters most when Family + // Sharing gives the customer more than one. + status = try? await backend.subscriptionStatus(inGroup: groupID) + isEligibleForIntroOffer = await backend.isEligibleForIntroOffer(inGroup: groupID) + } + + private func observeChanges() async { + // Both loops run until `run()` is cancelled. + async let statuses: Void = observeStatusUpdates() + async let purchases: Void = observePurchasedProducts() + _ = await (statuses, purchases) + } + + /// Renewals, billing failures, recoveries and refunds happen while the app is open, + /// often without any purchase. Each one arrives here. + private func observeStatusUpdates() async { + for await _ in backend.subscriptionStatusUpdates { + // The update carries one status; re-reading picks the best of the group's. + await refreshStatus() + } + } + + /// Purchases that complete outside a `purchase` call: Ask to Buy approvals, restores, + /// purchases made on another device. + private func observePurchasedProducts() async { + for await _ in backend.purchasedProducts { + notice = nil + await load() + } + } + + // MARK: Purchasing + + func purchase(_ product: StoreProduct, offer: PurchaseOptions? = nil) async { + guard purchasingProductID == nil else { return } + purchasingProductID = product.productID + defer { purchasingProductID = nil } + + var options = offer ?? .none + options.simulatesAskToBuyInSandbox = simulatesAskToBuy + do { + let (purchased, _) = try await backend.purchase(productID: product.productID, options: options) + notice = nil + if purchased.type == .consumable { + // A real app credits consumables on its server, keyed by the transaction. + coinBalance += Catalog.coinsPerPurchase + } + await load() + } catch PurchasesError.purchaseCancelled { + // The customer closed the payment sheet. That is a decision, not an error: + // no alert, no log noise, the paywall simply stays as it was. + } catch PurchasesError.purchasePending { + // Ask to Buy, or a payment that needs extra approval. The purchase is not lost: + // it arrives on `purchasedProducts` once approved, and `observeChanges` reloads. + notice = Notice( + symbol: "hourglass", + title: "Waiting for approval", + message: "\(product.displayName) will unlock as soon as the purchase is approved. You can keep using the app." + ) + } catch { + // `PurchasesError` is a `LocalizedError`, so its description is ready to show. + errorMessage = error.localizedDescription + } + } + + /// Buys a plan with a win-back offer. No signature needed: StoreKit decides eligibility. + func redeemWinBack(offerID: String) async { + guard let plan = statusPlan else { return } + await purchase(plan, offer: PurchaseOptions(winBackOfferID: offerID)) + } + + /// Buys a plan with a promotional offer. + /// + /// Promotional offers must be signed with your In-App Purchase key, which never ships in + /// the app. The app asks its server for a compact JWS and passes it on. + func redeemPromotionalOffer(offerID: String) async { + guard let plan = statusPlan else { return } + do { + let jws = try await PromotionalOfferSigner.signature( + offerID: offerID, + productID: plan.productID, + isDemo: backend is DemoPurchases + ) + let offer = PurchaseOptions.PromotionalOffer(offerID: offerID, compactJWS: jws) + await purchase(plan, offer: PurchaseOptions(promotionalOffer: offer)) + } catch { + errorMessage = error.localizedDescription + } + } + + func restore() async { + isRestoring = true + defer { isRestoring = false } + do { + // `AppStore.sync()` under the hood: it may ask the customer to sign in, so only + // call it from a button, never on launch. + try await backend.restore() + await load() + notice = hasPremium ? nil : Notice( + symbol: "magnifyingglass", + title: "Nothing to restore", + message: "No active purchases were found for this Apple Account." + ) + } catch PurchasesError.purchaseCancelled { + // The customer dismissed the sign-in prompt. + } catch { + errorMessage = error.localizedDescription + } + } + + /// Opens Apple's subscription settings, where a failing payment method is fixed. + func manageSubscription() async { + guard let scene = UIApplication.shared.connectedScenes + .compactMap({ $0 as? UIWindowScene }) + .first(where: { $0.activationState == .foregroundActive }) + else { return } + do { + try await backend.showManageSubscriptions(in: scene, groupID: groupID) + } catch { + errorMessage = error.localizedDescription + } + // The sheet is where problems get fixed; read the outcome when it closes. + await refreshStatus() + } +} + +/// Where a real app would call its server. +enum PromotionalOfferSigner { + struct NoServerError: LocalizedError { + var errorDescription: String? { + "Promotional offers need a signature from your server. Sign the offer there with your In-App Purchase key and pass the JWS in PurchaseOptions.promotionalOffer." + } + } + + static func signature(offerID: String, productID: String, isDemo: Bool) async throws -> String { + // Demo mode accepts any value; real StoreKit verifies it. + guard isDemo else { throw NoServerError() } + return "demo.signature" + } +} diff --git a/Examples/PaywallDemo/PaywallDemo/PaywallView.swift b/Examples/PaywallDemo/PaywallDemo/PaywallView.swift new file mode 100644 index 0000000..c5901fb --- /dev/null +++ b/Examples/PaywallDemo/PaywallDemo/PaywallView.swift @@ -0,0 +1,441 @@ +// +// PaywallView.swift +// PaywallDemo +// + +import RKPurchaseKit +import SwiftUI + +/// The paywall. Reads everything from `PaywallModel` and never touches StoreKit itself. +struct PaywallView: View { + @Bindable var model: PaywallModel + let scenario: DemoScenario + /// Lets the empty state offer a way into demo mode. + var switchScenario: (DemoScenario) -> Void = { _ in } + + @State private var bannerActionRunning = false + + var body: some View { + content + .navigationTitle("Premium") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .principal) { + ScenarioBadge(scenario: scenario) + } + } + .alert( + "Purchase Failed", + isPresented: Binding( + get: { model.errorMessage != nil }, + set: { if !$0 { model.errorMessage = nil } } + ) + ) { + Button("OK", role: .cancel) {} + } message: { + Text(model.errorMessage ?? "") + } + } + + @ViewBuilder + private var content: some View { + switch model.phase { + case .loading: + ProgressView("Loading products…") + .frame(maxWidth: .infinity, maxHeight: .infinity) + case .empty: + ContentUnavailableView { + Label("No Products", systemImage: "cart.badge.questionmark") + } description: { + Text("StoreKit returned no products. Run the PaywallDemo scheme from Xcode so it loads PaywallDemo.storekit, or try a simulated customer.") + } actions: { + Button("Show a Simulated Customer") { + switchScenario(.gracePeriod) + } + .buttonStyle(.borderedProminent) + } + case .failed(let message): + ContentUnavailableView { + Label("Couldn't Load Products", systemImage: "wifi.exclamationmark") + } description: { + Text(message) + } actions: { + Button("Try Again") { + Task { await model.load() } + } + .buttonStyle(.borderedProminent) + } + case .loaded: + paywall + } + } + + private var paywall: some View { + ScrollViewReader { proxy in + ScrollView { + VStack(spacing: 24) { + // The status comes first: it is the one thing on this screen that is + // about this customer rather than about the product. + if let banner = model.banner { + StatusBannerView( + banner: banner, + showsAction: !banner.isRecoverable, + isWorking: bannerActionRunning + ) { action in + perform(action, proxy: proxy) + } + .transition(.blurReplace) + } + + Header(hasPremium: model.hasPremium, isOnHold: model.status?.state == .inBillingRetryPeriod) + + if let notice = model.notice { + NoticeView(notice: notice) { model.notice = nil } + .transition(.move(edge: .top).combined(with: .opacity)) + } + + FeatureList() + + // A customer in grace period or billing retry still has a subscription. + // Offering them plans invites a second, duplicate purchase. + if model.banner?.isRecoverable != true { + plans + .id(Self.plansAnchor) + } + + extras + + Button { + Task { await model.restore() } + } label: { + if model.isRestoring { + ProgressView() + } else { + Text("Restore Purchases") + } + } + .buttonStyle(.borderless) + .font(.subheadline) + .disabled(model.isRestoring) + + Text("Subscriptions renew automatically until cancelled. Cancel anytime in Settings at least 24 hours before the end of the current period.") + .font(.footnote) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + } + .padding(.horizontal, 20) + .padding(.vertical, 16) + .frame(maxWidth: 560) + .frame(maxWidth: .infinity) + .animation(.smooth, value: model.banner) + .animation(.smooth, value: model.notice) + } + .safeAreaBar(edge: .bottom) { + if let banner = model.banner, banner.isRecoverable { + RecoveryAction(banner: banner, isWorking: bannerActionRunning) { + run { await model.manageSubscription() } + } + } else { + CallToAction(model: model) + } + } + .refreshable { await model.load() } + } + } + + private var plans: some View { + VStack(alignment: .leading, spacing: 12) { + SectionTitle("Choose a Plan") + ForEach(model.plans, id: \.productID) { product in + PlanCard( + product: product, + introOffer: model.introOffer(for: product), + isSelected: model.selectedPlanID == product.productID, + isCurrent: model.isCurrentPlan(product) + ) { + model.selectedPlanID = product.productID + } + } + } + } + + @ViewBuilder + private var extras: some View { + if !model.extras.isEmpty { + VStack(alignment: .leading, spacing: 8) { + HStack { + SectionTitle("One-Time Purchases") + Spacer() + if model.coinBalance > 0 { + Label("\(model.coinBalance)", systemImage: "circle.hexagongrid.fill") + .font(.subheadline.weight(.semibold)) + .foregroundStyle(.yellow) + .monospacedDigit() + .contentTransition(.numericText()) + } + } + VStack(spacing: 0) { + ForEach(model.extras, id: \.productID) { product in + ExtraRow( + product: product, + // Consumables are never "owned": they can be bought again. + isOwned: product.type != .consumable && model.entitledProductIDs.contains(product.productID), + isPurchasing: model.purchasingProductID == product.productID + ) { + Task { await model.purchase(product) } + } + if product.productID != model.extras.last?.productID { + Divider().padding(.leading, 50) + } + } + } + .padding(.horizontal, 16) + .padding(.vertical, 6) + .background(.background.secondary, in: .rect(cornerRadius: 20, style: .continuous)) + } + } + } + + private static let plansAnchor = "plans" + + private func perform(_ action: SubscriptionBanner.Action, proxy: ScrollViewProxy) { + switch action { + case .resubscribe: + withAnimation { proxy.scrollTo(Self.plansAnchor, anchor: .top) } + case .manageSubscription: + run { await model.manageSubscription() } + case .winBack(let offerID): + run { await model.redeemWinBack(offerID: offerID) } + case .promotionalOffer(let offerID): + run { await model.redeemPromotionalOffer(offerID: offerID) } + } + } + + private func run(_ work: @escaping @MainActor () async -> Void) { + bannerActionRunning = true + Task { + await work() + bannerActionRunning = false + } + } +} + +// MARK: - Pieces + +/// Replaces the purchase button while a subscription only needs its payment fixed. +private struct RecoveryAction: View { + let banner: SubscriptionBanner + let isWorking: Bool + let action: () -> Void + + var body: some View { + VStack(spacing: 8) { + Button(action: action) { + ZStack { + Label(banner.actionTitle ?? "Manage Subscription", systemImage: "creditcard") + .opacity(isWorking ? 0 : 1) + if isWorking { ProgressView() } + } + .font(.headline) + .frame(maxWidth: .infinity) + .padding(.vertical, 4) + } + .buttonStyle(.glassProminent) + .tint(banner.tone == .warning ? .orange : .red) + .controlSize(.large) + .disabled(isWorking) + + Text("Opens your App Store subscription settings. No new purchase needed.") + .font(.caption) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + } + .padding(.horizontal, 20) + .padding(.top, 8) + .padding(.bottom, 4) + .frame(maxWidth: 560) + } +} + +/// The purchase button for the selected plan, pinned above the home indicator. +private struct CallToAction: View { + let model: PaywallModel + + var body: some View { + if let plan { + VStack(spacing: 8) { + Button { + Task { await model.purchase(plan) } + } label: { + ZStack { + Text(title(for: plan)).opacity(isPurchasing ? 0 : 1) + if isPurchasing { ProgressView() } + } + .font(.headline) + .frame(maxWidth: .infinity) + .padding(.vertical, 4) + } + .buttonStyle(.glassProminent) + .controlSize(.large) + .disabled(isCurrent || isPurchasing) + + Text(finePrint(for: plan)) + .font(.caption) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + } + .padding(.horizontal, 20) + .padding(.top, 8) + .padding(.bottom, 4) + .frame(maxWidth: 560) + } + } + + private var plan: StoreProduct? { + model.plans.first { $0.productID == model.selectedPlanID } + } + + private var isPurchasing: Bool { model.purchasingProductID == plan?.productID } + private var isCurrent: Bool { plan.map(model.isCurrentPlan) ?? false } + + private func title(for plan: StoreProduct) -> String { + if isCurrent { return "Your Current Plan" } + if let offer = model.introOffer(for: plan), offer.paymentMode == .freeTrial { + return "Start Free Trial" + } + if model.status?.isActive == true { return "Switch to \(plan.displayName)" } + return "Subscribe for \(OfferText.price(of: plan))" + } + + private func finePrint(for plan: StoreProduct) -> String { + if let offer = model.introOffer(for: plan) { + return "\(OfferText.describe(offer, then: plan)). Cancel anytime." + } + return "\(OfferText.price(of: plan)). Cancel anytime." + } +} + +private struct Header: View { + let hasPremium: Bool + /// Billing retry: no access right now, but the customer is still a subscriber. A + /// "Unlock Premium" headline here would read like the pitch for someone who never paid. + let isOnHold: Bool + + var body: some View { + VStack(spacing: 12) { + Image(systemName: hasPremium ? "crown.fill" : isOnHold ? "pause.fill" : "sparkles") + .font(.system(size: 34, weight: .semibold)) + .foregroundStyle(.white) + .frame(width: 68, height: 68) + .background( + LinearGradient( + colors: [.accentColor, .purple, .pink], + startPoint: .topLeading, + endPoint: .bottomTrailing + ), + in: .rect(cornerRadius: 18, style: .continuous) + ) + .shadow(color: .purple.opacity(0.3), radius: 12, y: 6) + .contentTransition(.symbolEffect(.replace)) + .accessibilityHidden(true) + + Text(hasPremium ? "You're Premium" : isOnHold ? "Premium Is on Hold" : "Unlock Premium") + .font(.largeTitle.bold()) + .multilineTextAlignment(.center) + + Text(hasPremium + ? "Thanks for your support. Every feature is unlocked." + : isOnHold + ? "Your features come back as soon as the payment goes through." + : "Every feature, on every device, with new ones every month.") + .font(.body) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + } + } +} + +private struct FeatureList: View { + private let features: [(symbol: String, title: String, detail: String)] = [ + ("wand.and.stars", "Smart Tools", "Pro editing tools that do the busy work."), + ("icloud.fill", "Sync Everywhere", "Your library on iPhone, iPad and Mac."), + ("lock.shield.fill", "Private by Design", "No ads, no tracking, ever."), + ] + + var body: some View { + VStack(alignment: .leading, spacing: 16) { + ForEach(features, id: \.title) { feature in + HStack(alignment: .top, spacing: 14) { + Image(systemName: feature.symbol) + .font(.title3) + .foregroundStyle(.tint) + .frame(width: 28) + .accessibilityHidden(true) + VStack(alignment: .leading, spacing: 2) { + Text(feature.title).font(.headline) + Text(feature.detail) + .font(.subheadline) + .foregroundStyle(.secondary) + } + } + } + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, 4) + } +} + +private struct SectionTitle: View { + let text: String + init(_ text: String) { self.text = text } + + var body: some View { + Text(text) + .font(.title3.bold()) + .accessibilityAddTraits(.isHeader) + } +} + +/// Makes it obvious in screenshots and recordings which store is answering. +private struct ScenarioBadge: View { + let scenario: DemoScenario + + var body: some View { + Label(scenario == .live ? "Live StoreKit" : "Demo · \(scenario.title)", systemImage: scenario.symbol) + .font(.footnote.weight(.semibold)) + .labelStyle(.titleAndIcon) + .foregroundStyle(scenario == .live ? AnyShapeStyle(.secondary) : AnyShapeStyle(.orange)) + .lineLimit(1) + .minimumScaleFactor(0.8) + } +} + +// MARK: - Previews + +// Each preview runs the real screen against `DemoPurchases`. No StoreKit configuration, +// no simulator account, no network. + +#Preview("New customer") { PaywallPreview(.newCustomer) } +#Preview("Subscribed") { PaywallPreview(.subscribed) } +#Preview("Grace period") { PaywallPreview(.gracePeriod) } +#Preview("Billing retry") { PaywallPreview(.billingRetry) } +#Preview("Expired: cancelled") { PaywallPreview(.expiredCancelled) } +#Preview("Expired: billing error") { PaywallPreview(.expiredBillingError) } +#Preview("Revoked") { PaywallPreview(.revoked) } + +private struct PaywallPreview: View { + let scenario: DemoScenario + @State private var model: PaywallModel + + init(_ scenario: DemoScenario) { + self.scenario = scenario + _model = State(initialValue: PaywallModel(backend: DemoPurchases(scenario: scenario))) + } + + var body: some View { + NavigationStack { + PaywallView(model: model, scenario: scenario) + } + .task { await model.run() } + } +} diff --git a/Examples/PaywallDemo/PaywallDemo/ProductViews.swift b/Examples/PaywallDemo/PaywallDemo/ProductViews.swift new file mode 100644 index 0000000..94bf386 --- /dev/null +++ b/Examples/PaywallDemo/PaywallDemo/ProductViews.swift @@ -0,0 +1,145 @@ +// +// ProductViews.swift +// PaywallDemo +// + +import RKPurchaseKit +import SwiftUI + +/// One subscription plan, selectable like a radio button. +struct PlanCard: View { + let product: StoreProduct + /// Only passed when this customer is eligible for it. + let introOffer: SubscriptionOffer? + let isSelected: Bool + let isCurrent: Bool + let select: () -> Void + + var body: some View { + Button(action: select) { + HStack(alignment: .center, spacing: 14) { + Image(systemName: isSelected ? "checkmark.circle.fill" : "circle") + .font(.title2) + .foregroundStyle(isSelected ? AnyShapeStyle(.tint) : AnyShapeStyle(.tertiary)) + .contentTransition(.symbolEffect(.replace)) + + VStack(alignment: .leading, spacing: 4) { + if isCurrent { + Tag(text: "Current Plan", tint: .green) + } else if let introOffer { + Tag(text: OfferText.summary(of: introOffer).uppercased(), tint: .accentColor) + } + Text(product.displayName) + .font(.headline) + Text(caption) + .font(.subheadline) + .foregroundStyle(.secondary) + } + + Spacer(minLength: 8) + + VStack(alignment: .trailing, spacing: 2) { + Text(product.displayPrice) + .font(.headline) + .monospacedDigit() + if let period = product.subscriptionPeriod { + Text("per \(OfferText.period(period))") + .font(.caption) + .foregroundStyle(.secondary) + } + } + } + .padding(16) + .contentShape(.rect) + } + .buttonStyle(.plain) + .background(.background.secondary, in: .rect(cornerRadius: 20, style: .continuous)) + .overlay { + RoundedRectangle(cornerRadius: 20, style: .continuous) + .strokeBorder(isSelected ? AnyShapeStyle(.tint) : AnyShapeStyle(.separator), lineWidth: isSelected ? 2 : 1) + } + .accessibilityAddTraits(isSelected ? .isSelected : []) + .animation(.snappy, value: isSelected) + } + + private var caption: String { + if let introOffer { + return OfferText.describe(introOffer, then: product) + } + // Show the monthly equivalent of a yearly plan, the comparison customers make anyway. + if product.subscriptionPeriod == StoreSubscriptionPeriod(value: 1, unit: .year) { + // Format with the storefront's style, which StoreKit provides on the live + // product, so it matches `displayPrice`. The demo catalog is in US dollars. + let style = product.product?.priceFormatStyle + ?? Decimal.FormatStyle.Currency(code: product.currencyCode, locale: Locale(identifier: "en_US")) + let monthly = (product.price / 12).formatted(style.precision(.fractionLength(2))) + return "Just \(monthly)/month, billed yearly" + } + return product.description + } +} + +/// A one-time purchase: a consumable or a non-consumable. +struct ExtraRow: View { + let product: StoreProduct + let isOwned: Bool + let isPurchasing: Bool + let buy: () -> Void + + var body: some View { + HStack(spacing: 14) { + Image(systemName: product.type == .consumable ? "circle.hexagongrid.fill" : "infinity.circle.fill") + .font(.title) + .foregroundStyle(product.type == .consumable ? Color.yellow : Color.accentColor) + .symbolRenderingMode(.hierarchical) + .frame(width: 36) + + VStack(alignment: .leading, spacing: 2) { + Text(product.displayName) + .font(.headline) + Text(product.description) + .font(.subheadline) + .foregroundStyle(.secondary) + } + + Spacer(minLength: 8) + + if isOwned { + Label("Owned", systemImage: "checkmark") + .font(.subheadline.weight(.semibold)) + .foregroundStyle(.green) + } else { + Button(action: buy) { + ZStack { + // Keeps the button the same width while the spinner shows. + Text(product.displayPrice).opacity(isPurchasing ? 0 : 1) + if isPurchasing { ProgressView() } + } + .monospacedDigit() + .fontWeight(.semibold) + } + .buttonStyle(.bordered) + .buttonBorderShape(.capsule) + .disabled(isPurchasing) + .accessibilityLabel("Buy \(product.displayName), \(product.displayPrice)") + } + } + .padding(.vertical, 6) + } +} + +/// A small capsule label. +struct Tag: View { + let text: String + let tint: Color + + var body: some View { + Text(text) + .font(.caption.weight(.semibold)) + .padding(.horizontal, 8) + .padding(.vertical, 3) + .foregroundStyle(tint) + .background(tint.opacity(0.15), in: .capsule) + .lineLimit(1) + } +} diff --git a/Examples/PaywallDemo/PaywallDemo/PurchasesBackend.swift b/Examples/PaywallDemo/PaywallDemo/PurchasesBackend.swift new file mode 100644 index 0000000..edc9f6f --- /dev/null +++ b/Examples/PaywallDemo/PaywallDemo/PurchasesBackend.swift @@ -0,0 +1,35 @@ +// +// PurchasesBackend.swift +// PaywallDemo +// + +import RKPurchaseKit +import UIKit + +/// Everything the paywall needs from the store, as a protocol the screen can be handed. +/// +/// `PurchasesProtocol` already covers products, purchases, entitlements and subscription +/// status. The paywall also wants to *react* when those change while it is on screen, and to +/// send the customer to Apple's subscription settings. Those live on `PurchasesManager` itself +/// rather than on the kit's protocol, so this app adds them in a small refinement of its own. +/// +/// Depending on a protocol instead of on `PurchasesManager.shared` is what lets the same +/// screen run against real StoreKit, against `DemoPurchases` in demo mode, and inside +/// SwiftUI previews. +protocol PurchasesBackend: PurchasesProtocol { + /// A fresh stream of products that became entitled (purchases, restores, Ask to Buy + /// approvals, purchases made on another device). + var purchasedProducts: AsyncStream { get } + + /// A fresh stream of renewal statuses: renewals, expirations, billing failures, refunds. + var subscriptionStatusUpdates: AsyncStream { get } + + /// Shows the system's manage-subscriptions sheet, where the customer can fix a payment + /// method, change plan or cancel. + @MainActor + func showManageSubscriptions(in scene: UIWindowScene, groupID: String?) async throws +} + +/// The real thing. `PurchasesManager` already has every member above with these exact +/// signatures, so the conformance is empty. +extension PurchasesManager: PurchasesBackend {} diff --git a/Examples/PaywallDemo/PaywallDemo/RootView.swift b/Examples/PaywallDemo/PaywallDemo/RootView.swift new file mode 100644 index 0000000..75821ae --- /dev/null +++ b/Examples/PaywallDemo/PaywallDemo/RootView.swift @@ -0,0 +1,83 @@ +// +// RootView.swift +// PaywallDemo +// + +import SwiftUI + +/// Hosts the paywall and the demo-mode switch. +/// +/// Switching scenario swaps the whole backend: a new `PaywallModel` over either +/// `PurchasesManager.shared` or a `DemoPurchases`. The paywall itself has no idea which one +/// it got, which is the point. +struct RootView: View { + @State private var scenario: DemoScenario + @State private var model: PaywallModel + + init(scenario: DemoScenario = RootView.launchScenario) { + _scenario = State(initialValue: scenario) + _model = State(initialValue: PaywallModel(backend: scenario.makeBackend())) + } + + var body: some View { + NavigationStack { + PaywallView(model: model, scenario: scenario) { + scenario = $0 + } + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + DemoMenu(scenario: $scenario, simulatesAskToBuy: $model.simulatesAskToBuy) + } + } + } + .onChange(of: scenario) { _, newValue in + let askToBuy = model.simulatesAskToBuy + model = PaywallModel(backend: newValue.makeBackend()) + model.simulatesAskToBuy = askToBuy + } + // Keyed by the model, so switching scenario cancels the old model's observation + // loops and starts the new one's. + .task(id: ObjectIdentifier(model)) { + await model.run() + } + } + + /// Lets a launch argument pick the starting scenario, for screenshots and UI tests: + /// `-DemoScenario gracePeriod`. + static var launchScenario: DemoScenario { + UserDefaults.standard.string(forKey: "DemoScenario").flatMap(DemoScenario.init(rawValue:)) ?? .live + } +} + +/// The toolbar menu that picks what the paywall talks to. +private struct DemoMenu: View { + @Binding var scenario: DemoScenario + @Binding var simulatesAskToBuy: Bool + + var body: some View { + Menu { + // One picker, so the checkmark always sits next to the current choice. + Picker("Store", selection: $scenario) { + Label(DemoScenario.live.title, systemImage: DemoScenario.live.symbol) + .tag(DemoScenario.live) + Section("Simulated Customer") { + ForEach(DemoScenario.allCases.filter { $0 != .live }) { scenario in + Label(scenario.title, systemImage: scenario.symbol) + .tag(scenario) + } + } + } + .pickerStyle(.inline) + Section { + Toggle(isOn: $simulatesAskToBuy) { + Label("Simulate Ask to Buy", systemImage: "person.2.badge.gearshape") + } + } footer: { + Text("Sets PurchaseOptions.simulatesAskToBuyInSandbox on every purchase.") + } + } label: { + Label("Demo Mode", systemImage: "theatermask.and.paintbrush") + } + .accessibilityHint("Choose live StoreKit or a simulated subscription state") + } +} diff --git a/Examples/PaywallDemo/PaywallDemo/StatusBannerView.swift b/Examples/PaywallDemo/PaywallDemo/StatusBannerView.swift new file mode 100644 index 0000000..0690593 --- /dev/null +++ b/Examples/PaywallDemo/PaywallDemo/StatusBannerView.swift @@ -0,0 +1,99 @@ +// +// StatusBannerView.swift +// PaywallDemo +// + +import SwiftUI + +/// Renders a `SubscriptionBanner`. All decisions were made by the banner; this only draws. +struct StatusBannerView: View { + let banner: SubscriptionBanner + /// Off when the paywall shows the same action in its bottom bar. + var showsAction = true + var isWorking = false + let perform: (SubscriptionBanner.Action) -> Void + + var body: some View { + VStack(alignment: .leading, spacing: 12) { + Label { + Text(banner.title) + .font(.headline) + } icon: { + Image(systemName: banner.symbol) + .foregroundStyle(tint) + .symbolRenderingMode(.hierarchical) + .imageScale(.large) + } + + Text(banner.message) + .font(.subheadline) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + + if showsAction, let action = banner.action, let title = banner.actionTitle { + Button { + perform(action) + } label: { + HStack(spacing: 8) { + if isWorking { + ProgressView() + } + Text(title) + } + .frame(maxWidth: .infinity) + } + .buttonStyle(.borderedProminent) + .tint(tint) + .controlSize(.regular) + .disabled(isWorking) + } + } + .padding(16) + .frame(maxWidth: .infinity, alignment: .leading) + .background(tint.opacity(0.12), in: .rect(cornerRadius: 22, style: .continuous)) + .overlay { + RoundedRectangle(cornerRadius: 22, style: .continuous) + .strokeBorder(tint.opacity(0.25), lineWidth: 1) + } + .accessibilityElement(children: .contain) + } + + private var tint: Color { + switch banner.tone { + case .positive: .green + case .warning: .orange + case .critical: .red + case .invitation: .accentColor + } + } +} + +/// A persistent, neutral message such as "Waiting for approval". +struct NoticeView: View { + let notice: PaywallModel.Notice + let dismiss: () -> Void + + var body: some View { + HStack(alignment: .top, spacing: 12) { + Image(systemName: notice.symbol) + .font(.title3) + .foregroundStyle(.tint) + .symbolEffect(.pulse, options: .repeating) + VStack(alignment: .leading, spacing: 4) { + Text(notice.title) + .font(.headline) + Text(notice.message) + .font(.subheadline) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + Spacer(minLength: 0) + Button("Dismiss", systemImage: "xmark", action: dismiss) + .labelStyle(.iconOnly) + .buttonStyle(.borderless) + .foregroundStyle(.secondary) + } + .padding(16) + .background(.regularMaterial, in: .rect(cornerRadius: 22, style: .continuous)) + } +} diff --git a/Examples/PaywallDemo/PaywallDemo/SubscriptionBanner.swift b/Examples/PaywallDemo/PaywallDemo/SubscriptionBanner.swift new file mode 100644 index 0000000..b871b50 --- /dev/null +++ b/Examples/PaywallDemo/PaywallDemo/SubscriptionBanner.swift @@ -0,0 +1,234 @@ +// +// SubscriptionBanner.swift +// PaywallDemo +// + +import Foundation +import RKPurchaseKit + +/// What the paywall tells the customer about their subscription, decided from one +/// `StoreSubscriptionStatus`. +/// +/// This is the heart of the demo. A paywall that only asks "is the customer entitled?" sees +/// two worlds: paying and not paying. The App Store reports five, and each one deserves a +/// different screen: +/// +/// | State | Access | What to say | +/// |------------------------|--------|--------------------------------------------------------| +/// | subscribed | yes | Nothing to sell. Show when it renews. | +/// | in grace period | yes | Payment failed. Fix it by a date, access is kept. | +/// | in billing retry | no | Payment failed. Access is paused, the plan can recover.| +/// | expired | no | Depends on *why*: cancelled, card declined, price rise.| +/// | revoked | no | Refunded or removed from Family Sharing. | +/// +/// Keeping the decision in a plain value type, away from the view, makes it easy to read, +/// to preview and to unit test. +struct SubscriptionBanner: Equatable { + enum Tone: Equatable { + /// Everything is fine. + case positive + /// Access is kept, but the customer needs to act. + case warning + /// Access is gone. + case critical + /// Access is gone and the customer is someone to win back. + case invitation + } + + enum Action: Equatable { + /// Open Apple's subscription settings, where a payment method can be fixed. + case manageSubscription + /// Sell the plan again with the given win-back offer. + case winBack(offerID: String) + /// Sell the plan again with a promotional offer your server signs. + case promotionalOffer(offerID: String) + /// Plain resubscribe: scroll to the plans. + case resubscribe + } + + var symbol: String + var title: String + var message: String + var tone: Tone + var action: Action? + var actionTitle: String? + + /// The subscription still exists and only needs a working payment method: grace period + /// and billing retry. Selling a new plan to this customer is the classic mistake, so the + /// paywall swaps its purchase button for the banner's action. + var isRecoverable = false +} + +extension SubscriptionBanner { + /// The banner for a status, or `nil` for a customer who has never subscribed. + /// + /// - Parameters: + /// - status: The group's most relevant status, from `subscriptionStatus(inGroup:)` or + /// `subscriptionStatusUpdates`. + /// - plan: The product the status belongs to, used for its win-back and promotional + /// offers. StoreKit lists a win-back offer only while this customer may redeem it. + init?(status: StoreSubscriptionStatus?, plan: StoreProduct?) { + guard let status else { return nil } + let planName = plan?.displayName ?? "Premium" + + switch status.state { + case .subscribed: + let date = status.renewalDate ?? status.transaction.expirationDate + if status.willAutoRenew { + self.init( + symbol: "checkmark.seal.fill", + title: "\(planName) is active", + message: date.map { "Renews on \($0.formatted(date: .long, time: .omitted))." } + ?? "Thanks for subscribing.", + tone: .positive + ) + } else { + // Still paid up, but the customer turned off renewal. A gentle nudge now is + // cheaper than a win-back campaign later. + self.init( + symbol: "calendar.badge.exclamationmark", + title: "\(planName) ends soon", + message: date.map { "Auto-renew is off. Access ends on \($0.formatted(date: .long, time: .omitted))." } + ?? "Auto-renew is off.", + tone: .positive, + action: .manageSubscription, + actionTitle: "Keep Premium" + ) + } + + case .inGracePeriod: + // The renewal charge failed, but you enabled Billing Grace Period, so the App Store + // keeps the customer subscribed while it retries. Do not lock anything. Do ask them + // to fix the card, and say by when. + let deadline = status.gracePeriodExpirationDate + .map { " by \($0.formatted(date: .long, time: .omitted))" } ?? "" + self.init( + symbol: "creditcard.trianglebadge.exclamationmark", + title: "Payment issue", + message: "We couldn't renew your subscription. Update your payment method\(deadline) to keep Premium. Your access is unchanged until then.", + tone: .warning, + action: .manageSubscription, + actionTitle: "Update Payment Method", + isRecoverable: true + ) + + case .inBillingRetryPeriod: + // The grace period is over (or was never enabled). Access is paused, but the + // subscription is not gone: the App Store keeps retrying for up to 60 days, and + // a fixed payment method resumes it without a new purchase. Selling a new plan + // here would be wrong; sending the customer to their payment settings is right. + self.init( + symbol: "pause.circle.fill", + title: "Premium is paused", + message: "Your last payment didn't go through, so Premium is paused. Update your payment method and it resumes right away. No need to subscribe again.", + tone: .critical, + action: .manageSubscription, + actionTitle: "Update Payment Method", + isRecoverable: true + ) + + case .expired: + self.init(expiredBecause: status.expirationReason, plan: plan, planName: planName) + + case .revoked: + // The App Store refunded the purchase, or a family organizer stopped sharing it. + // Access must go, and there is nothing to fix: just make resubscribing easy. + let date = status.transaction.revocationDate + .map { " on \($0.formatted(date: .long, time: .omitted))" } ?? "" + self.init( + symbol: "xmark.seal.fill", + title: "Access removed", + message: "Your \(planName) purchase was refunded\(date), so its features are no longer available.", + tone: .critical, + action: .resubscribe, + actionTitle: "See Plans" + ) + + default: + // `State` is an extensible struct: a future StoreKit release can add a state this + // app predates. Fall back to the rule the App Store gives us. + self.init( + symbol: status.isActive ? "checkmark.seal.fill" : "questionmark.circle", + title: status.isActive ? "\(planName) is active" : "Subscription inactive", + message: "Status code \(status.state.rawValue).", + tone: status.isActive ? .positive : .critical + ) + } + } + + /// An expired subscription is several different customers. The reason decides the pitch. + private init( + expiredBecause reason: StoreSubscriptionStatus.ExpirationReason?, + plan: StoreProduct?, + planName: String + ) { + switch reason { + case .autoRenewDisabled?: + // They chose to leave. This is the audience win-back offers were made for, and + // StoreKit only lists a win-back offer while this customer is eligible for it. + if let offer = plan?.winBackOffers.first, let offerID = offer.id, let plan { + self.init( + symbol: "gift.fill", + title: "We'd love to have you back", + message: "Come back to \(planName): \(OfferText.describe(offer, then: plan)).", + tone: .invitation, + action: .winBack(offerID: offerID), + actionTitle: "Redeem Offer" + ) + } else { + self.init( + symbol: "hand.wave.fill", + title: "We'd love to have you back", + message: "Your \(planName) subscription has ended. Pick a plan below to pick up where you left off.", + tone: .invitation, + action: .resubscribe, + actionTitle: "See Plans" + ) + } + + case .billingError?: + // They never meant to leave: the App Store gave up on a failing card. Do not + // pitch them like a churned customer. Say what happened, and make the way back + // cheap, here with a promotional offer your server signs. + if let offer = plan?.promotionalOffers.first, let offerID = offer.id, let plan { + self.init( + symbol: "creditcard.fill", + title: "Your payment didn't go through", + message: "Premium ended because the App Store couldn't charge your payment method. Resubscribe and get \(OfferText.describe(offer, then: plan)).", + tone: .critical, + action: .promotionalOffer(offerID: offerID), + actionTitle: "Resubscribe" + ) + } else { + self.init( + symbol: "creditcard.fill", + title: "Your payment didn't go through", + message: "Premium ended because the App Store couldn't charge your payment method. Update it and resubscribe to continue.", + tone: .critical, + action: .resubscribe, + actionTitle: "See Plans" + ) + } + + case .didNotConsentToPriceIncrease?: + self.init( + symbol: "tag.fill", + title: "Premium has ended", + message: "Your subscription ended because the new price wasn't accepted. You can resubscribe at the current price at any time.", + tone: .invitation, + action: .resubscribe, + actionTitle: "See Plans" + ) + + default: + self.init( + symbol: "clock.badge.xmark", + title: "Premium has ended", + message: "Your \(planName) subscription has expired.", + tone: .invitation, + action: .resubscribe, + actionTitle: "See Plans" + ) + } + } +} diff --git a/Examples/PaywallDemo/README.md b/Examples/PaywallDemo/README.md new file mode 100644 index 0000000..e73edfe --- /dev/null +++ b/Examples/PaywallDemo/README.md @@ -0,0 +1,106 @@ +# PaywallDemo + +A small SwiftUI paywall built on [RKPurchaseKit](../../README.md) 4.0. It shows that a paywall +has more to say than "subscribed or not": the App Store reports five subscription states, and +each one deserves its own screen. + +| State | Access | What the paywall does | +|---|---|---| +| Subscribed | yes | Shows the renewal date and marks the current plan. | +| Grace period | yes | "Payment issue, update by ". Access is kept; the purchase button becomes *Update Payment Method*. | +| Billing retry | no | "Premium is paused". No plans are offered (that would sell a duplicate subscription); one button opens Apple's subscription settings. | +| Expired | no | Depends on the reason: cancelled by the customer → win-back offer; billing error → "your payment didn't go through" plus a promotional offer; declined price increase → its own message. | +| Revoked | no | "Access removed": the purchase was refunded. | + +| New customer | Grace period | Billing retry | Expired, cancelled | +|---|---|---|---| +| ![New customer with a free trial](Screenshots/1-new-customer.png) | ![Grace period](Screenshots/2-grace-period.png) | ![Billing retry](Screenshots/3-billing-retry.png) | ![Expired with a win-back offer](Screenshots/4-expired-win-back.png) | + +The screenshots are taken in demo mode (see below). + +## Run it + +1. Open `PaywallDemo.xcodeproj` in Xcode 26 or later. +2. Pick an iPhone simulator and press **Run**. + +The shared `PaywallDemo` scheme loads `PaywallDemo.storekit` in its Run action, so StoreKit +serves the products locally. There is no App Store Connect setup, no sandbox account and no +signing team to choose. The file defines: + +- the **Premium** subscription group: *Premium Monthly* ($4.99/month, with a promotional offer + and a win-back offer) and *Premium Yearly* ($39.99/year, with a 3-day free trial), +- **100 Coins**, a consumable, +- **Lifetime Unlock**, a non-consumable, +- Billing Grace Period turned on. + +The StoreKit configuration is applied by Xcode's Run action only. An app installed and +launched any other way (`simctl`, TestFlight) talks to the real App Store, finds none of these +products, and shows the "No Products" screen, which offers a way into demo mode. + +### Demo mode + +The theater-mask menu in the toolbar switches the paywall between **Live StoreKit** and a +simulated customer in each state: new customer, subscribed, grace period, billing retry, +expired (cancelled), expired (billing error) and revoked. Demo mode swaps +`PurchasesManager.shared` for `DemoPurchases`, a mock that conforms to `PurchasesProtocol` +and is built entirely from the kit's public model initializers. The screen code is identical +in both modes. + +The mock behaves, it does not just pose: buying a plan subscribes, and *Update Payment +Method* pretends the customer fixed their card, so a grace-period or billing-retry banner +turns into "Premium is active" through `subscriptionStatusUpdates`, the same way a real +recovery arrives. + +The menu also has **Simulate Ask to Buy**, which sets +`PurchaseOptions.simulatesAskToBuyInSandbox` on every purchase to show the pending message. + +To start in a given state, pass a launch argument, for example in the scheme's Run options or +with `simctl launch`: + +```text +-DemoScenario gracePeriod +``` + +Values: `live`, `newCustomer`, `subscribed`, `gracePeriod`, `billingRetry`, +`expiredCancelled`, `expiredBillingError`, `revoked`. + +The same mock drives the SwiftUI previews in `PaywallView.swift`, one per state. + +### Reproduce the states with real StoreKit + +With the app running from Xcode in **Live StoreKit** mode: + +1. Buy a plan in the app. +2. Open `PaywallDemo.storekit` and turn on **Editor → Enable Billing Retry on Renewal**. With + **Editor → Subscription Renewal Rate** set to a fast rate, the next renewal fails within + seconds. Billing Grace Period is already on in this file, so the subscription first enters + the **grace period**, then **billing retry** once the grace period ends. +3. Open **Debug → StoreKit → Manage Transactions** to drive the rest from the transaction + list: resolve the billing issue to recover the subscription, cancel it to let it **expire** + with `autoRenewDisabled`, or refund it to see it **revoked**. + +The paywall updates on its own each time, because it listens to `subscriptionStatusUpdates` +and `purchasedProducts`. + +## Where each API is used + +| API | Where | +|---|---| +| `PurchasesManager.configure(identifiers:)` | `PaywallDemoApp.swift`, once at launch | +| `PurchasesProtocol` | `PurchasesBackend.swift` refines it with the two streams and `showManageSubscriptions(in:groupID:)`; `DemoPurchases.swift` conforms | +| `requestProducts(includingCache: false)` | `PaywallModel.load()` | +| `StoreProduct.displayPrice`, `subscriptionPeriod`, `introductoryOffer` | `ProductViews.swift`, phrased by `OfferText.swift` ("3 days free, then $39.99/year") | +| `isEligibleForIntroOffer(inGroup:)` | `PaywallModel.refreshStatus()`; the trial is advertised only to eligible customers | +| `subscriptionStatus(inGroup:)`, `StoreSubscriptionStatus` | `PaywallModel.refreshStatus()`, decided in `SubscriptionBanner.swift` | +| `subscriptionStatusUpdates`, `purchasedProducts` | `PaywallModel.observeChanges()` | +| `purchase(productID:options:)`, `PurchaseOptions` | `PaywallModel.purchase(_:offer:)`: win-back (`winBackOfferID`), promotional (`promotionalOffer`), Ask to Buy (`simulatesAskToBuyInSandbox`) | +| `catch PurchasesError.purchaseCancelled` / `.purchasePending` | `PaywallModel.purchase(_:offer:)`: cancel is silent, pending shows a message, anything else shows `localizedDescription` | +| `restore()` | `PaywallModel.restore()` | +| `showManageSubscriptions(in:groupID:)` | `PaywallModel.manageSubscription()`, from the grace-period and billing-retry banners | +| `entitlementProductIDs()` | `PaywallModel.refreshStatus()`, for the lifetime unlock | + +Promotional offers need a signature from your server, made with your In-App Purchase key, +which must never ship in an app. `PromotionalOfferSigner` in `PaywallModel.swift` marks that +spot: in demo mode it returns a placeholder, in live mode it explains what is missing. See +[Subscriptions](../../Sources/PurchaseKit/RKPurchaseKit.docc/Subscriptions.md) for the full +flow. diff --git a/Examples/PaywallDemo/Screenshots/1-new-customer.png b/Examples/PaywallDemo/Screenshots/1-new-customer.png new file mode 100644 index 0000000..1cf76f3 Binary files /dev/null and b/Examples/PaywallDemo/Screenshots/1-new-customer.png differ diff --git a/Examples/PaywallDemo/Screenshots/2-grace-period.png b/Examples/PaywallDemo/Screenshots/2-grace-period.png new file mode 100644 index 0000000..e0278a8 Binary files /dev/null and b/Examples/PaywallDemo/Screenshots/2-grace-period.png differ diff --git a/Examples/PaywallDemo/Screenshots/3-billing-retry.png b/Examples/PaywallDemo/Screenshots/3-billing-retry.png new file mode 100644 index 0000000..58fc07b Binary files /dev/null and b/Examples/PaywallDemo/Screenshots/3-billing-retry.png differ diff --git a/Examples/PaywallDemo/Screenshots/4-expired-win-back.png b/Examples/PaywallDemo/Screenshots/4-expired-win-back.png new file mode 100644 index 0000000..af1c643 Binary files /dev/null and b/Examples/PaywallDemo/Screenshots/4-expired-win-back.png differ diff --git a/README.md b/README.md index 7c2bdd1..cc9609d 100644 --- a/README.md +++ b/README.md @@ -117,6 +117,10 @@ The full guide with before/after code: [Migrating to PurchaseKit 4](https://rami - **`requestProducts()` with no arguments** is now a distinct overload on `PurchasesProtocol`. Calls are unchanged; only a conformer that relied on the default implementation is affected, and that case no longer compiles rather than recursing forever at runtime. - `PurchasesError` now conforms to `Equatable` and `LocalizedError`, and `PurchasesManager.resolved()` returns the singleton by throwing `notConfigured` instead of trapping like `shared`. +### 📱 Example app + +[`Examples/PaywallDemo`](Examples/PaywallDemo) is a SwiftUI paywall that reacts to every subscription state the App Store reports: subscribed, grace period, billing retry, expired (with the reason) and revoked. Open `PaywallDemo.xcodeproj` and press Run; the scheme loads a local StoreKit configuration, so no App Store Connect setup is needed. A demo-mode menu switches the same screen to a `PurchasesProtocol` mock for each state. + ### 📚 Documentation - 🧭 [Online documentation →](https://ramiz69.github.io/PurchaseKit)