Skip to content

Paywalls with custom purchase and restore logic handlers#3973

Merged
jamesrb1 merged 213 commits into
mainfrom
james/paywalls-manually-handle-purchase-redux
Jul 5, 2024
Merged

Paywalls with custom purchase and restore logic handlers#3973
jamesrb1 merged 213 commits into
mainfrom
james/paywalls-manually-handle-purchase-redux

Conversation

@jamesrb1

@jamesrb1 jamesrb1 commented Jun 19, 2024

Copy link
Copy Markdown
Contributor

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:

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:

@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:

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:

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.

    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.
  1. 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 😱.

code.completion.mov

This is the problematic function signature:

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:

        performPurchase: PerformPurchase? = nil,
        performRestore: PerformRestore? = nil

and embed them in a struct:

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:

        myAppPurchaseLogic: MyAppPurchaseLogic? = nil

Now the code completes as so:

with-struct.mov

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.

@aboedo

aboedo commented Jul 4, 2024

Copy link
Copy Markdown
Member

the attention to detail in code completion is 🥇

@aboedo aboedo left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

great work on this!

Comment thread RevenueCatUI/PaywallView.swift Outdated
Comment thread RevenueCatUI/Purchasing/PurchaseHandler.swift
Comment thread RevenueCatUI/Purchasing/PurchaseHandler.swift
Comment thread Tests/RevenueCatUITests/PaywallFooterTests.swift Outdated
Comment on lines +196 to +224
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()

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't mind duplicate code in tests usually but it feels like this is an easy extract?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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!!

Comment on lines +226 to +231
Task {
_ = try await purchasHandler.restorePurchases()
completed = true
}

expect(completed).toEventually(beTrue())

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we can simplify this a lot with async in the test signature

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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...

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I have gone through the new tests and found the ones that don't rely on preference key timing and made those ones async.

Comment thread Tests/RevenueCatUITests/Templates/ExternalPurchaseAndRestoreTests.swift Outdated
Comment thread Tests/RevenueCatUITests/Templates/ExternalPurchaseAndRestoreTests.swift Outdated
Comment on lines +285 to +302
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())

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This can be simplified with just async / await, but there are also some concurrency goodies in Nimble now that we could explore

https://github.com/Quick/Nimble/blob/6ba9e680a1962a86510e9ea2c5c79fd43c5eeff4/Sources/Nimble/Nimble.docc/Guides/Concurrency.md

@jamesrb1 jamesrb1 Jul 4, 2024

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@aboedo

aboedo commented Jul 4, 2024

Copy link
Copy Markdown
Member

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

@jamesrb1

jamesrb1 commented Jul 5, 2024

Copy link
Copy Markdown
Contributor Author

I believe I've addressed all the comments, save for a couple that have been left as to-dos for future PRs:

  1. Update Nimble to take advantage of asynchronous expectations.
  2. Investigate if we can call custom view modifier closures from within the modified view without using Preferences, so that we can run the closures before the triggering call returns.

    For example, in the code below, .onPurchaseCompleted { may be called after await Self.purchaseHandler.purchase(package: returns:
    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))
    }

@jamesrb1 jamesrb1 requested a review from aboedo July 5, 2024 03:54

@joshdholtz joshdholtz left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I tthhiiinnkkk this is good to ship! 🚢 💪

@joshdholtz joshdholtz left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I tthhiiinnkkk this is good to ship! 🚢 💪

@joshdholtz joshdholtz left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I tthhiiinnkkk this is good to ship! 🚢 💪

@aboedo aboedo left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

:shipit:

@jamesrb1 jamesrb1 merged commit 41d3541 into main Jul 5, 2024
@jamesrb1 jamesrb1 deleted the james/paywalls-manually-handle-purchase-redux branch July 5, 2024 19:05
joshdholtz added a commit that referenced this pull request Jul 16, 2024
**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>
nyeu pushed a commit that referenced this pull request Oct 2, 2024
## 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>
nyeu pushed a commit that referenced this pull request Oct 2, 2024
**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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

pr:feat A new feature

Projects

None yet

Development

Successfully merging this pull request may close these issues.

6 participants