RevenueCat / purchases-ios

In-app purchases and subscriptions made easy. Support for iOS, watchOS, tvOS, macOS, and visionOS.
https://www.revenuecat.com/
MIT License
2.2k stars 294 forks source link

Paywalls with custom purchase and restore logic handlers #3973

Open jamesrb1 opened 2 weeks ago

jamesrb1 commented 2 weeks ago

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.
  2. If someone configures purchases to use .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 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:

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:

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.

aboedo commented 13 hours ago

the attention to detail in code completion is 🥇

aboedo commented 12 hours ago

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 commented 33 minutes ago

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))
    }