Skip to content

PurchasesOrchestrator: disambiguate transactions from the queue#2890

Merged
NachoSoto merged 10 commits into
mainfrom
nacho/sdk-3243-422s-fix-bug-where-transactions-are-misidentified-as
Aug 7, 2023
Merged

PurchasesOrchestrator: disambiguate transactions from the queue#2890
NachoSoto merged 10 commits into
mainfrom
nacho/sdk-3243-422s-fix-bug-where-transactions-are-misidentified-as

Conversation

@NachoSoto

@NachoSoto NachoSoto commented Jul 26, 2023

Copy link
Copy Markdown
Contributor

This adds tests and a fix for a scenario discovered by @aboedo and @mcastany.

Timeline:

  • Make a purchase of product_1
  • Close app
  • Renewals happen in the background
  • Subscription expires
  • Open app, renewals are in the queue but not processed yet
  • Start a purchase of product_1 (SKPaymentQueue.add)
  • StoreKit queue shows renewal from step 3 as finished, it's expired
  • SDK sees finished transaction in the queue for product_1
  • SDK sees completion block for product_1, considers it a successful .purchase
  • SDK posts receipt, initiation_source: .purchase 💥
  • Backend replies with 422 because StoreKit hasn't processed the transaction from 6

Fix:

The fix looks at the date when the completion block was added (the time .purchase() is called).
If the transaction was created before this, we know it didn't come from the actual purchase, and therefore it should be identified as .queue.

@NachoSoto NachoSoto added the pr:fix A bug fix label Jul 26, 2023
@NachoSoto NachoSoto requested review from a team and mcastany July 26, 2023 09:54

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Logging this for awareness next time we have to debug these logs.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I added this for awareness, but we'll eventually need to deal with this and remove this log.
The docs make this clear:

The contents of this property are undefined except when transactionState is set to SKPaymentTransactionState.purchased or SKPaymentTransactionState.restored.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This helps debug these failures.

@NachoSoto NachoSoto force-pushed the nacho/sdk-3243-422s-fix-bug-where-transactions-are-misidentified-as branch from 63253e5 to 71ba0da Compare July 26, 2023 10:00
self.logger.clearMessages()

// 6. Purchase again
_ = try await self.purchases.purchase(package: self.monthlyPackage)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

❗ One thing I don't understand even after looking at the receipt posted: this call to purchase returns an inactive entitlement ❓
We should look into it either in this PR or in a separate test.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'll wait to talk to @aboedo about this cause I'm really confused.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yeah, that's unexpected - that's what this whole PR is meant to address.
well, actually...
I mean, purchase is going to return right after seeing one of the queue values return. like, this case is still true:

image

The problem isn't just that the initiation_source was marked incorrectly, it's that we're returning that value for the purchase, when in reality we should ignore transactions that don't have that date entirely.

I'll comment in the corresponding line

@mcastany mcastany requested a review from MarkVillacampa July 26, 2023 11:50
@NachoSoto NachoSoto force-pushed the nacho/sdk-3243-422s-fix-bug-where-transactions-are-misidentified-as branch from f1e9ecb to cc0f2f2 Compare July 27, 2023 10:31

@aboedo aboedo left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Left a few comments. Looking great! But we need to think about the case where we're calling completion too early. Maybe we can pair up on this tomorrow?

Comment thread Sources/Purchasing/Purchases/PurchasesOrchestrator.swift Outdated
Comment on lines -837 to +851
case (true, true): return .restore
case (false, _): return .queue
case (.some, true): return .restore

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

kinda feels like we're losing a little bit of readability in the transition from true -> some

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That's the only way to both check for it being nil and extract the value from it though. Any suggestion?

Comment on lines +936 to +951
callbacks[productIdentifier] = completion
callbacks[productIdentifier] = (completion, Date())

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

wonder if we'll want to end up passing a date provider for this for tests

self.logger.clearMessages()

// 6. Purchase again
_ = try await self.purchases.purchase(package: self.monthlyPackage)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yeah, that's unexpected - that's what this whole PR is meant to address.
well, actually...
I mean, purchase is going to return right after seeing one of the queue values return. like, this case is still true:

image

The problem isn't just that the initiation_source was marked incorrectly, it's that we're returning that value for the purchase, when in reality we should ignore transactions that don't have that date entirely.

I'll comment in the corresponding line

