GetCustomerInfo posts receipts if there are pending transactions#2533
Conversation
While working on #2533, I knew that my proof of concept had a retain cycle. I however was very confused when it wasn't detected by this mechanism introduced first introduced in #2104. Turns out that this had been wrong the whole time: the current implementation, as explained by the comment, was executing those blocks in LIFO order, which meant that the references were being set to `nil` before the assertions could be created. This meant that by the time the `except { [weak purchases = self.purchases] ... }` lines were called, `self.purchases` itself was `nil`, so nothing was being checked. This simplifies the implementation by inlining the checks, and saving a `weak` reference before clearing the singleton and all the local references. Luckily there was only one failing test that had to do with how `Purchases` was being created, and implementation details of `expectFatalError`. To avoid this, I changed the test to use the `Purchases.init` method directly.
06af69b to
77ddcc8
Compare
…gate` transaction handling While working on #2533 I noticed that the implementation for `StoreKit2TransactionListenerDelegate` was unnecessarily complex: ```swift @available(iOS 15.0, tvOS 15.0, macOS 12.0, watchOS 8.0, *) extension PurchasesOrchestrator: StoreKit2TransactionListenerDelegate { func storeKit2TransactionListener( _ listener: StoreKit2TransactionListener, updatedTransaction transaction: StoreTransactionType ) async throws { let isRestore = self.systemInfo.observerMode _ = try await self.syncPurchases(receiptRefreshPolicy: .always, isRestore: isRestore, initiationSource: .queue) await Async.call { completion in self.finishTransactionIfNeeded(transaction) { @mainactor in completion(()) } } } } ``` Instead of only posting the one transaction, we were calling `syncPurchases`, which is inconsistent with how we handle SK1 transactions. The new logic in #2533 will also make use of this single-transaction handling code, so it makes sense to unify this as well. This only required an overload to specify the `InitiationSource`, which uncovered one incorrect `isRestore` parameter in one of our tests. I verified that this is expected based on other tests, and the implementation of `initiationSource(for productIdentifier:restored:)`, as well as checking with @antoniobg.
While working on #2533, I knew that my proof of concept had a retain cycle. I however was very confused when it wasn't detected by this mechanism introduced first introduced in #2104. Turns out that this had been wrong the whole time: the current implementation, as explained by the comment, was executing those blocks in LIFO order, which meant that the references were being set to `nil` before the assertions could be created. This meant that by the time the `except { [weak purchases = self.purchases] ... }` lines were called, `self.purchases` itself was `nil`, so nothing was being checked. This simplifies the implementation by inlining the checks, and saving a `weak` reference before clearing the singleton and all the local references. Luckily there was only one failing test that had to do with how `Purchases` was being created, and implementation details of `expectFatalError`. To avoid this, I changed the test to use the `Purchases.init` method directly.
Offline Entitlements: don't compute offline CustomerInfo when purchasing a consumable products
#2522
973d424 to
65182be
Compare
65182be to
e00ce02
Compare
2e46a23 to
e689b27
Compare
e00ce02 to
72d979a
Compare
4fb32c2 to
9a6dba6
Compare
Codecov Report
@@ Coverage Diff @@
## transaction-poster-3 #2533 +/- ##
=======================================================
Coverage ? 87.96%
=======================================================
Files ? 201
Lines ? 13931
Branches ? 0
=======================================================
Hits ? 12254
Misses ? 1677
Partials ? 0 |
This improves an upcoming log from #2533: ``` DEBUG: ℹ️ Found unfinished transaction, will post receipt in lieu of fetching CustomerInfo: <StoreTransaction: identifier="9CB3E6CE-A6F6-41EC-B25D-FE0CCE5783F6" product="" date="2023-05-26 21:07:55 +0000" quantity=1 > ```
Necessary refactor for #2533. This allows `CustomerInfoManager` to have an instance of `TransactionPosterType` instead of the whole `PurchasesOrchestrator`. Essentially this becomes the hierarchy now:  ### Changes: - Created stateless `TransactionPoster` - Extracted `handlePurchasedTransaction` into `TransactionPoster` - The new implementation there is _mostly_ the same, with the one difference that it's stateless. Instead of holding `purchaseCompleteCallbacksByProductID`, that's done by `PurchasesOrchestrator`. The methods there all take a callback, so they're easier to use and don't require keeping a callback around. - Created `PurchaseSource` to abstract the combo of `isRestore` and `InitiationSource`. This is still messy using the deprecated `allowSharingAppStoreAccount`, but at least that's abstracted out and the new `TransactionPoster` won't deal with that deprecated property. - I didn't mock `TransactionPoster`, which means that all existing tests that check `PurchasesOrchestrator` continue working the same way. This was on purpose to keep this refactor as simple as possible. - This supersedes #2536: the change made there was now possible here without any integration test failures, thanks to the fact that we can process transactions independently without messing with the `purchaseCompleteCallbacksByProductID` state now. ### Future improvements This is just a start, there's a lot more than can be done to simplify the `PurchasesOrchestrator` monster. The main thing would be to remove all the custom code for `restorePurchases`, which is largely the same as what's in `TransactionPoster` now. ### Notes: Just posting this here for posteriority. I had to trace the call hierarchy for both SK1 and SK2. This "block" is what's now in `TransactionPoster`: 
34de8f5 to
55a39c0
Compare
07fef8b to
619d960
Compare
55a39c0 to
397edda
Compare
All these tests were calling the method without waiting for `completion`. This breaks things in an upcoming PR (#2533) because it's no longer synchronous, but also this avoids leaving things running behind when the tests are done.
… to finish All these tests were calling the method without waiting for `completion`. This breaks things in an upcoming PR (#2533) because it's no longer synchronous, but also this avoids leaving things running behind when the tests are done.
619d960 to
7c3168c
Compare
tonidero
left a comment
There was a problem hiding this comment.
Just a question but looks great! I think we should also implement this behavior in Android soon
| private static let sourceForUnfinishedTransaction: PurchaseSource = .init( | ||
| isRestore: false, | ||
| // This might have been in theory a `.purchase`. The only downside of this is that the server | ||
| // won't validate that the product is present in the receipt. |
There was a problem hiding this comment.
Hmm can you explain more in what situation this transaction could originate from a purchase? If we are in the middle of posting the transaction after a purchase, this should wait for that I guess?
There was a problem hiding this comment.
- Server offline
- Purchase fails
- Compute customer info (but transaction still in queue)
- Next time you launch the app, server is back up
- Pending transaction -> post receipt
Technically that was a .purchase when it was done originally, but we're sending it as .queue now.
There was a problem hiding this comment.
Ahh right, that would also happen currently if a POST receipt fails and it's retried later on right? But yeah, that makes sense 👍
… parameters (#2542) Follow up to #2540. This was necessary to ensure that we can implement #2533 without reference cycles. This was the hierarchy before this change:  The red arrows are what this PR removes. ## Changes: - `TransactionPoster` no longer has references to `IdentityManager` and `Attribution` - `markSyncedIfNeeded` is no longer in `TransactionPoster`, moved back to `PurchasesOrchestrator` - Changed `TransactionPoster`'s callback to return `Result<CustomerInfo, BackendError>`, providing greater flexibility for users to be able to read the specific errors. - Extracted `PurchasedTransactionData` to be reduce the number of parameters passed around. This removed the need for 2 `swiftlint:disable:next function_parameter_count`. This is the majority of this diff. It was helpful to combine in this PR because I needed to add another parameter to the flow: `unsyncedAttributes`, since `TransactionPoster` could no longer read these directly.
7c3168c to
0a505fc
Compare
**This is an automatic release.** ### New Features * `Offline Entitlements`: use offline-computed `CustomerInfo` when server is down (#2368) via NachoSoto (@NachoSoto) ### Bugfixes * `AppleReceipt.debugDescription`: don't pretty-print JSON (#2564) via NachoSoto (@NachoSoto) * `SK2StoreProduct`: fix crash on iOS 12 (#2565) via NachoSoto (@NachoSoto) * `GetCustomerInfo` posts receipts if there are pending transactions (#2533) via NachoSoto (@NachoSoto) ### Performance Improvements * `PurchasedProductsFetcher`: cache current entitlements (#2507) via NachoSoto (@NachoSoto) * Performance: new check to ensure serialization / deserialization is done from background thread (#2496) via NachoSoto (@NachoSoto) ### Dependency Updates * Bump fastlane from 2.212.2 to 2.213.0 (#2544) via dependabot[bot] (@dependabot[bot]) ### Other Changes * `CustomerInfoManager`: post all unfinished transactions (#2563) via NachoSoto (@NachoSoto) * `PostReceiptOperation`: added ability to also post `AdServices` token (#2566) via NachoSoto (@NachoSoto) * `Offline Entitlements`: improved computation log (#2562) via NachoSoto (@NachoSoto) * Added `TransactionPoster` tests (#2557) via NachoSoto (@NachoSoto) * Refactored `TransactionPoster`: removed 2 dependencies and abstracted parameters (#2542) via NachoSoto (@NachoSoto) * `CustomerInfoManagerTests`: wait for `getAndCacheCustomerInfo` to finish (#2555) via NachoSoto (@NachoSoto) * `StoreTransaction`: implemented `description` (#2556) via NachoSoto (@NachoSoto) * `Backend.ResponseHandler` is now `@Sendable` (#2541) via NachoSoto (@NachoSoto) * Extracted `TransactionPoster` from `PurchasesOrchestrator` (#2540) via NachoSoto (@NachoSoto) * `enableAdServicesAttributionTokenCollection`: added integration test (#2551) via NachoSoto (@NachoSoto) * `AttributionPoster`: replaced hardcoded strings with constants (#2548) via NachoSoto (@NachoSoto) * `DefaultDecodable`: moved to `Misc/Codable/DefaultDecodable.swift` (#2528) via NachoSoto (@NachoSoto) * `CircleCI`: specify device to run `backend_integration_tests` (#2547) via NachoSoto (@NachoSoto) * Created `StoreKit2TransactionFetcher` (#2539) via NachoSoto (@NachoSoto) * Fix load shedder integration tests (#2546) via Josh Holtz (@joshdholtz) * Fix doc on `Offering.getMetadataValue` (#2545) via Josh Holtz (@joshdholtz) * Extracted and tested `AsyncSequence.extractValues` (#2538) via NachoSoto (@NachoSoto) * `Offline Entitlements`: don't compute offline `CustomerInfo` when purchasing a consumable products (#2522) via NachoSoto (@NachoSoto) * `OfflineEntitlementsManager`: disable offline `CustomerInfo` in observer mode (#2520) via NachoSoto (@NachoSoto) * `BasePurchasesTests`: fixed leak detection (#2534) via NachoSoto (@NachoSoto) * `PurchaseTesterSwiftUI`: added `ProxyView` to `iOS` (#2531) via NachoSoto (@NachoSoto) * `PurchasedProductsFetcher`: removed `AppStore.sync` call (#2521) via NachoSoto (@NachoSoto) * `PurchaseTesterSwiftUI`: added new window on Mac to manage proxy (#2518) via NachoSoto (@NachoSoto) * `PurchasedProductsFetcher`: added log if fetching purchased products is slow (#2515) via NachoSoto (@NachoSoto) * `Offline Entitlements`: disable for custom entitlements mode (#2509) via NachoSoto (@NachoSoto) * `Offline Entitlements`: fixed iOS 12 tests (#2514) via NachoSoto (@NachoSoto) * `PurchasedProductsFetcher`: don't throw errors if purchased products were found (#2506) via NachoSoto (@NachoSoto) * `Offline Entitlements`: allow creating offline `CustomerInfo` with empty `ProductEntitlementMapping` (#2504) via NachoSoto (@NachoSoto) * `Offline Entitlements`: integration tests (#2501) via NachoSoto (@NachoSoto) * `CustomerInfoManager`: don't cache offline `CustomerInfo` (#2378) via NachoSoto (@NachoSoto) * `DangerousSettings`: debug-only `forceServerErrors` (#2486) via NachoSoto (@NachoSoto) * `CocoapodsInstallation`: fixed `Xcode 14.3.0` issue (#2489) via NachoSoto (@NachoSoto) * `CarthageInstallation`: removed workaround (#2488) via NachoSoto (@NachoSoto) --------- Co-authored-by: NachoSoto <ignaciosoto90@gmail.com>
Similar to #2786 and #2777. This test was verifying `getCustomerInfoCallCount`, but that gets updated asynchronously since #2533. The correct version waits for it to be updated, then ensures that it doesn't get updated again after logging out. Fixes https://app.circleci.com/pipelines/github/RevenueCat/purchases-ios/12762/workflows/a7e8c4ac-cecb-4c25-90ff-4390ed226bc4/jobs/88794
This fixes the last edge-case for offline entitlements. Solves the following scenario:
This race condition happened when fetching
CustomerInfoafter we had computed an offlineCustomerInfowith a purchase: if the server is still not aware of the purchase, then we lose that until some arbitrary amount of time whenStoreKitmight decide to send the pending transactions in the queue.For that reason, instead of waiting, we proactively check now whenever
CustomerInfoManagerfetches new info.The refactors #2540 and #2542 where required to break a retain cycle.
Also thanks to #2534 we know for sure that there aren't any cycles.
Note that this is an iOS 15.0+ only feature. It would be useful to better ensure consistency in older versions, but it's not strictly required, as those versions don't support offline entitlements either.