PurchasesOrchestrator: replaced calls to syncPurchases with posting receipt for an individual product during SK2 purchases#1666
Conversation
There was a problem hiding this comment.
This was duplicated below, so it's now in a shared local function above.
59f6c28 to
7facf7d
Compare
02270d6 to
45366d4
Compare
|
Unit tests pass, but looking into integration tests now (glad to have those since this is a critical change). |
|
This simple test fails 🎉 looking into why func testCanMakePurchase() async throws {
try await self.purchaseMonthlyOffering()
} |
|
Integration tests fixed :) This is ready |
There was a problem hiding this comment.
This is an async wrapper on top of self.handlePurchasedTransaction.
There was a problem hiding this comment.
This avoids calling the delegate during purchases (leading to a duplicate syncPurchases call). Now this is only called for transactions that come from StoreKit.Transaction.updates
741347d to
4882022
Compare
|
I wanna add a new unit test to make sure we always pass product data |
|
just to clarify, in the PR description it says
But my understanding is that we were sending two posts, right? one with and one without the product details? |
There was a problem hiding this comment.
when does this else case happen? i.e.: when do we not have a transaction?
There was a problem hiding this comment.
Yeah this is kind of hard to follow because we convert from Product.PurchaseResult (an enum with 3 cases) to a tuple of Optionals. There would be no transaction in the userCancelled or pending cases. This is also related to CSDK-113, it'll change with that fix.
There was a problem hiding this comment.
sounds good. I think this is another case where we should add some comments since it'll be confusing to folks without context
There was a problem hiding this comment.
don't we already know whether this is a cancellation from the lines before this?
There was a problem hiding this comment.
The line before catches StoreKitError.userCancelled. This comes from Product.PurchaseResult.userCancelled. It's unclear when StoreKit would throw one versus returning the other, but we need to handle both.
There was a problem hiding this comment.
could we write that up as a comment here? Otherwise it won't be apparent to new devs looking at this code
There was a problem hiding this comment.
🤔 why do we have onlyIfEmpty in this case? If we're calling this method, we know that there was a purchase, right? I feel like we should always refresh in that case, there's a good chance that the receipt isn't up to date otherwise
There was a problem hiding this comment.
I added that default value to keep the existing behavior to the previous callers.
Looks like there is only one place: storeKitWrapper(:updatedTransaction:). I suppose it makes sense for that one to use .always as well?
There was a problem hiding this comment.
I think so - if anything is coming in from StoreKit it should be safe to assume that there's an apple ID logged into the device
There was a problem hiding this comment.
I'll do this in a separate PR if that's ok to minimize the changes in behavior / tests in this PR, so if something breaks it's easier to revert one versus the other.
There was a problem hiding this comment.
If the productIdentifier is empty, it means we got a bad SKTransaction.
Current behavior is to just not send it to the backend.
But...
At this point, the user got charged. They won't have access, and they'll get an error. That's a terrible experience. Sure, it's because of a bug in StoreKit, but it's a bug in the price, and the only value of fetching the product here is to send the prices to our backend. So the user gets a bad experience but we're not preventing any issues on their side, this is just for accounting purposes.
The user should come first. If they made a purchase, and it went through, we should prioritize granting them access. Getting the products comes second.
This is illustrated in the current flow by the fact that we don't check whether we actually could fetch the product - there's no guarantee that we will (the request could fail) but we don't care, we just send the receipt.
We should do the same for the case where the SKTransaction is broken. The receipt should still be fine, so even if the transaction is broken, we can send the transaction to the backend, and get the customerInfo, and preserve user experience, in exchange for slightly worse analytics when SKTransactions are broken.
It's not like the customer would be able to fix this by retrying either, they'll already have purchased the product
There was a problem hiding this comment.
So I'd suggest to:
- Log when the transaction is bad, that'll help us debug issues with prices
- Always refresh and send the receipt, even if the transaction was bad
There was a problem hiding this comment.
For context, this PR doesn't change the existing behavior. The reason I changed this to notEmpty is because of this (StoreTransactionType.productIdentifier is not Optional) whereas previously the nil check did that.
Log when the transaction is bad, that'll help us debug issues with prices
That's always done by SKPaymentTransaction.productIdentifier.
Always refresh and send the receipt, even if the transaction was bad
I think that makes sense. So to make sure I understand, basically change the else to:
self.postReceipt(withTransaction: transaction,
receiptData: receiptData,
products: [], // No product data, but still post the receipt
storefront: storefront)I can add unit tests for this too.
There was a problem hiding this comment.
Huh I see, there's just no test coverage for it.
It is an edge case after all, but I'll add a test.
There was a problem hiding this comment.
Turns out this breaks during tests, because we rely on product identifiers for the callbacks keys...
There was a problem hiding this comment.
I'm gonna leave this for a separate PR to deal with this problem.
There was a problem hiding this comment.
the RTTI here feels a little weird... why are we special casing SK1? SK2 transactions have a finish method too
There was a problem hiding this comment.
Yeah I agree this is messy. The reason is SK2 transactions are finished by StoreKit2TransactionListener, so I wanted to keep the existing behavior here. Even though this method uses any StoreTransaction, I didn't want to call that twice on SK2 transactions :/
But looking at this now, maybe we can remove the finish call in StoreKit2TransactionListener and call it here for consistency?
There was a problem hiding this comment.
I think that's probably a good call, although it doesn't need to be a part of this PR
There was a problem hiding this comment.
Okay let me make a Jira for it then.
There was a problem hiding this comment.
https://revenuecats.atlassian.net/browse/CSDK-122
This is ready for a final look then :)
|
Thanks for the amazingly thorough review @aboedo! This is a change in a very critical part of the code, so I really appreciate the close look. |
@aboedo correct, we were posting receipts twice. But no, none of them contained the product details, because in both cases they came from calls to |
…ng receipt for an individual product during SK2 purchases Fixes [SDKONCALL-19]. SK2 purchases were always calling `syncPurchases`, which called `Backend.post(receiptData:...)` with no `productData`, which meant that after every SK2 purchase we never posted the price. This refactor makes a lot of methods in `PurchasesOrchestrator` use `StoreTransaction` so they can be used during SK2 purchases as well. Specifically, `handlePurchasedTransaction` is now called after both SK1 and SK2 purchases. - `StoreKit2TransactionListenerDelegate` no longer needs to return `CustomerInfo` - `StoreKit2TransactionListenerDelegate.handle(purchaseResult:)` no longer notifies the delegate (only done by `StoreKit.Transaction.updates`), which was leading to duplicate `syncPurchases` calls. - If the purchase is cancelled, `syncPurchases` isn't called. Instead, the `CustomerInfo` is fetched from `CustomerInfoManager`
4882022 to
0b209d2
Compare
aboedo
left a comment
There was a problem hiding this comment.
looks great! I left a couple of minor comments
There was a problem hiding this comment.
could we write that up as a comment here? Otherwise it won't be apparent to new devs looking at this code
There was a problem hiding this comment.
sounds good. I think this is another case where we should add some comments since it'll be confusing to folks without context
| _ = try await orchestrator.purchase(sk2Product: product, promotionalOffer: nil) | ||
|
|
||
| expect(self.backend.invokedPostReceiptDataCount) == 1 | ||
| expect(self.backend.invokedPostReceiptDataParameters?.productData).toNot(beNil()) |
There was a problem hiding this comment.
I added these checks in several of the tests too.
_This release is compatible with Xcode 14 beta 1_ ### Bug fixes * `EntitlementInfo.isActive` returns true if `requestDate == expirationDate` (#1684) via beylmk (@beylmk) * Fixed usages of `seealso` (#1689) via NachoSoto (@NachoSoto) * Fixed `ROT13.string` thread-safety (#1686) via NachoSoto (@NachoSoto) * `PurchasesOrchestrator`: replaced calls to `syncPurchases` with posting receipt for an individual product during SK2 purchases (#1666) via NachoSoto (@NachoSoto)
…ceipt data Fixes [CSDK-120]. See conversation: #1666 (comment) > why do we have `onlyIfEmpty` in this case? If we're calling this method, we know that there was a purchase, right? I feel like we should always refresh in that case, there's a good chance that the receipt isn't up to date otherwise
…ceipt data Fixes [CSDK-120]. See conversation: #1666 (comment) > why do we have `onlyIfEmpty` in this case? If we're calling this method, we know that there was a purchase, right? I feel like we should always refresh in that case, there's a good chance that the receipt isn't up to date otherwise
…ceipt data (#1703) Fixes [CSDK-120]. See conversation: #1666 (comment) > why do we have `onlyIfEmpty` in this case? If we're calling this method, we know that there was a purchase, right? I feel like we should always refresh in that case, there's a good chance that the receipt isn't up to date otherwise



Fixes SDKONCALL-19.
SK2 purchases were always calling
syncPurchases, which calledBackend.post(receiptData:...)with noproductData, which meant that after every SK2 purchase we never posted the price.This refactor makes a lot of methods in
PurchasesOrchestratoruseStoreTransactionso they can be used during SK2 purchases as well. Specifically,handlePurchasedTransactionis now called after both SK1 and SK2 purchases.Other changes:
StoreKit2TransactionListenerDelegateno longer needs to returnCustomerInfoStoreKit2TransactionListenerDelegate.handle(purchaseResult:)no longer notifies the delegate (only done byStoreKit.Transaction.updates), which was leading to duplicatesyncPurchasescalls.syncPurchasesisn't called. Instead, theCustomerInfois fetched fromCustomerInfoManager