Comment thread Sources/Purchasing/Purchases/PurchasesOrchestrator.swift
@NachoSoto NachoSoto added the WIP label Jul 31, 2023
@NachoSoto NachoSoto marked this pull request as draft July 31, 2023 09:36
@NachoSoto NachoSoto force-pushed the nacho/sdk-3243-422s-fix-bug-where-transactions-are-misidentified-as branch from f7306ee to 24e53a3 Compare August 2, 2023 21:05
@NachoSoto NachoSoto marked this pull request as ready for review August 2, 2023 21:31
@NachoSoto NachoSoto removed the WIP label Aug 2, 2023
Comment on lines +27 to +30
// This matches the behavior of `SKPaymentTransaction`.
guard self.transactionState == .purchased || self.transactionState == .restored else {
return nil
}

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm glad I did this because this exposed how this is breaking some scenarios.

@codecov

codecov Bot commented Aug 3, 2023

Copy link
Copy Markdown

Codecov Report

Merging #2890 (aea4824) into main (cad8328) will increase coverage by 0.00%.
The diff coverage is 96.82%.

❗ Current head aea4824 differs from pull request most recent head c2e8da0. Consider uploading reports for the commit c2e8da0 to get more accurate results

@@           Coverage Diff           @@
##             main    #2890   +/-   ##
=======================================
  Coverage   86.63%   86.64%           
=======================================
  Files         217      217           
  Lines       15531    15564   +33     
=======================================
+ Hits        13456    13486   +30     
- Misses       2075     2078    +3     
Files Changed Coverage Δ
Sources/Misc/Deprecations.swift 38.12% <0.00%> (-0.28%) ⬇️
...s/LocalReceiptParsing/Helpers/ReceiptStrings.swift 84.28% <50.00%> (ø)
Sources/Logging/Strings/PurchaseStrings.swift 89.51% <100.00%> (+0.28%) ⬆️
...tworking/Operations/PostReceiptDataOperation.swift 92.76% <100.00%> (+0.14%) ⬆️
...s/Purchasing/Purchases/PurchasesOrchestrator.swift 87.84% <100.00%> (+0.40%) ⬆️
...ing/StoreKitAbstractions/SK1StoreTransaction.swift 100.00% <100.00%> (ø)
...ing/StoreKitAbstractions/SK2StoreTransaction.swift 100.00% <100.00%> (ø)
...hasing/StoreKitAbstractions/StoreTransaction.swift 87.27% <100.00%> (+0.73%) ⬆️

... and 5 files with indirect coverage changes

@NachoSoto

Copy link
Copy Markdown
Contributor Author

@aboedo all tests passing 🎉

@NachoSoto NachoSoto force-pushed the nacho/sdk-3243-422s-fix-bug-where-transactions-are-misidentified-as branch from 807253b to aea4824 Compare August 3, 2023 21:25

@joshdholtz joshdholtz left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A few small questions/nits but looks good to ship 👍

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Might be a nit but any reason why this is String instead of InitiationSource? 🤷‍♂️

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good suggestion, changed.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oh I know why: this is used by ReceiptParser too, which doesn't have that type

Comment on lines 977 to 984

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why does this not need to remove the callback? When would this callback get removed? 🤔

Just want to make sure its not there for forever 🤷‍♂️

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This case represents a situation where there's activity in the queue for an older, probably expired transaction for the same product.
A callback would only exist as a result of initiating a purchase. So the assumption here is that:
a) the message in the queue here was not for the purchase we initiated, but rather some old activity in the queue. This is why we check the transaction date.
b) the queue will eventually post a message for the purchase that we did initiate.

But we could write comments to make that very explicit here, it's delicate stuff

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done.

Comment on lines 977 to 984

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This case represents a situation where there's activity in the queue for an older, probably expired transaction for the same product.
A callback would only exist as a result of initiating a purchase. So the assumption here is that:
a) the message in the queue here was not for the purchase we initiated, but rather some old activity in the queue. This is why we check the transaction date.
b) the queue will eventually post a message for the purchase that we did initiate.

But we could write comments to make that very explicit here, it's delicate stuff

Comment on lines 464 to 465

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

shouldn't we eventually post the transaction from 6 as a purchase?

@NachoSoto NachoSoto Aug 7, 2023

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LOL yeah this is a bad refactor from me changing the test. clearMessages shouldn't be there.

@aboedo

aboedo commented Aug 7, 2023

Copy link
Copy Markdown
Member

looking great! left a question

This adds tests and a fix for a scenario discovered by @aboedo and @mcastany.

- Make a purchase of product_1
- Close app
- Renewals happen in the background
- Subscription expires
- Open app, renewals are in the queue but not processed yet
- Start a purchase of `product_1` (`SKPaymentQueue.add`)
- StoreKit queue shows renewal from step 3 as finished, it's expired
- SDK sees finished transaction in the queue for `product_1`
- SDK sees completion block for `product_1`, considers it a successful `.purchase`
- SDK posts receipt, `initiation_source: .purchase` 💥
- Backend replies with 422 because StoreKit hasn't processed the transaction from 6

