Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Paywalls with custom purchase and restore logic handlers (#3973)
## tl;dr PaywallView has a new constructor that takes `performPurchase` and `performRestore` blocks, which are called to preform purchasing/restore directly by the customer's app when the purchase/restore buttons are tapped on the paywall. This makes it possible to use RC Paywalls when `Purchases` has been configured `.with(purchasesAreCompletedBy: .myApp)`. Example usage: ```swift PaywallView(performPurchase: { var userCancelled = false var error: Error? // use StoreKit to perform purchase return (userCancelled: userCancelled, error: error) }, performRestore: { var success = false var error: Error? // use StoreKit to perform restore return (success: success, error: error) }) ``` ## Description When a `PaywallView` is constructed, a new `PurchaseHandler` is created. The `PurchaseHandler` (an internal RevenueCatUI class) is owned by the `PaywallView`, and it is responsible for executing new purchases and restores. When a `PaywallView` is constructed **without** `performPurchase` and `performRestore` blocks, the `PaywallView` creates a `PurchaseHandler` capable of preforming purchases using RevenueCat. When a `PaywallView` is constructed with `performPurchase` and `performRestore` blocks, it can also make purchases using the customer-supplied closures. The `PurchaseHandler` is invoked when the user taps the `PurchaseButton`, calling `purchaseHandler.purchase(package: self.selectedPackage.content)`, which branches to either the internal or external purchase code, as defined by `purchasesAreCompletedBy`: ```swift @mainactor func purchase(package: Package) async throws { switch self.purchases.purchasesAreCompletedBy { case .revenueCat: try await performPurchase(package: package) case .myApp: try await performExternalPurchaseLogic(package: package) } } ``` Purchase and Restore blocks can also be assigned for paywall footers: ```swift MyAppDefinedPaywall() .paywallFooter(myAppPurchaseLogic: MyAppPurchaseLogic(performPurchase: { packageToPurchase in var userCancelled = false var error: Error? // use StoreKit to perform purchase return (userCancelled: userCancelled, error: error) }, performRestore: { var success = false var error: Error? // use StoreKit to perform restore return (success: success, error: error) })) ``` and via `.presentPaywallIfNeeded`: ```swift MyAppDefinedPaywal() .presentPaywallIfNeeded(requiredEntitlementIdentifier: "test", myAppPurchaseLogic: MyAppPurchaseLogic(performPurchase: { packageToPurchase in return (userCancelled: false, error: nil) }, performRestore: { return (success: true, error: nil) })) ``` ## Notes ### Testing A lot needs to be mocked, the purchase/restore blocks that are passed in need to be assigned directly to the `PurchaseHandler`, which is then passed in as part of a `PaywallConfiguration`, both of which are normally constructed internally in the PaywallView constructor. ```swift func testHandleExternalRestoreWithPurchaaseHandlers() throws { var completed = false var customRestoreCodeExecuted = false let purchasHandler = Self.externalPurchaseHandler { _ in return (userCancelled: true, error: nil) } performRestore: { customRestoreCodeExecuted = true return (success: true, error: nil) } let config = PaywallViewConfiguration(purchaseHandler: purchasHandler) try PaywallView(configuration: config).addToHierarchy() Task { _ = try await purchasHandler.restorePurchases() completed = true } expect(completed).toEventually(beTrue()) expect(customRestoreCodeExecuted) == true } ``` ### Error Handling For the external code blocks to be called, `purchasesAreCompletedBy` needs to be set to `.myApp` AND the blocks need to be defined. So we need to handle cases when these are not consistent: 1. If someone configures purchasesAreCompletedBy to `.myApp`, and then displays a PaywallView **without** purchase/restore handlers, the SDK will: * Log an error when the PaywallView is constructed * When the PaywallView is displayed, replace it with a big red banner when in debug mode, fatalError() in release mode. * If you purchase directly via the purchase handler (which is an internal class but this is done for testing), it will throw. 2. If someone configures purchases to use `.revenueCat`, and then displays a PaywallView **with** purchase/restore handler, the SDK will: * Log a warning when the PaywallView is constructed Rationale: In case 1, there could be no (acceptable) way to make a purchase, so this is very bad and it needs to be dealt with. In case 2, they've over-specified the paywall, and while this might be confusing (why isn't my code being called??), it's not as problematic, and we want to make it easy for people to switch from using their purchase logic to our purchase logic. These checks are made in PaywallView.swift, method `checkForConfigurationConsitency()`. ### UIKit Not yet supported, will add in a follow-up PR, unlikely to be complicated. ### Paywall Footer and Paywall If Needed The `performPurchase` and `performRestore` parameters for the paywall footer are contained in a struct rather than as loose parameters of closure types, because doing the latter would create a very poor experience with regards to code completion, where Xcode will always offer complete your code as a trailing closure, but will always use the first closure where the function signature matches. The following illustrates the problem of using loose closure parameters: The trailing closure that Xcode auto-creates here does _not_ get called for `performPurchase`, but rather `purchaseStarted`, because it also has a `package` as its input parameter, and comes first in the parameter list 😱. https://github.com/RevenueCat/purchases-ios/assets/109382862/1f809e7c-b661-4844-89e3-fd846a029531 This is the problematic function signature: ```swift public func paywallFooter( offering: Offering, condensed: Bool = false, fonts: PaywallFontProvider = DefaultPaywallFontProvider(), purchaseStarted: PurchaseOfPackageStartedHandler? = nil, purchaseCompleted: PurchaseOrRestoreCompletedHandler? = nil, purchaseCancelled: PurchaseCancelledHandler? = nil, restoreStarted: RestoreStartedHandler? = nil, restoreCompleted: PurchaseOrRestoreCompletedHandler? = nil, purchaseFailure: PurchaseFailureHandler? = nil, restoreFailure: PurchaseFailureHandler? = nil, performPurchase: PerformPurchase? = nil, performRestore: PerformRestore? = nil ) -> some View ``` To fix this, get rid of the two new parameters: ```swift performPurchase: PerformPurchase? = nil, performRestore: PerformRestore? = nil ``` and embed them in a struct: ```swift public struct MyAppPurchaseLogic { public let performPurchase: PerformPurchase public let performRestore: PerformRestore public init(performPurchase: @escaping PerformPurchase, performRestore: @escaping PerformRestore) { self.performPurchase = performPurchase self.performRestore = performRestore } } ``` which we use as a last parameter: ```swift myAppPurchaseLogic: MyAppPurchaseLogic? = nil ``` Now the code completes as so: https://github.com/RevenueCat/purchases-ios/assets/109382862/332ac42d-ccba-42bc-bfc3-702d7870c99c The `MyAppPurchaseLogic` doesn't complete in perfectly, but once you initialize one of those it goes nicely the rest of the way, including the error messages that instruct you on what you need to return. --------- Signed-off-by: dependabot[bot] <[email protected]> Co-authored-by: Josh Holtz <[email protected]> Co-authored-by: Cesar de la Vega <[email protected]> Co-authored-by: RevenueCat Git Bot <[email protected]> Co-authored-by: Toni Rico <[email protected]> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Will Taylor <[email protected]> Co-authored-by: Andy Boedo <[email protected]>
- Loading branch information