Skip to content

Conversation

@tonidero
Copy link
Contributor

@tonidero tonidero commented Dec 31, 2025

Description

These are the main changes in this PR:

  • Now, when posting a receipt, if we find that there is any cached information we will include this information in that POST /receipt. This doesn't handle edge cases that may happen when the purchase is not found from the store anymore (will handle in follow-up PRs)
  • Include amazon store user id + marketplace in ReceiptInfo, so it can be cached and posted later in POST /receipt when needed.
  • Include original observer_mode in post receipt request body. The cached value will have priority over the current one.

…ity-of-2' into sdk-4188-puchases-android-improve-accuracy-and-reliability-of-3
@codecov
Copy link

codecov bot commented Jan 5, 2026

Codecov Report

❌ Patch coverage is 81.13208% with 10 lines in your changes missing coverage. Please review.
✅ Project coverage is 78.97%. Comparing base (204d052) to head (90ac987).

Files with missing lines Patch % Lines
...lin/com/revenuecat/purchases/common/ReceiptInfo.kt 68.18% 0 Missing and 7 partials ⚠️
...tlin/com/revenuecat/purchases/PostReceiptHelper.kt 84.21% 0 Missing and 3 partials ⚠️
Additional details and impacted files
@@                                         Coverage Diff                                         @@
##           sdk-4188-puchases-android-improve-accuracy-and-reliability-of-2    #2989      +/-   ##
===================================================================================================
- Coverage                                                            78.98%   78.97%   -0.02%     
===================================================================================================
  Files                                                                  341      341              
  Lines                                                                13449    13476      +27     
  Branches                                                              1804     1820      +16     
===================================================================================================
+ Hits                                                                 10623    10642      +19     
+ Misses                                                                2079     2078       -1     
- Partials                                                               747      756       +9     

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

…ity-of-2' of github.com:RevenueCat/purchases-android into sdk-4188-puchases-android-improve-accuracy-and-reliability-of-3
Copy link
Member

@JayShortway JayShortway left a comment

Choose a reason for hiding this comment

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

Awesomeee! Some minor comments and some philosophical ones 😄

return@let mapOf("revision" to it.revision, "rule_id" to it.ruleId)
},
"observer_mode" to !finishTransactions,
"observer_mode" to (originalObserverMode ?: !finishTransactions),
Copy link
Member

Choose a reason for hiding this comment

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

Should we even fall back to the current value? That makes the field ambiguous, as it can now mean either "observer mode at time of transaction" or "observer mode at time of posting". Do we even care about the latter? If yes, maybe we should make separate fields?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Right, I went back and forth on this (see commit 😅 ).

My thoughts in the end were, we should always use the cached value if we have any cached data. If we don't, we should use the current value, since we can assume the purchase was just performed and/or comes from the queue.

My main reason for not separating it is that we're already sending this information on a header for all requests, so maybe we don't really need to send it at all unless we have a cached value. However, not sending it would mean possible changes in the backend, so I decided to leave the current behavior if the purchase is not cached.

But yeah, this is indeed confusing, so might be worth sending this in a new field to avoid confusion.

Copy link
Member

Choose a reason for hiding this comment

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

Yea I think we should avoid confusion, for us and for the backend. It could even lead to some subtly incorrect attributions. Maybe we leave the header as-is and introduce a new field in the body. (I'd also be okay removing the header if the backend isn't reading it).

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I split this into a separate field: 3878eef

@antoniobg we will need to accept this new field in the backend. It's meaning will be the value of this field at the time when the purchase was made (which could have potentially changed if the value changed before the data was successfully posted)

Copy link
Contributor

Choose a reason for hiding this comment

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

If it's a new parameter, can we change the name that matches better the configuration, like purchase_completion_method or completion_method ?

Copy link
Contributor Author

@tonidero tonidero Jan 8, 2026

Choose a reason for hiding this comment

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

Indeed... what do you think of purchase_completed_by, with possible values being my_app and revenuecat? Note that the public API for this is going to be PurchasesAreCompletedBy.MY_APP or PurchasesAreCompletedBy.REVENUECAT. cc @RevenueCat/coresdk @antoniobg

Copy link
Contributor

Choose a reason for hiding this comment

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

Sounds good to me

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Ok renamed in 95af91f

Comment on lines 152 to 154
val presentedPaywall = paywallPresentedCache.getAndRemovePresentedEvent()
val effectivePaywallData = presentedPaywall?.toPaywallPostReceiptData()
?: cachedTransactionMetadata?.paywallPostReceiptData
Copy link
Member