The fix looks at the date when the completion block was added (the time `.purchase()` is called).
If the transaction was created _before_ this, we know it didn't come from the actual purchase, and therefore it should be identified as `.queue`.
@NachoSoto NachoSoto force-pushed the nacho/sdk-3243-422s-fix-bug-where-transactions-are-misidentified-as branch from aea4824 to 3dc2c5b Compare August 7, 2023 22:37
@NachoSoto NachoSoto force-pushed the nacho/sdk-3243-422s-fix-bug-where-transactions-are-misidentified-as branch from 3dc2c5b to c2e8da0 Compare August 7, 2023 22:40
@NachoSoto NachoSoto enabled auto-merge (squash) August 7, 2023 22:40
@NachoSoto NachoSoto merged commit 216f1e1 into main Aug 7, 2023
@NachoSoto NachoSoto deleted the nacho/sdk-3243-422s-fix-bug-where-transactions-are-misidentified-as branch August 7, 2023 22:57
return self.purchaseCompleteCallbacksByProductID.modify { callbacks -> PurchaseCompletedBlock? in
guard let value = callbacks[transaction.productIdentifier] else { return nil }

if !transaction.hasKnownPurchaseDate || value.creationDate <= transaction.purchaseDate {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I know I'm late here but maybe we could extract this condition to a function transactionDateMatchCallbackDate, transactionMatchesCallback or something like that? Just so it's easier to read

NachoSoto added a commit that referenced this pull request Aug 9, 2023
_This release is compatible with Xcode 15 beta 6 and visionOS beta 3_

### Bugfixes
* `visionOS`: support for `Xcode 15 beta 6` (#2989) via NachoSoto
(@NachoSoto)
* `CachingProductsManager`: avoid crash when caching different products
with same identifier (#2979) via NachoSoto (@NachoSoto)
* `PurchasesOrchestrator`: disambiguate transactions from the queue
(#2890) via NachoSoto (@NachoSoto)
### Performance Improvements
* `StoreKit2TransactionListener`: handle transactions asynchronously
(#2910) via NachoSoto (@NachoSoto)
### Other Changes
* `Atomic`: avoid race conditions modifying dictionaries (#2981) via
NachoSoto (@NachoSoto)
* `Logging`: avoid logging "updating offerings" when request is cached
(#2904) via NachoSoto (@NachoSoto)
* `StoreKit2TransactionListener`: converted into an `actor` (#2909) via
NachoSoto (@NachoSoto)
* `Integration Tests`: added more observer mode tests (#2905) via
NachoSoto (@NachoSoto)
* Created `PostedTransactionCache` (#2911) via NachoSoto (@NachoSoto)
* `IntroEligibility`: changed products to `Set<String>` (#2976) via
NachoSoto (@NachoSoto)
* `AdServices`: Rename `postAdServicesTokenIfNeeded()` to
`postAdServicesTokenOncePerInstallIfNeeded()` (#2968) via Josh Holtz
(@joshdholtz)
* `SK1StoreProduct`: changed `productType` warning to debug (#2957) via
NachoSoto (@NachoSoto)
* `PrivacyInfo.xcprivacy`: added `UserDefaults` to access API types
(#2913) via NachoSoto (@NachoSoto)
* `Integration Tests`: new test to verify that SK1 purchases don't leave
SK2 unfinished transactions (#2906) via NachoSoto (@NachoSoto)
* `Logging`: log entire cache key with verbose logs (#2888) via
NachoSoto (@NachoSoto)
* `StoreProduct`: added test covering warning log (#2897) via NachoSoto
(@NachoSoto)
* `CustomEntitlementComputation`: use custom API key (#2879) via Toni
Rico (@tonidero)
* `CachingProductsManager`: removed duplicate log and added tests
(#2898) via NachoSoto (@NachoSoto)
* `Xcode 15 beta 5`: fixed test compilation (#2885) via NachoSoto
(@NachoSoto)

---------

Co-authored-by: NachoSoto <ignaciosoto90@gmail.com>
NachoSoto added a commit that referenced this pull request Aug 16, 2023
… downgrades

Fixes #3020.
This essentially reverts #2890.

As shown in #3020, downgrades are reported with the same transaction identifier as the original transaction. Therefore our approach in #2890 is invalid.
We considered checking whether the `SKPayment` in the `SKPaymentTransaction` matches the one added to the queue as a way to disambiguate these transactions, but it doesn't. So we have no way of telling the difference between queue transactions and purchases.

I've left the original tests as a way to at least document this behavior.
NachoSoto added a commit that referenced this pull request Aug 16, 2023
… downgrades (#3028)

Fixes #3020.
This essentially reverts #2890.

As shown in #3020, downgrades are reported with the same transaction
identifier as the original transaction. Therefore our approach in #2890
is invalid.
We considered checking whether the `SKPayment` in the
`SKPaymentTransaction` matches the one added to the queue as a way to
disambiguate these transactions, but it doesn't. So we have no way of
telling the difference between queue transactions and purchases.

I've left the original tests as a way to at least document this
behavior.
MarkVillacampa pushed a commit that referenced this pull request Sep 6, 2023
)

This adds tests and a fix for a scenario discovered by @aboedo and
@mcastany.

### Timeline:
- Make a purchase of product_1
- Close app
- Renewals happen in the background
- Subscription expires
- Open app, renewals are in the queue but not processed yet
- Start a purchase of `product_1` (`SKPaymentQueue.add`)
- StoreKit queue shows renewal from step 3 as finished, it's expired
- SDK sees finished transaction in the queue for `product_1`
- SDK sees completion block for `product_1`, considers it a successful
`.purchase`
- SDK posts receipt, `initiation_source: .purchase` 💥
- Backend replies with 422 because StoreKit hasn't processed the
transaction from 6

### Fix:
The fix looks at the date when the completion block was added (the time
`.purchase()` is called).
If the transaction was created _before_ this, we know it didn't come
from the actual purchase, and therefore it should be identified as
`.queue`.
MarkVillacampa pushed a commit that referenced this pull request Sep 6, 2023
_This release is compatible with Xcode 15 beta 6 and visionOS beta 3_

### Bugfixes
* `visionOS`: support for `Xcode 15 beta 6` (#2989) via NachoSoto
(@NachoSoto)
* `CachingProductsManager`: avoid crash when caching different products
with same identifier (#2979) via NachoSoto (@NachoSoto)
* `PurchasesOrchestrator`: disambiguate transactions from the queue
(#2890) via NachoSoto (@NachoSoto)
### Performance Improvements
* `StoreKit2TransactionListener`: handle transactions asynchronously
(#2910) via NachoSoto (@NachoSoto)
### Other Changes
* `Atomic`: avoid race conditions modifying dictionaries (#2981) via
NachoSoto (@NachoSoto)
* `Logging`: avoid logging "updating offerings" when request is cached
(#2904) via NachoSoto (@NachoSoto)
* `StoreKit2TransactionListener`: converted into an `actor` (#2909) via
NachoSoto (@NachoSoto)
* `Integration Tests`: added more observer mode tests (#2905) via
NachoSoto (@NachoSoto)
* Created `PostedTransactionCache` (#2911) via NachoSoto (@NachoSoto)
* `IntroEligibility`: changed products to `Set<String>` (#2976) via
NachoSoto (@NachoSoto)
* `AdServices`: Rename `postAdServicesTokenIfNeeded()` to
`postAdServicesTokenOncePerInstallIfNeeded()` (#2968) via Josh Holtz
(@joshdholtz)
* `SK1StoreProduct`: changed `productType` warning to debug (#2957) via
NachoSoto (@NachoSoto)
* `PrivacyInfo.xcprivacy`: added `UserDefaults` to access API types
(#2913) via NachoSoto (@NachoSoto)
* `Integration Tests`: new test to verify that SK1 purchases don't leave
SK2 unfinished transactions (#2906) via NachoSoto (@NachoSoto)
* `Logging`: log entire cache key with verbose logs (#2888) via
NachoSoto (@NachoSoto)
* `StoreProduct`: added test covering warning log (#2897) via NachoSoto
(@NachoSoto)
* `CustomEntitlementComputation`: use custom API key (#2879) via Toni
Rico (@tonidero)
* `CachingProductsManager`: removed duplicate log and added tests
(#2898) via NachoSoto (@NachoSoto)
* `Xcode 15 beta 5`: fixed test compilation (#2885) via NachoSoto
(@NachoSoto)

---------

Co-authored-by: NachoSoto <ignaciosoto90@gmail.com>
MarkVillacampa pushed a commit that referenced this pull request Sep 6, 2023
… downgrades (#3028)

Fixes #3020.
This essentially reverts #2890.

As shown in #3020, downgrades are reported with the same transaction
identifier as the original transaction. Therefore our approach in #2890
is invalid.
We considered checking whether the `SKPayment` in the
`SKPaymentTransaction` matches the one added to the queue as a way to
disambiguate these transactions, but it doesn't. So we have no way of
telling the difference between queue transactions and purchases.

I've left the original tests as a way to at least document this
behavior.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants