Files
purchases-ios/RevenueCatUI/View+PurchaseRestoreCompleted.swift
T
41d3541451 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] <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>
2024-07-05 12:05:44 -07:00

422 lines
14 KiB
Swift

//
// Copyright RevenueCat Inc. All Rights Reserved.
//
// Licensed under the MIT License (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// https://opensource.org/licenses/MIT
//
// View+PurchaseRestoreCompleted.swift
//
// Created by Nacho Soto on 7/31/23.
import RevenueCat
import SwiftUI
import StoreKit
// swiftlint:disable file_length
/// A closure used for notifying of purchase or restore completion.
public typealias PurchaseOrRestoreCompletedHandler = @MainActor @Sendable (CustomerInfo) -> Void
/// A closure used for notifying of purchase completion.
public typealias PurchaseCompletedHandler = @MainActor @Sendable (_ transaction: StoreTransaction?,
_ customerInfo: CustomerInfo) -> Void
/// A closure used for notifying of purchase initiation.
@available(iOS, deprecated: 1, renamed: "PurchaseOfPackageStartedHandler")
@available(tvOS, deprecated: 1, renamed: "PurchaseOfPackageStartedHandler")
@available(watchOS, deprecated: 1, renamed: "PurchaseOfPackageStartedHandler")
@available(macOS, deprecated: 1, renamed: "PurchaseOfPackageStartedHandler")
@available(macCatalyst, deprecated: 1, renamed: "PurchaseOfPackageStartedHandler")
public typealias PurchaseStartedHandler = @MainActor @Sendable () -> Void
/// A closure used for notifying of purchase of a package initiation.
public typealias PurchaseOfPackageStartedHandler = @MainActor @Sendable (_ package: Package) -> Void
/// A closure used for notifying of purchase cancellation.
public typealias PurchaseCancelledHandler = @MainActor @Sendable () -> Void
/// A closure used to perform custom purchase logic implemented by your app.
/// - Parameters:
/// - package: The package to be purchased.
/// - Returns: A tuple indicating whether the user cancelled the operation and
/// any error encountered during the purchase process.
/// - userCancelled: `true` if the user cancelled the purchase; otherwise, `false`.
/// - error: An optional error that occurred during the purchase process, or `nil` if no error occurred.
@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *)
public typealias PerformPurchase = @MainActor @Sendable (
_ packageToPurchase: Package
) async -> (userCancelled: Bool, error: Error?)
/// A closure used to perform custom restore logic implemented by your app.
/// - Returns: A tuple indicating whether the restore operation was successful and
/// any error encountered during the process.
/// - success: `true` if the restore operation succeeded; otherwise, `false`.
/// - error: An optional error that occurred during the restore process, or `nil` if no error occurred.
@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *)
public typealias PerformRestore = @MainActor @Sendable (
) async -> (success: Bool, error: Error?)
/// A closure used for notifying of failures during purchases or restores.
public typealias PurchaseFailureHandler = @MainActor @Sendable (NSError) -> Void
/// A closure used for notifying of restore initiation.
public typealias RestoreStartedHandler = @MainActor @Sendable () -> Void
@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *)
@available(macOS, unavailable, message: "RevenueCatUI does not support macOS yet")
extension View {
/// Invokes the given closure when a purchase begins.
/// Example:
/// ```swift
/// @State
/// var body: some View {
/// PaywallView()
/// .onPurchaseStarted {
/// print("Purchase started")
/// }
/// }
/// ```
///
/// ### Related Articles
/// [Documentation](https://rev.cat/paywalls)
@available(iOS, deprecated: 1, renamed: "onPurchaseStarted(handler:)")
@available(tvOS, deprecated: 1, renamed: "onPurchaseStarted(handler:)")
@available(watchOS, deprecated: 1, renamed: "onPurchaseStarted(handler:)")
@available(macOS, deprecated: 1, renamed: "onPurchaseStarted(handler:)")
@available(macCatalyst, deprecated: 1, renamed: "onPurchaseStarted(handler:)")
public func onPurchaseStarted(
_ handler: @escaping PurchaseStartedHandler
) -> some View {
return self.modifier(OnPurchaseStartedModifier(handler: { _ in
handler()
}))
}
/// Invokes the given closure when a purchase of a package begins.
/// Example:
/// ```swift
/// @State
/// var body: some View {
/// PaywallView()
/// .onPurchaseStarted { package in
/// print("Purchase started for package: \(package)")
/// }
/// }
/// ```
///
/// ### Related Articles
/// [Documentation](https://rev.cat/paywalls)
public func onPurchaseStarted(
_ handler: @escaping PurchaseOfPackageStartedHandler
) -> some View {
return self.modifier(OnPurchaseStartedModifier(handler: handler))
}
/// Invokes the given closure when a purchase is completed.
/// The closure includes the `CustomerInfo` with unlocked entitlements.
/// Example:
/// ```swift
/// @State
/// private var displayPaywall: Bool = true
///
/// var body: some View {
/// ContentView()
/// .sheet(isPresented: self.$displayPaywall) {
/// PaywallView()
/// .onPurchaseCompleted { customerInfo in
/// print("Purchase completed: \(customerInfo.entitlements)")
/// self.displayPaywall = false
/// }
/// }
/// }
/// ```
///
/// ### Related Articles
/// [Documentation](https://rev.cat/paywalls)
public func onPurchaseCompleted(
_ handler: @escaping PurchaseOrRestoreCompletedHandler
) -> some View {
return self.modifier(OnPurchaseCompletedModifier(handler: handler))
}
/// Invokes the given closure when a purchase is completed.
/// The closure includes the `CustomerInfo` with unlocked entitlements.
/// Example:
/// ```swift
/// @State
/// private var displayPaywall: Bool = true
///
/// var body: some View {
/// ContentView()
/// .sheet(isPresented: self.$displayPaywall) {
/// PaywallView()
/// .onPurchaseCompleted { transaction, customerInfo in
/// print("Purchase completed: \(customerInfo.entitlements)")
/// self.displayPaywall = false
/// }
/// }
/// }
/// ```
///
/// ### Related Articles
/// [Documentation](https://rev.cat/paywalls)
public func onPurchaseCompleted(
_ handler: @escaping PurchaseCompletedHandler
) -> some View {
return self.modifier(OnPurchaseCompletedModifier(handler: handler))
}
/// Invokes the given closure when a purchase is cancelled.
///
/// Example:
/// ```swift
/// PaywallView()
/// .onPurchaseCancelled {
/// print("Purchase was cancelled")
/// }
/// ```
public func onPurchaseCancelled(
_ handler: @escaping PurchaseCancelledHandler
) -> some View {
return self.modifier(OnPurchaseCancelledModifier(handler: handler))
}
/// Invokes the given closure when a restore begins.
/// Example:
/// ```swift
/// @State
/// var body: some View {
/// PaywallView()
/// .onRestoreStarted {
/// print("Restore started")
/// }
/// }
/// ```
///
/// ### Related Articles
/// [Documentation](https://rev.cat/paywalls)
public func onRestoreStarted(
_ handler: @escaping RestoreStartedHandler
) -> some View {
return self.modifier(OnRestoreStartedModifier(handler: handler))
}
/// Invokes the given closure when restore purchases is completed.
/// The closure includes the `CustomerInfo` after the process is completed.
/// Example:
/// ```swift
/// @State
/// private var displayPaywall: Bool = true
///
/// var body: some View {
/// ContentView()
/// .sheet(isPresented: self.$displayPaywall) {
/// PaywallView()
/// .onRestoreCompleted { customerInfo in
/// if !customerInfo.entitlements.active.isEmpty {
/// self.displayPaywall = false
/// }
/// }
/// }
/// }
/// ```
///
/// - Warning: Receiving a ``CustomerInfo``does not imply that the user has any entitlements,
/// simply that the process was successful. You must verify the ``CustomerInfo/entitlements``
/// to confirm that they are active.
///
/// ### Related Articles
/// [Documentation](https://rev.cat/paywalls)
public func onRestoreCompleted(
_ handler: @escaping PurchaseOrRestoreCompletedHandler
) -> some View {
return self.modifier(OnRestoreCompletedModifier(handler: handler))
}
/// Invokes the given closure when an error is produced during a purchase.
/// Example:
/// ```swift
/// var body: some View {
/// ContentView()
/// .sheet(isPresented: self.$displayPaywall) {
/// PaywallView()
/// .onPurchaseFailure { error in
/// print("Error purchasing: \(error)")
/// }
/// }
/// }
/// ```
public func onPurchaseFailure(
_ handler: @escaping PurchaseFailureHandler
) -> some View {
return self.modifier(PurchaseFailureModifier(handler: handler))
}
/// Invokes the given closure when an error is produced during restore purchases.
/// Example:
/// ```swift
/// var body: some View {
/// ContentView()
/// .sheet(isPresented: self.$displayPaywall) {
/// PaywallView()
/// .onRestoreFailure { error in
/// print("Error restoring purchases: \(error)")
/// }
/// }
/// }
/// ```
public func onRestoreFailure(
_ handler: @escaping PurchaseFailureHandler
) -> some View {
return self.modifier(RestoreFailureModifier(handler: handler))
}
/// Invokes the given closure when the paywall is meant to be dismissed. This closure will be called:
/// - When a purchase is completed.
/// - Whenever the close button is pressed.
/// Example:
/// ```swift
/// var body: some View {
/// ContentView()
/// .sheet(isPresented: self.$displayPaywall) {
/// PaywallView()
/// .onRequestedDismissal {
/// self.dismiss()
/// }
/// }
/// }
/// ```
public func onRequestedDismissal(_ action: @escaping (() -> Void)) -> some View {
self.environment(\.onRequestedDismissal, action)
}
}
@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *)
private struct OnPurchaseStartedModifier: ViewModifier {
let handler: PurchaseOfPackageStartedHandler
func body(content: Content) -> some View {
content
.onPreferenceChange(PurchaseInProgressPreferenceKey.self) { package in
if let package {
self.handler(package)
}
}
}
}
@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *)
private struct OnPurchaseCompletedModifier: ViewModifier {
let handler: PurchaseCompletedHandler
init(handler: @escaping PurchaseOrRestoreCompletedHandler) {
self.handler = { _, customerInfo in handler(customerInfo) }
}
init(handler: @escaping PurchaseCompletedHandler) {
self.handler = handler
}
func body(content: Content) -> some View {
content
.onPreferenceChange(PurchasedResultPreferenceKey.self) { result in
if let result, !result.userCancelled {
self.handler(result.transaction, result.customerInfo)
}
}
}
}
@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *)
private struct OnPurchaseCancelledModifier: ViewModifier {
let handler: PurchaseCancelledHandler
init(handler: @escaping PurchaseCancelledHandler) {
self.handler = handler
}
func body(content: Content) -> some View {
content
.onPreferenceChange(PurchasedResultPreferenceKey.self) { result in
if let result, result.userCancelled {
self.handler()
}
}
}
}
@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *)
private struct OnRestoreStartedModifier: ViewModifier {
let handler: RestoreStartedHandler
func body(content: Content) -> some View {
content
.onPreferenceChange(RestoreInProgressPreferenceKey.self) { inProgress in
if inProgress {
self.handler()
}
}
}
}
@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *)
private struct OnRestoreCompletedModifier: ViewModifier {
let handler: PurchaseOrRestoreCompletedHandler
func body(content: Content) -> some View {
content
.onPreferenceChange(RestoredCustomerInfoPreferenceKey.self) { customerInfo in
if let customerInfo {
self.handler(customerInfo)
}
}
}
}
@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *)
private struct PurchaseFailureModifier: ViewModifier {
let handler: PurchaseFailureHandler
func body(content: Content) -> some View {
content
.onPreferenceChange(PurchaseErrorPreferenceKey.self) { error in
if let error {
self.handler(error)
}
}
}
}
@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *)
private struct RestoreFailureModifier: ViewModifier {
let handler: PurchaseFailureHandler
func body(content: Content) -> some View {
content
.onPreferenceChange(RestoreErrorPreferenceKey.self) { error in
if let error {
self.handler(error)
}
}
}
}