Choose a reason for hiding this comment

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

Could this be a potential source of bugs if we're posting cached receipts while a paywall is showing? It might accidentally attribute the cached receipt to the currently showing paywall, if I understand correctly, right?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Hmm you're indeed right that this may potentially happen... and could also be happening right now I think, since the event storage mechanism is completely unattached to the posting of the receipt here, we don't know if it's related... We should think on how to fix this issue more permanently... Great catch!!

For this PR, I'm thinking we could potentially check, if there is a cached value for the given token we don't even need to call getAndRemovePresentedEvent above, to avoid missattributions later, but as mentioned, this wouldn't fix the existing issue I think.

Copy link
Member

@JayShortway JayShortway Jan 6, 2026

Choose a reason for hiding this comment

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

if there is a cached value for the given token we don't even need to call getAndRemovePresentedEvent above

True, but we should make sure the paywallPresentedCache doesn't contain stale data in any case.

An additional thing we could do is check if the presented paywall's Offering corresponds to the PresentedOfferingContext we have? Not airtight by any means, but it does allow us to eliminate obvious missattributions.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

we should make sure the paywallPresentedCache doesn't contain stale data in any case.

Right, I was checking and we basically should be clearing the cached paywallPresentedCache when:

  • Performing a successful POST /receipt.
  • When closing the paywall (or at least asked to be dismissed). This includes:
    • When navigating back with the back button
    • When clicking on close buttons with the navigate back action
    • When navigation to a web checkout url.

I believe this shouldn't leave cached paywall information around.

An additional thing we could do is check if the presented paywall's Offering corresponds to the PresentedOfferingContext we have? Not airtight by any means, but it does allow us to eliminate obvious missattributions.

I do think this is a valid check, but will likely do that in a separate PR, since it seems like a different thing than what we're trying to do in this PR

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Changed the order of operations in a7f7d22.

Will take note and do try to figure a more robust solution for the cached paywall events in a follow-up PR

tonidero and others added 5 commits January 7, 2026 17:15
…ity-of' into sdk-4188-puchases-android-improve-accuracy-and-reliability-of-2
…ity-of-2' of github.com:RevenueCat/purchases-android into sdk-4188-puchases-android-improve-accuracy-and-reliability-of-3
@tonidero tonidero requested a review from JayShortway January 8, 2026 09:04
Copy link

@rickvdl rickvdl left a comment

Choose a reason for hiding this comment

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

Awesome work!


val presentedPaywall = paywallPresentedCache.getAndRemovePresentedEvent()
val presentedPaywall = if (cachedTransactionMetadata == null) {
paywallPresentedCache.getAndRemovePresentedEvent()
Copy link

Choose a reason for hiding this comment

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

Should we also remove the in-memory cached value if we don't use it (if we don't call getAndRemovePresentedEvent()) to prevent it from being used in the next (non-paywall?) purchase?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

We shouldn't, since it's possible the paywall is still displayed while we're syncing purchases in the background. We should only clear it in the same situations we were clearing it before (upon a successful purchase and/or when closing the paywall)

} else {
null
}
val effectivePaywallData = cachedTransactionMetadata?.paywallPostReceiptData
Copy link

Choose a reason for hiding this comment

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

Did we decide on keying the in-memory cached presentedPaywall by product id / transaction ID or similar? In order to avoid mixing this data with an unrelated transaction? Not sure if it's a realistic scenario or only theoretical one, but what if a purchase comes in from the queue while this in-memory value is set?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Note that the cachedTransactionMetadata's paywall is indeed keyed by the purchase token hash. As for the in memory event (that came from before these PRs), it's indeed a problem I'm thinking about how to solve... It normally shouldn't be an issue since we clear the in memory cache when closing the paywall, but there are edge cases that are not correctly handled, like when making a purchase when PurchasesAreCompletedBy.MY_APP or if for any reason presenting multiple paywalls at once.

@tonidero tonidero requested a review from a team January 8, 2026 13:23
Copy link
Member

@JayShortway JayShortway left a comment

Choose a reason for hiding this comment

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

Good stuff!

return@let mapOf("revision" to it.revision, "rule_id" to it.ruleId)
},
"observer_mode" to !finishTransactions,
"purchase_completed_by" to purchasesAreCompletedBy.name.lowercase(),
Copy link
Member

Choose a reason for hiding this comment

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

❤️

