From 9c141a4c0df91a7ba982f2f6c1757964f29fc7d0 Mon Sep 17 00:00:00 2001 From: NachoSoto Date: Wed, 20 Sep 2023 14:15:09 -0700 Subject: [PATCH 1/4] `Paywalls`: don't display "Purchases restored successfully" with no entitlements We can improve the logic here, but this is a simple improvement for the obvious case where nothing was actually restored. We don't have an easy way to test this, but at least I added coverage for `onRestoreCompleted` which was missing. --- RevenueCatUI/Views/FooterView.swift | 7 +++++-- .../PurchaseCompletedHandlerTests.swift | 21 +++++++++++++++++++ 2 files changed, 26 insertions(+), 2 deletions(-) diff --git a/RevenueCatUI/Views/FooterView.swift b/RevenueCatUI/Views/FooterView.swift index 82862ca2cf..ae68f22467 100644 --- a/RevenueCatUI/Views/FooterView.swift +++ b/RevenueCatUI/Views/FooterView.swift @@ -167,8 +167,11 @@ private struct RestorePurchasesButton: View { var body: some View { AsyncButton { - _ = try await self.purchaseHandler.restorePurchases() - self.displayRestoredAlert = true + let customerInfo = try await self.purchaseHandler.restorePurchases() + + if !customerInfo.entitlements.active.isEmpty { + self.displayRestoredAlert = true + } } label: { if #available(iOS 16.0, macOS 13.0, tvOS 16.0, *) { ViewThatFits { diff --git a/Tests/RevenueCatUITests/PurchaseCompletedHandlerTests.swift b/Tests/RevenueCatUITests/PurchaseCompletedHandlerTests.swift index 24b00f4919..803839c466 100644 --- a/Tests/RevenueCatUITests/PurchaseCompletedHandlerTests.swift +++ b/Tests/RevenueCatUITests/PurchaseCompletedHandlerTests.swift @@ -70,6 +70,27 @@ class PurchaseCompletedHandlerTests: TestCase { expect(customerInfo).toEventually(be(TestData.customerInfo)) } + func testOnRestoreCompleted() throws { + var customerInfo: CustomerInfo? + + try PaywallView( + offering: Self.offering.withLocalImages, + customerInfo: TestData.customerInfo, + introEligibility: .producing(eligibility: .eligible), + purchaseHandler: Self.purchaseHandler + ) + .onRestoreCompleted { + customerInfo = $0 + } + .addToHierarchy() + + Task { + _ = try await Self.purchaseHandler.restorePurchases() + } + + expect(customerInfo).toEventually(be(TestData.customerInfo)) + } + private static let purchaseHandler: PurchaseHandler = .mock() private static let offering = TestData.offeringWithNoIntroOffer private static let package = TestData.annualPackage From 4c20917008a64e4f34c74d34a25cf9712c11267c Mon Sep 17 00:00:00 2001 From: NachoSoto Date: Wed, 20 Sep 2023 14:30:09 -0700 Subject: [PATCH 2/4] Improved logic and added tests --- RevenueCatUI/Data/TestData.swift | 61 ++++++++++++++++--- .../Purchasing/PurchaseHandler+TestData.swift | 6 +- RevenueCatUI/Purchasing/PurchaseHandler.swift | 7 ++- RevenueCatUI/Views/FooterView.swift | 5 +- .../Purchasing/PurchaseHandlerTests.swift | 13 +++- 5 files changed, 76 insertions(+), 16 deletions(-) diff --git a/RevenueCatUI/Data/TestData.swift b/RevenueCatUI/Data/TestData.swift index 73220396b6..292e0fff14 100644 --- a/RevenueCatUI/Data/TestData.swift +++ b/RevenueCatUI/Data/TestData.swift @@ -440,7 +440,8 @@ internal enum TestData { #endif static let customerInfo: CustomerInfo = { - let json = """ + return .decode( + """ { "schema_version": "4", "request_date": "2022-03-08T17:42:58Z", @@ -463,13 +464,46 @@ internal enum TestData { } } """ + ) + }() - let decoder = JSONDecoder() - decoder.keyDecodingStrategy = .convertFromSnakeCase - decoder.dateDecodingStrategy = .iso8601 - - // swiftlint:disable:next force_try - return try! decoder.decode(CustomerInfo.self, from: Data(json.utf8)) + static let customerInfoWithSubscriptions: CustomerInfo = { + return .decode( + """ + { + "schema_version": "4", + "request_date": "2022-03-08T17:42:58Z", + "request_date_ms": 1646761378845, + "subscriber": { + "first_seen": "2022-03-08T17:42:58Z", + "last_seen": "2022-03-08T17:42:58Z", + "management_url": "https://apps.apple.com/account/subscriptions", + "non_subscriptions": { + }, + "original_app_user_id": "$RCAnonymousID:5b6fdbac3a0c4f879e43d269ecdf9ba1", + "original_application_version": "1.0", + "original_purchase_date": "2022-04-12T00:03:24Z", + "other_purchases": { + }, + "subscriptions": { + "com.revenuecat.product": { + "billing_issues_detected_at": null, + "expires_date": "2062-04-12T00:03:35Z", + "grace_period_expires_date": null, + "is_sandbox": true, + "original_purchase_date": "2022-04-12T00:03:28Z", + "period_type": "intro", + "purchase_date": "2022-04-12T00:03:28Z", + "store": "app_store", + "unsubscribe_detected_at": null + }, + }, + "entitlements": { + } + } + } + """ + ) }() static let localization1: PaywallData.LocalizedConfiguration = .init( @@ -540,4 +574,17 @@ extension PackageType { } +private extension CustomerInfo { + + static func decode(_ json: String) -> Self { + let decoder = JSONDecoder() + decoder.keyDecodingStrategy = .convertFromSnakeCase + decoder.dateDecodingStrategy = .iso8601 + + // swiftlint:disable:next force_try + return try! decoder.decode(Self.self, from: Data(json.utf8)) + } + +} + #endif diff --git a/RevenueCatUI/Purchasing/PurchaseHandler+TestData.swift b/RevenueCatUI/Purchasing/PurchaseHandler+TestData.swift index 3dc0bc3d02..5d9f2f21d7 100644 --- a/RevenueCatUI/Purchasing/PurchaseHandler+TestData.swift +++ b/RevenueCatUI/Purchasing/PurchaseHandler+TestData.swift @@ -19,16 +19,16 @@ import RevenueCat @available(iOS 15.0, macOS 12.0, tvOS 15.0, *) extension PurchaseHandler { - static func mock() -> Self { + static func mock(customerInfo: CustomerInfo = TestData.customerInfo) -> Self { return self.init( purchases: MockPurchases { _ in return ( transaction: nil, - customerInfo: TestData.customerInfo, + customerInfo: customerInfo, userCancelled: false ) } restorePurchases: { - return TestData.customerInfo + return customerInfo } trackEvent: { event in Logger.debug("Tracking event: \(event)") } diff --git a/RevenueCatUI/Purchasing/PurchaseHandler.swift b/RevenueCatUI/Purchasing/PurchaseHandler.swift index 3cb01a3b0c..1bd2e30e49 100644 --- a/RevenueCatUI/Purchasing/PurchaseHandler.swift +++ b/RevenueCatUI/Purchasing/PurchaseHandler.swift @@ -91,8 +91,10 @@ extension PurchaseHandler { return result } + /// - Returns: `success` is `true` only when the resulting `CustomerInfo` + /// had any transactions @MainActor - func restorePurchases() async throws -> CustomerInfo { + func restorePurchases() async throws -> (info: CustomerInfo, success: Bool) { self.actionInProgress = true defer { self.actionInProgress = false } @@ -103,7 +105,8 @@ extension PurchaseHandler { self.restoredCustomerInfo = customerInfo } - return customerInfo + return (customerInfo, + success: !customerInfo.activeSubscriptions.isEmpty) } func trackPaywallImpression(_ eventData: PaywallEvent.Data) { diff --git a/RevenueCatUI/Views/FooterView.swift b/RevenueCatUI/Views/FooterView.swift index ae68f22467..2886e0a8e2 100644 --- a/RevenueCatUI/Views/FooterView.swift +++ b/RevenueCatUI/Views/FooterView.swift @@ -167,9 +167,8 @@ private struct RestorePurchasesButton: View { var body: some View { AsyncButton { - let customerInfo = try await self.purchaseHandler.restorePurchases() - - if !customerInfo.entitlements.active.isEmpty { + let success = try await self.purchaseHandler.restorePurchases().success + if success { self.displayRestoredAlert = true } } label: { diff --git a/Tests/RevenueCatUITests/Purchasing/PurchaseHandlerTests.swift b/Tests/RevenueCatUITests/Purchasing/PurchaseHandlerTests.swift index 0c3b3af819..8f571e6b8b 100644 --- a/Tests/RevenueCatUITests/Purchasing/PurchaseHandlerTests.swift +++ b/Tests/RevenueCatUITests/Purchasing/PurchaseHandlerTests.swift @@ -55,13 +55,24 @@ class PurchaseHandlerTests: TestCase { func testRestorePurchases() async throws { let handler: PurchaseHandler = .mock() - _ = try await handler.restorePurchases() + let result = try await handler.restorePurchases() + + expect(result.info) === TestData.customerInfo + expect(result.success) == false expect(handler.restored) == true expect(handler.restoredCustomerInfo) === TestData.customerInfo expect(handler.purchasedCustomerInfo).to(beNil()) expect(handler.actionInProgress) == false } + func testRestorePurchasesWithNoTransactions() async throws { + let handler: PurchaseHandler = .mock(customerInfo: TestData.customerInfoWithSubscriptions) + + let result = try await handler.restorePurchases() + expect(result.info) === TestData.customerInfoWithSubscriptions + expect(result.success) == true + } + } #endif From 9d92e7d8e4e8e26f15420af27d5e753d01c31767 Mon Sep 17 00:00:00 2001 From: NachoSoto Date: Wed, 20 Sep 2023 14:34:11 -0700 Subject: [PATCH 3/4] Non-subscriptions --- RevenueCatUI/Data/TestData.swift | 41 +------- RevenueCatUI/Purchasing/PurchaseHandler.swift | 12 ++- .../Purchasing/PurchaseHandlerTests.swift | 96 ++++++++++++++++++- 3 files changed, 104 insertions(+), 45 deletions(-) diff --git a/RevenueCatUI/Data/TestData.swift b/RevenueCatUI/Data/TestData.swift index 292e0fff14..2a68a3646a 100644 --- a/RevenueCatUI/Data/TestData.swift +++ b/RevenueCatUI/Data/TestData.swift @@ -467,45 +467,6 @@ internal enum TestData { ) }() - static let customerInfoWithSubscriptions: CustomerInfo = { - return .decode( - """ - { - "schema_version": "4", - "request_date": "2022-03-08T17:42:58Z", - "request_date_ms": 1646761378845, - "subscriber": { - "first_seen": "2022-03-08T17:42:58Z", - "last_seen": "2022-03-08T17:42:58Z", - "management_url": "https://apps.apple.com/account/subscriptions", - "non_subscriptions": { - }, - "original_app_user_id": "$RCAnonymousID:5b6fdbac3a0c4f879e43d269ecdf9ba1", - "original_application_version": "1.0", - "original_purchase_date": "2022-04-12T00:03:24Z", - "other_purchases": { - }, - "subscriptions": { - "com.revenuecat.product": { - "billing_issues_detected_at": null, - "expires_date": "2062-04-12T00:03:35Z", - "grace_period_expires_date": null, - "is_sandbox": true, - "original_purchase_date": "2022-04-12T00:03:28Z", - "period_type": "intro", - "purchase_date": "2022-04-12T00:03:28Z", - "store": "app_store", - "unsubscribe_detected_at": null - }, - }, - "entitlements": { - } - } - } - """ - ) - }() - static let localization1: PaywallData.LocalizedConfiguration = .init( title: "Ignite your child's *curiosity*", subtitle: "Get access to all our educational content trusted by **thousands** of parents.", @@ -574,7 +535,7 @@ extension PackageType { } -private extension CustomerInfo { +extension CustomerInfo { static func decode(_ json: String) -> Self { let decoder = JSONDecoder() diff --git a/RevenueCatUI/Purchasing/PurchaseHandler.swift b/RevenueCatUI/Purchasing/PurchaseHandler.swift index 1bd2e30e49..22261d526e 100644 --- a/RevenueCatUI/Purchasing/PurchaseHandler.swift +++ b/RevenueCatUI/Purchasing/PurchaseHandler.swift @@ -105,8 +105,8 @@ extension PurchaseHandler { self.restoredCustomerInfo = customerInfo } - return (customerInfo, - success: !customerInfo.activeSubscriptions.isEmpty) + return (info: customerInfo, + success: customerInfo.hasActiveSubscriptionsOrNonSubscriptions) } func trackPaywallImpression(_ eventData: PaywallEvent.Data) { @@ -223,3 +223,11 @@ private extension PaywallEvent.Data { } } + +private extension CustomerInfo { + + var hasActiveSubscriptionsOrNonSubscriptions: Bool { + return !self.activeSubscriptions.isEmpty || !self.nonSubscriptions.isEmpty + } + +} diff --git a/Tests/RevenueCatUITests/Purchasing/PurchaseHandlerTests.swift b/Tests/RevenueCatUITests/Purchasing/PurchaseHandlerTests.swift index 8f571e6b8b..aced885f44 100644 --- a/Tests/RevenueCatUITests/Purchasing/PurchaseHandlerTests.swift +++ b/Tests/RevenueCatUITests/Purchasing/PurchaseHandlerTests.swift @@ -65,14 +65,104 @@ class PurchaseHandlerTests: TestCase { expect(handler.actionInProgress) == false } - func testRestorePurchasesWithNoTransactions() async throws { - let handler: PurchaseHandler = .mock(customerInfo: TestData.customerInfoWithSubscriptions) + func testRestorePurchasesWithActiveSubscriptions() async throws { + let handler: PurchaseHandler = .mock(customerInfo: Self.customerInfoWithSubscriptions) let result = try await handler.restorePurchases() - expect(result.info) === TestData.customerInfoWithSubscriptions + expect(result.info) === Self.customerInfoWithSubscriptions expect(result.success) == true } + func testRestorePurchasesWithNonSubscriptions() async throws { + let handler: PurchaseHandler = .mock(customerInfo: Self.customerInfoWithNonSubscriptions) + + let result = try await handler.restorePurchases() + expect(result.info) === Self.customerInfoWithNonSubscriptions + expect(result.success) == true + } + +} + +@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) +private extension PurchaseHandlerTests { + + static let customerInfoWithSubscriptions: CustomerInfo = { + return .decode( + """ + { + "schema_version": "4", + "request_date": "2022-03-08T17:42:58Z", + "request_date_ms": 1646761378845, + "subscriber": { + "first_seen": "2022-03-08T17:42:58Z", + "last_seen": "2022-03-08T17:42:58Z", + "management_url": "https://apps.apple.com/account/subscriptions", + "non_subscriptions": { + }, + "original_app_user_id": "$RCAnonymousID:5b6fdbac3a0c4f879e43d269ecdf9ba1", + "original_application_version": "1.0", + "original_purchase_date": "2022-04-12T00:03:24Z", + "other_purchases": { + }, + "subscriptions": { + "com.revenuecat.product": { + "billing_issues_detected_at": null, + "expires_date": "2062-04-12T00:03:35Z", + "grace_period_expires_date": null, + "is_sandbox": true, + "original_purchase_date": "2022-04-12T00:03:28Z", + "period_type": "intro", + "purchase_date": "2022-04-12T00:03:28Z", + "store": "app_store", + "unsubscribe_detected_at": null + }, + }, + "entitlements": { + } + } + } + """ + ) + }() + + static let customerInfoWithNonSubscriptions: CustomerInfo = { + return .decode( + """ + { + "schema_version": "4", + "request_date": "2022-03-08T17:42:58Z", + "request_date_ms": 1646761378845, + "subscriber": { + "first_seen": "2022-03-08T17:42:58Z", + "last_seen": "2022-03-08T17:42:58Z", + "management_url": "https://apps.apple.com/account/subscriptions", + "non_subscriptions": { + "com.revenuecat.product.tip": [ + { + "purchase_date": "2022-02-11T00:03:28Z", + "original_purchase_date": "2022-03-10T00:04:28Z", + "id": "17459f5ff7", + "store_transaction_id": "340001090153249", + "store": "app_store", + "is_sandbox": false + } + ] + }, + "original_app_user_id": "$RCAnonymousID:5b6fdbac3a0c4f879e43d269ecdf9ba1", + "original_application_version": "1.0", + "original_purchase_date": "2022-04-12T00:03:24Z", + "other_purchases": { + }, + "subscriptions": { + }, + "entitlements": { + } + } + } + """ + ) + }() + } #endif From b4da438e3c8738b96db953352bfdb70f5f10cb60 Mon Sep 17 00:00:00 2001 From: NachoSoto Date: Wed, 20 Sep 2023 14:35:20 -0700 Subject: [PATCH 4/4] Simplified call --- RevenueCatUI/Purchasing/PurchaseHandler+TestData.swift | 2 +- Tests/RevenueCatUITests/Purchasing/PurchaseHandlerTests.swift | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/RevenueCatUI/Purchasing/PurchaseHandler+TestData.swift b/RevenueCatUI/Purchasing/PurchaseHandler+TestData.swift index 5d9f2f21d7..b88004dd80 100644 --- a/RevenueCatUI/Purchasing/PurchaseHandler+TestData.swift +++ b/RevenueCatUI/Purchasing/PurchaseHandler+TestData.swift @@ -19,7 +19,7 @@ import RevenueCat @available(iOS 15.0, macOS 12.0, tvOS 15.0, *) extension PurchaseHandler { - static func mock(customerInfo: CustomerInfo = TestData.customerInfo) -> Self { + static func mock(_ customerInfo: CustomerInfo = TestData.customerInfo) -> Self { return self.init( purchases: MockPurchases { _ in return ( diff --git a/Tests/RevenueCatUITests/Purchasing/PurchaseHandlerTests.swift b/Tests/RevenueCatUITests/Purchasing/PurchaseHandlerTests.swift index aced885f44..dbc8d0032c 100644 --- a/Tests/RevenueCatUITests/Purchasing/PurchaseHandlerTests.swift +++ b/Tests/RevenueCatUITests/Purchasing/PurchaseHandlerTests.swift @@ -66,7 +66,7 @@ class PurchaseHandlerTests: TestCase { } func testRestorePurchasesWithActiveSubscriptions() async throws { - let handler: PurchaseHandler = .mock(customerInfo: Self.customerInfoWithSubscriptions) + let handler: PurchaseHandler = .mock(Self.customerInfoWithSubscriptions) let result = try await handler.restorePurchases() expect(result.info) === Self.customerInfoWithSubscriptions @@ -74,7 +74,7 @@ class PurchaseHandlerTests: TestCase { } func testRestorePurchasesWithNonSubscriptions() async throws { - let handler: PurchaseHandler = .mock(customerInfo: Self.customerInfoWithNonSubscriptions) + let handler: PurchaseHandler = .mock(Self.customerInfoWithNonSubscriptions) let result = try await handler.restorePurchases() expect(result.info) === Self.customerInfoWithNonSubscriptions