Paywalls with custom purchase and restore logic handlers#3973
Conversation
…to paywalls-manually-handle-purchases
To enable useful code completion.
|
the attention to detail in code completion is 🥇 |
| try PaywallView( | ||
| offering: Self.offering.withLocalImages, | ||
| customerInfo: TestData.customerInfo, | ||
| introEligibility: .producing(eligibility: .eligible), | ||
| purchaseHandler: purchasHandler | ||
| ) | ||
| .onPurchaseStarted { _ in | ||
| callbackOrder.append("onPurchaseStarted") | ||
| } | ||
| .onPurchaseCompleted { _ in | ||
| callbackOrder.append("onPurchaseCompleted") | ||
| } | ||
| .onPurchaseCancelled { | ||
| callbackOrder.append("onPurchaseCancelled") | ||
| } | ||
| .onPurchaseFailure { _ in | ||
| callbackOrder.append("onPurchaseFailure") | ||
| } | ||
| .onRestoreStarted({ | ||
| callbackOrder.append("onRestoreStarted") | ||
| }) | ||
| .onRestoreCompleted({ info in | ||
| callbackOrder.append("onRestoreCompleted") | ||
| customerInfo = info | ||
| }) | ||
| .onRestoreFailure({ _ in | ||
| callbackOrder.append("onRestoreFailure") | ||
| }) | ||
| .addToHierarchy() |
There was a problem hiding this comment.
I don't mind duplicate code in tests usually but it feels like this is an easy extract?
There was a problem hiding this comment.
Ugh I know, I almost mentioned this in the PR writeup. I tried to extract it a few ways but it's not easy, or at least I didn't find a way, I was being blocked because callbackOrder.append("") is a mutating function and these modifiers are all escaping closures and it is not allowed to do this. I tried to work around it but came up blank and decided for testing code it wasn't worth continuing to work on. IF there is a good way to extract it though I'd love to use it!!
| Task { | ||
| _ = try await purchasHandler.restorePurchases() | ||
| completed = true | ||
| } | ||
|
|
||
| expect(completed).toEventually(beTrue()) |
There was a problem hiding this comment.
we can simplify this a lot with async in the test signature
There was a problem hiding this comment.
I thought so too and had looked into it, but it ends up being a bit complicated. For example the existing test:
func testOnPurchaseCompleted() throws {
var customerInfo: CustomerInfo?
try PaywallView(
offering: Self.offering.withLocalImages,
customerInfo: TestData.customerInfo,
introEligibility: .producing(eligibility: .eligible),
purchaseHandler: Self.purchaseHandler
)
.onPurchaseCompleted {
customerInfo = $0
}
.addToHierarchy()
Task {
_ = try await Self.purchaseHandler.purchase(package: Self.package)
}
expect(customerInfo).toEventually(be(TestData.customerInfo))
}If you make it async as below, it fails:
func testOnPurchaseCompleted() async throws {
var customerInfo: CustomerInfo?
try PaywallView(
offering: Self.offering.withLocalImages,
customerInfo: TestData.customerInfo,
introEligibility: .producing(eligibility: .eligible),
purchaseHandler: Self.purchaseHandler
)
.onPurchaseCompleted {
customerInfo = $0
}
.addToHierarchy()
_ = try await Self.purchaseHandler.purchase(package: Self.package)
expect(customerInfo).to(be(TestData.customerInfo))
}The onPurchaseCompleted is called via a preference key change, and something about the way it's being called makes it come after the await continues. Which seems possibly incorrect, but I'll need more time to look into this, I'll come back to it if there is time to get this merged with it, but if not, maybe another PR?
There was a problem hiding this comment.
So I'm back to trying this again, and now the async method above is working. Why now, and why not before, I don't know...
There was a problem hiding this comment.
I think that using preference keys for triggering the modifier closures is not the way to do it if we want to guarantee they've all been executed by any specific time. These are tied to the SwiftUI view update system and when they execute aren't really under our control, so when Self.purchaseHandler.purchase returns, it may have set things in motion for onPurchaseCompleted to be called, but it may or may not have been called by any specific time. It's also why polling works (because it happens quickly), but checking immediately after the await generally doesn't.
So, if it's possible to change how the modifiers are called so that the ordering of these calls comes as one would expect, then that would be a good idea, but doing so touches quite a bit and should be a different PR.
There was a problem hiding this comment.
I have gone through the new tests and found the ones that don't rely on preference key timing and made those ones async.
| func testHandleExternalPurchaseWithoutPurchaseHandler() throws { | ||
| var errorThrown = false | ||
|
|
||
| let purchasHandler = Self.externalPurchaseHandler() | ||
|
|
||
| let config = PaywallViewConfiguration(purchaseHandler: purchasHandler) | ||
|
|
||
| try PaywallView(configuration: config).addToHierarchy() | ||
|
|
||
| Task { | ||
| do { | ||
| _ = try await purchasHandler.purchase(package: Self.package) | ||
| } catch { | ||
| errorThrown = true | ||
| } | ||
| } | ||
|
|
||
| expect(errorThrown).toEventually(beTrue()) |
There was a problem hiding this comment.
This can be simplified with just async / await, but there are also some concurrency goodies in Nimble now that we could explore
There was a problem hiding this comment.
We're using Nimble 10, a lot of this async stuff came in in Nimble 11, current version is Nimble 13. Definitely time for an update, but maybe as part of a separate PR?
There was a problem hiding this comment.
I started looking at this, and I don't think there are any complicated changes but there are a lot of them (they've described their own time interval type, NimbleTimeInterval), so I think this would be better done as a separate PR.
|
it'd be amazing to be able to resolve the inconsistent configuration at an API level, but I have a hard time coming up with great ways to do it. One thought would be to wrap the configuration methods from RevenueCat in RevenueCatUI so that we could also add one that has a purchaseHandler (or force you to set up a purchaseHandler when you set purchasesAreFinishedBy .myapp). Another would be to use Swift macros to check that the methods are called consistently. I think that's probably a lot of work, but not impossible if understand it correctly. Not something we need to take care of right away, though |
|
I believe I've addressed all the comments, save for a couple that have been left as to-dos for future PRs:
func testOnPurchaseCompleted() async throws {
var customerInfo: CustomerInfo?
try PaywallView(
offering: Self.offering.withLocalImages,
customerInfo: TestData.customerInfo,
introEligibility: .producing(eligibility: .eligible),
purchaseHandler: Self.purchaseHandler
)
.onPurchaseCompleted {
customerInfo = $0
}
.addToHierarchy()
_ = try await Self.purchaseHandler.purchase(package: Self.package)
expect(customerInfo).to(be(TestData.customerInfo))
} |
joshdholtz
left a comment
There was a problem hiding this comment.
I tthhiiinnkkk this is good to ship! 🚢 💪
joshdholtz
left a comment
There was a problem hiding this comment.
I tthhiiinnkkk this is good to ship! 🚢 💪
joshdholtz
left a comment
There was a problem hiding this comment.
I tthhiiinnkkk this is good to ship! 🚢 💪
**This is an automatic release.** ### New Features * Paywalls with custom purchase and restore logic handlers (#3973) via James Borthwick (@jamesrb1) ### Bugfixes * Prevent paywall PurchaseHandler from being cleared on rerender (#4035) via Josh Holtz (@joshdholtz) * Update Purchase Tester for 5.0.0 (#4015) via Will Taylor (@fire-at-will) ### Dependency Updates * Bump fastlane from 2.221.0 to 2.221.1 (#3977) via dependabot[bot] (@dependabot[bot]) ### Other Changes * Bring official `xcodes` back to CI (#4029) via Cesar de la Vega (@vegaro) * Paywalls tester with sandbox purchases (#4024) via James Borthwick (@jamesrb1) * Update v5 migration guide to contain current latest version (#4019) via Toni Rico (@tonidero) * CI Build Docs Improvements (#4014) via Will Taylor (@fire-at-will) * Use available resource class for backend-integration-tests-offline-job (#4013) via Will Taylor (@fire-at-will) * Add `X-Preferred-Locales` header (#4008) via Cesar de la Vega (@vegaro) --------- Co-authored-by: Toni Rico <antonio.rico.diez@revenuecat.com> Co-authored-by: Josh Holtz <me@joshholtz.com>
## 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] <support@github.com>
Co-authored-by: Josh Holtz <me@joshholtz.com>
Co-authored-by: Cesar de la Vega <cesarvegaro@gmail.com>
Co-authored-by: RevenueCat Git Bot <72824662+RCGitBot@users.noreply.github.com>
Co-authored-by: Toni Rico <toni.rico.diez@revenuecat.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Will Taylor <wtaylor151@gmail.com>
Co-authored-by: Andy Boedo <andresboedo@gmail.com>
**This is an automatic release.** ### New Features * Paywalls with custom purchase and restore logic handlers (#3973) via James Borthwick (@jamesrb1) ### Bugfixes * Prevent paywall PurchaseHandler from being cleared on rerender (#4035) via Josh Holtz (@joshdholtz) * Update Purchase Tester for 5.0.0 (#4015) via Will Taylor (@fire-at-will) ### Dependency Updates * Bump fastlane from 2.221.0 to 2.221.1 (#3977) via dependabot[bot] (@dependabot[bot]) ### Other Changes * Bring official `xcodes` back to CI (#4029) via Cesar de la Vega (@vegaro) * Paywalls tester with sandbox purchases (#4024) via James Borthwick (@jamesrb1) * Update v5 migration guide to contain current latest version (#4019) via Toni Rico (@tonidero) * CI Build Docs Improvements (#4014) via Will Taylor (@fire-at-will) * Use available resource class for backend-integration-tests-offline-job (#4013) via Will Taylor (@fire-at-will) * Add `X-Preferred-Locales` header (#4008) via Cesar de la Vega (@vegaro) --------- Co-authored-by: Toni Rico <antonio.rico.diez@revenuecat.com> Co-authored-by: Josh Holtz <me@joshholtz.com>
tl;dr
PaywallView has a new constructor that takes
performPurchaseandperformRestoreblocks, 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 whenPurchaseshas been configured.with(purchasesAreCompletedBy: .myApp).Example usage:
Description
When a
PaywallViewis constructed, a newPurchaseHandleris created. ThePurchaseHandler(an internal RevenueCatUI class) is owned by thePaywallView, and it is responsible for executing new purchases and restores.When a
PaywallViewis constructed withoutperformPurchaseandperformRestoreblocks, thePaywallViewcreates aPurchaseHandlercapable of preforming purchases using RevenueCat. When aPaywallViewis constructed withperformPurchaseandperformRestoreblocks, it can also make purchases using the customer-supplied closures.The
PurchaseHandleris invoked when the user taps thePurchaseButton, callingpurchaseHandler.purchase(package: self.selectedPackage.content), which branches to either the internal or external purchase code, as defined bypurchasesAreCompletedBy:Purchase and Restore blocks can also be assigned for paywall footers:
and via
.presentPaywallIfNeeded: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 aPaywallConfiguration, both of which are normally constructed internally in the PaywallView constructor.Error Handling
For the external code blocks to be called,
purchasesAreCompletedByneeds to be set to.myAppAND the blocks need to be defined. So we need to handle cases when these are not consistent:.myApp, and then displays a PaywallView without purchase/restore handlers, the SDK will:.revenueCat, and then displays a PaywallView with purchase/restore handler, the SDK will: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
performPurchaseandperformRestoreparameters 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 ratherpurchaseStarted, because it also has apackageas its input parameter, and comes first in the parameter list 😱.code.completion.mov
This is the problematic function signature:
To fix this, get rid of the two new parameters:
and embed them in a struct:
which we use as a last parameter:
Now the code completes as so:
with-struct.mov
The
MyAppPurchaseLogicdoesn'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.