Comment on lines +92 to +95
* Merges this [ReceiptInfo] with another [ReceiptInfo], giving precedence to the values in this
* instance when there are conflicts.
*/
fun mergeWith(receiptInfo: ReceiptInfo): ReceiptInfo {
Copy link
Member

Choose a reason for hiding this comment

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

🫶

@tonidero tonidero force-pushed the sdk-4188-puchases-android-improve-accuracy-and-reliability-of-2 branch from 544e8d2 to d7fff68 Compare January 8, 2026 14:53
…ity-of-2' of github.com:RevenueCat/purchases-android into sdk-4188-puchases-android-improve-accuracy-and-reliability-of-3
Copy link

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

This PR improves transaction origin accuracy by implementing cached data merging when posting receipts and adding Amazon-specific fields to ReceiptInfo. The changes enable better handling of transaction metadata that may have been cached during the purchase flow.

Key Changes

  • Added storeUserID and marketplace fields to ReceiptInfo to support Amazon store data
  • Implemented mergeWith() method in ReceiptInfo to intelligently combine current and cached receipt information
  • Replaced observerMode with purchasesAreCompletedBy enum in LocalTransactionMetadata for more explicit transaction completion tracking
  • Modified PostReceiptHelper to merge cached transaction metadata (including receipt info, paywall data, and purchasesAreCompletedBy) when posting receipts

Reviewed changes

Copilot reviewed 15 out of 15 changed files in this pull request and generated 3 comments.

Show a summary per file
File Description
ReceiptInfo.kt Added Amazon fields (storeUserID, marketplace) and implemented mergeWith logic for combining current and cached data
Backend.kt Refactored postReceiptData to use ReceiptInfo fields directly and added purchasesAreCompletedBy parameter
PostReceiptHelper.kt Enhanced to retrieve and merge cached transaction metadata when posting receipts
LocalTransactionMetadata.kt Replaced observerMode boolean with purchasesAreCompletedBy enum for clearer semantics
AppConfig.kt Made purchasesAreCompletedBy publicly accessible
SyncPurchasesHelper.kt Updated to include storeUserID and marketplace in ReceiptInfo
PurchasesOrchestrator.kt Updated to include storeUserID and marketplace in ReceiptInfo for Amazon purchases
Test files Comprehensive test updates and additions covering new merge functionality and cached data scenarios

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

"applied_targeting_rule" to receiptInfo.presentedOfferingContext?.targetingContext?.let {
return@let mapOf("revision" to it.revision, "rule_id" to it.ruleId)
},
"observer_mode" to !finishTransactions,
Copy link

Copilot AI Jan 8, 2026

Choose a reason for hiding this comment

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

Both observer_mode and purchase_completed_by are being sent in the request body. This appears to be redundant since they represent similar information. Consider whether both fields are needed or if observer_mode should be removed in favor of purchase_completed_by.

Suggested change
"observer_mode" to !finishTransactions,

Copilot uses AI. Check for mistakes.
isRestore.toString(),
finishTransactions.toString(),
subscriberAttributes.toString(),
receiptInfo.toString(),
Copy link

Copilot AI Jan 8, 2026

Choose a reason for hiding this comment

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

The cache key for deduplicating postReceiptData calls doesn't include purchasesAreCompletedBy. This could lead to incorrect request deduplication when the same receipt is posted with different purchasesAreCompletedBy values. Consider adding purchasesAreCompletedBy.toString() to the cacheKey list.

Suggested change
receiptInfo.toString(),
receiptInfo.toString(),
purchasesAreCompletedBy.toString(),

Copilot uses AI. Check for mistakes.
Comment on lines +39 to +40
@SerialName("purchases_are_completed_by")
val purchasesAreCompletedBy: PurchasesAreCompletedBy,
Copy link

Copilot AI Jan 8, 2026

Choose a reason for hiding this comment

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

The serialization field name is changed from observer_mode to purchases_are_completed_by, but the field type has also changed from nullable Boolean to non-nullable PurchasesAreCompletedBy enum. This is a breaking change for persisted data. Any previously cached transactions with observer_mode will fail to deserialize. Consider adding a migration strategy or using a custom deserializer to handle backward compatibility.

Copilot uses AI. Check for mistakes.
}
val effectivePaywallData = cachedTransactionMetadata?.paywallPostReceiptData
?: presentedPaywall?.toPaywallPostReceiptData()
val effectiveReceiptInfo = cachedTransactionMetadata?.receiptInfo?.let { receiptInfo.mergeWith(it) }
Copy link
Member

Choose a reason for hiding this comment

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

I wonder, if the result of the mergeWith actually results in changes, shouldn't we update the cached metadata with the merged information? Otherwise, a failure in the POST /receipt could result in data loss?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

6 participants