Skip to content

Cache isAutoRenewing to detect subscription changes without syncPurchases#3198

Merged
facumenzella merged 17 commits into
mainfrom
facundo/cc-cache-auto-renewing-status
Mar 13, 2026
Merged

Cache isAutoRenewing to detect subscription changes without syncPurchases#3198
facumenzella merged 17 commits into
mainfrom
facundo/cc-cache-auto-renewing-status

Conversation

@facumenzella

@facumenzella facumenzella commented Mar 9, 2026

Copy link
Copy Markdown
Member

Summary

  • Caches isAutoRenewing status per hashed token in DeviceCache (new SharedPreferences key)
  • PostPendingTransactionsHelper now detects when isAutoRenewing changes (e.g., user cancels/resubscribes via Play Store) and reposts only those tokens
  • This avoids needing syncPurchases to detect external subscription changes, which is problematic because it posts all purchases with RESTORE initiation source and can transfer purchases between users

How it works

  1. After queryPurchases(), the helper compares each token's current isAutoRenewing with the cached value
  2. Tokens with changed status are included in the sync alongside any new (unposted) tokens
  3. After detecting changes, the new statuses are saved to cache for next comparison

Context: #3152 (comment)

Test plan

  • Unit tests for DeviceCache.getPurchasesWithAutoRenewingChange (5 tests: no cache, true→false, false→true, unchanged, not in sent cache)
  • Unit tests for DeviceCache.saveAutoRenewingStatus (2 tests: saves JSON, skips null)
  • Unit tests for PostPendingTransactionsHelper auto-renewing detection (4 tests: changed synced, combined with new purchases, no changes, status saved)
  • Manual test: cancel subscription in Play Store, return to app, verify cancellation detected without syncPurchases

🤖 Generated with Claude Code


Note

Medium Risk
Touches core purchase token caching and pending-transaction sync behavior, including a SharedPreferences migration; bugs here could cause missed or repeated posts of active subscriptions.

Overview
Adds a new unified token cache in DeviceCache that stores hashed posted tokens plus per-token metadata (currently isAutoRenewing) in a JSON map, with in-memory caching and migration from the legacy StringSet key.

Updates pending purchase syncing to repost only tokens whose isAutoRenewing changed (in addition to newly-seen tokens), saving unchanged tokens’ auto-renewing status eagerly and updating changed tokens only after a successful post.

Threads isAutoRenewing through receipt posting and billing wrappers (Google, Amazon, Galaxy) by extending addSuccessfullyPostedToken / postTokenWithoutConsuming, and expands unit tests to cover migration, change detection, and the new caching call sites.

Written by Cursor Bugbot for commit 563fc97. This will update automatically on new commits. Configure here.

@tonidero tonidero left a comment

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.

Mostly a question on the implementation, but yeah, this is what I was thinking! 🫶

Comment thread purchases/src/main/kotlin/com/revenuecat/purchases/common/caching/DeviceCache.kt Outdated

@tonidero tonidero left a comment

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.

Would love your thoughts on this @RevenueCat/catforms

@facumenzella facumenzella requested a review from tonidero March 9, 2026 14:47
@facumenzella facumenzella marked this pull request as ready for review March 9, 2026 14:47
@facumenzella facumenzella requested a review from a team as a code owner March 9, 2026 14:47

@cursor cursor Bot left a comment

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.

Cursor Bugbot has reviewed your changes and found 1 potential issue.

Autofix Details

Bugbot Autofix prepared a fix for the issue found in the latest run.

  • ✅ Fixed: Auto-renewing status saved before post may lose failed retries
    • Auto-renewing status is now persisted only for transactions not being posted immediately and per-transaction after successful post, so failed posts keep old cached status and remain retryable.

Create PR

Or push these changes by commenting:

@cursor push d8ca9457a4
Preview (d8ca9457a4)
diff --git a/purchases/src/main/kotlin/com/revenuecat/purchases/PostPendingTransactionsHelper.kt b/purchases/src/main/kotlin/com/revenuecat/purchases/PostPendingTransactionsHelper.kt
--- a/purchases/src/main/kotlin/com/revenuecat/purchases/PostPendingTransactionsHelper.kt
+++ b/purchases/src/main/kotlin/com/revenuecat/purchases/PostPendingTransactionsHelper.kt
@@ -6,6 +6,7 @@
 import com.revenuecat.purchases.common.LogIntent
 import com.revenuecat.purchases.common.caching.DeviceCache
 import com.revenuecat.purchases.common.log
+import com.revenuecat.purchases.common.sha1
 import com.revenuecat.purchases.identity.IdentityManager
 import com.revenuecat.purchases.models.PurchaseState
 import com.revenuecat.purchases.models.StoreTransaction
@@ -57,10 +58,16 @@
                     val autoRenewingChanged = deviceCache.getPurchasesWithAutoRenewingChange(
                         purchasesByHashedToken,
                     )
-                    deviceCache.saveAutoRenewingStatus(purchasesByHashedToken)
                     val transactionsToSync = (newPurchases + autoRenewingChanged).distinctBy {
                         it.purchaseToken
                     }
+                    val transactionsToSyncTokens = transactionsToSync.map { it.purchaseToken }.toSet()
+                    val purchasesNotBeingSynced = purchasesByHashedToken.filterValues { transaction ->
+                        transaction.purchaseToken !in transactionsToSyncTokens
+                    }
+                    if (purchasesNotBeingSynced.isNotEmpty()) {
+                        deviceCache.saveAutoRenewingStatus(purchasesNotBeingSynced)
+                    }
                     val pendingTransactionsTokens = purchasesByHashedToken.values
                         .filter { it.purchaseState == PurchaseState.PENDING }
                         .map { it.purchaseToken }
@@ -119,6 +126,13 @@
                                 },
                             )
                         },
+                        onTransactionSuccess = { transaction ->
+                            deviceCache.saveAutoRenewingStatus(
+                                mapOf(
+                                    transaction.purchaseToken.sha1() to transaction,
+                                ),
+                            )
+                        },
                     )
                 },
                 onError = { error ->
@@ -137,6 +151,7 @@
         onNoTransactionsToSync: (() -> Unit),
         onError: ((PurchasesError) -> Unit),
         onSuccess: ((CustomerInfo) -> Unit),
+        onTransactionSuccess: ((StoreTransaction) -> Unit) = {},
     ) {
         if (transactionsToSync.isEmpty()) {
             log(LogIntent.DEBUG) { PurchaseStrings.NO_PENDING_PURCHASES_TO_SYNC }
@@ -149,7 +164,8 @@
                 appUserID,
                 PostReceiptInitiationSource.UNSYNCED_ACTIVE_PURCHASES,
                 sdkOriginated = false,
-                transactionPostSuccess = { _, customerInfo ->
+                transactionPostSuccess = { transaction, customerInfo ->
+                    onTransactionSuccess(transaction)
                     results.add(Result.Success(customerInfo))
                     callCompletionFromResults(transactionsToSync, results, onError, onSuccess)
                 },

diff --git a/purchases/src/test/java/com/revenuecat/purchases/PostPendingTransactionsHelperTest.kt b/purchases/src/test/java/com/revenuecat/purchases/PostPendingTransactionsHelperTest.kt
--- a/purchases/src/test/java/com/revenuecat/purchases/PostPendingTransactionsHelperTest.kt
+++ b/purchases/src/test/java/com/revenuecat/purchases/PostPendingTransactionsHelperTest.kt
@@ -363,12 +363,9 @@
                 transactionPostError = any()
             )
         } answers {
-            transactions?.let {
-                it.forEach { transaction ->
-                    lambda<SuccessfulPurchaseCallback>().captured.invoke(transaction, customerInfo!!)
-                }
-            } ?: run {
-                lambda<SuccessfulPurchaseCallback>().captured.invoke(mockk(), customerInfo!!)
+            val capturedTransactions = firstArg<List<StoreTransaction>>()
+            capturedTransactions.forEach { transaction ->
+                lambda<SuccessfulPurchaseCallback>().captured.invoke(transaction, customerInfo!!)
             }
         }
     }
@@ -398,7 +395,7 @@
             deviceCache.getPurchasesWithAutoRenewingChange(purchasesByHashedToken)
         } returns autoRenewingChanged
         every {
-            deviceCache.saveAutoRenewingStatus(purchasesByHashedToken)
+            deviceCache.saveAutoRenewingStatus(any())
         } just Runs
 
         every {
@@ -871,5 +868,43 @@
         }
     }
 
+    @Test
+    fun `when auto-renewing status change sync fails, status is not saved`() {
+        val purchase = stubGooglePurchase(
+            purchaseToken = "token",
+            productIds = listOf("product"),
+            purchaseState = Purchase.PurchaseState.PURCHASED,
+        )
+        val transaction = purchase.toStoreTransaction(ProductType.SUBS)
+        val purchasesByHash = mapOf(purchase.purchaseToken.sha1() to transaction)
+
+        mockSuccessfulQueryPurchases(
+            purchasesByHashedToken = purchasesByHash,
+            notInCache = emptyList(),
+            autoRenewingChanged = listOf(transaction),
+        )
+
+        val error = PurchasesError(PurchasesErrorCode.StoreProblemError, "Broken")
+        every {
+            postTransactionWithProductDetailsHelper.postTransactions(
+                transactions = listOf(transaction),
+                allowSharingPlayStoreAccount = allowSharingPlayStoreAccount,
+                appUserID = appUserId,
+                initiationSource = initiationSource,
+                sdkOriginated = false,
+                transactionPostSuccess = any(),
+                transactionPostError = captureLambda(),
+            )
+        } answers {
+            lambda<ErrorPurchaseCallback>().captured.invoke(transaction, error)
+        }
+
+        syncAndAssertResult(SyncPendingPurchaseResult.Error(error))
+
+        verify(exactly = 0) {
+            deviceCache.saveAutoRenewingStatus(any())
+        }
+    }
+
     // endregion
 }
This Bugbot Autofix run was free. To enable autofix for future PRs, go to the Cursor dashboard.

@facumenzella facumenzella requested a review from vegaro March 9, 2026 14:50
@codecov

codecov Bot commented Mar 9, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 91.20879% with 8 lines in your changes missing coverage. Please review.
✅ Project coverage is 79.41%. Comparing base (dec91ef) to head (563fc97).
⚠️ Report is 2 commits behind head on main.

Files with missing lines Patch % Lines
...revenuecat/purchases/common/caching/DeviceCache.kt 90.76% 4 Missing and 2 partials ⚠️
...lin/com/revenuecat/purchases/amazon/AmazonCache.kt 0.00% 2 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main    #3198      +/-   ##
==========================================
+ Coverage   79.34%   79.41%   +0.06%     
==========================================
  Files         356      356              
  Lines       14276    14342      +66     
  Branches     1945     1958      +13     
==========================================
+ Hits        11327    11389      +62     
- Misses       2144     2149       +5     
+ Partials      805      804       -1     

☔ 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.

@tonidero tonidero left a comment

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.

A couple of comments. The most important one I think would be to try to keep the data storage and retrieval as a single operation, to avoid possible issues.

Comment thread purchases/src/main/kotlin/com/revenuecat/purchases/common/caching/DeviceCache.kt Outdated
}

@Synchronized
fun addSuccessfullyPostedToken(token: String) {

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 think ideally we would save the autorenewing status in one go, instead of first storing it empty and adding the value later...

If we separate it, it's more likely that we miss something IMO... I understand this might require some refactors but I think ideally we just keep most of the outside logic the same it was before, just that the new "token" is the entry of the map that contains information for the purchase. If anything changes, in either the key or value, it would be considered a different, new purchase.

Happy to chat about this if you think otherwise though!

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Done in 47533e2fc. addSuccessfullyPostedToken now accepts an isAutoRenewing parameter, and BillingWrapper.consumeAndSave passes purchase.isAutoRenewing so the value is stored when the token is first added. Callers without a StoreTransaction (e.g. PostReceiptHelper.postTokenWithoutProductInfo) default to null.

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.

Actually, I think we should always store it with a value... If we don't, in some cases, like with postTokenWithoutConsuming, it means that token will be posted twice, one the first time and another on next app foreground, when it detects a non-null value. I think this should always have a value? Happy to chat if we find any scenario where this is not true though.

We should also do this for the Amazon store as well, otherwise we could be running into the issue we're trying to avoid in #3152 but for the amazon store only.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Done in dd668a757. isAutoRenewing is now threaded through all token-saving paths:

  • postTokenWithoutConsuming accepts isAutoRenewing param, passed from SyncPurchasesHelper (which has the StoreTransaction)
  • AmazonBilling.consumeAndSave passes purchase.isAutoRenewing to AmazonCache
  • BillingWrapper.consumeAndSave already passes it (from previous commit)

The only caller that still defaults to null is PurchasesOrchestrator's Amazon receipt path, which only has a raw receipt ID string without a StoreTransaction.

@emerge-tools

emerge-tools Bot commented Mar 9, 2026

Copy link
Copy Markdown

📸 Snapshot Test

571 unchanged

Name Added Removed Modified Renamed Unchanged Errored Approval
TestPurchasesUIAndroidCompatibility
com.revenuecat.testpurchasesuiandroidcompatibility
0 0 0 0 314 0 N/A
TestPurchasesUIAndroidCompatibility Paparazzi
com.revenuecat.testpurchasesuiandroidcompatibility.paparazzi
0 0 0 0 257 0 N/A

🛸 Powered by Emerge Tools

facumenzella and others added 6 commits March 10, 2026 17:33
…ncPurchases

When users manage subscriptions outside the app (e.g., cancelling via Play Store),
the SDK now detects changes to isAutoRenewing by comparing the current value from
queryPurchases with a cached value. Changed tokens are automatically reposted,
avoiding the need for a full syncPurchases (which posts everything with RESTORE
initiation source and can transfer purchases between users).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- Use getJSONObjectOrNull instead of duplicating JSON parse + try/catch
- Use putString helper instead of raw preferences.edit()
- Replace triple map pass + unchecked cast with single-pass buildMap via JSONObject.apply

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Replaces the legacy StringSet (`$apiKey.tokens`) with a single JSON map
(`$apiKey.tokensV2`) that stores both "which tokens have been posted" (keys)
and their isAutoRenewing status (values).

Migration: on first read, if the legacy StringSet exists, entries are
converted to the new format with null auto-renewing values (unknown),
then the legacy key is deleted. Null values mean the auto-renewing status
hasn't been observed yet — they get populated on the next syncPendingPurchaseQueue.

This eliminates the two-sources-of-truth issue where the token set and
auto-renewing map could drift apart.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Fixes a bug where saveAutoRenewingStatus was called eagerly before
posting, so if the post failed, the cache already reflected the new
value and the change would never be retried.

Now: unchanged tokens' auto-renewing status is saved eagerly (safe
since they're not being reposted). Changed tokens' status is saved
per-transaction only on successful post via updateAutoRenewingStatus.
If the post fails, the old cached value is preserved and the change
will be detected again on the next sync cycle.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Address review feedback:
- Token cache values are now JSON objects ({ "isAutoRenewing": true })
  instead of flat booleans, allowing future extensibility without migration.
- addSuccessfullyPostedToken now accepts isAutoRenewing parameter so the
  value is stored when the token is first added (via BillingWrapper.consumeAndSave).
- Backwards compatibility: getTokenMap reads both old flat boolean format
  and new nested format.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Address Toni's feedback to always store isAutoRenewing when saving a
token. Thread the value through:
- postTokenWithoutConsuming: new isAutoRenewing parameter, passed from
  SyncPurchasesHelper (which has the StoreTransaction)
- AmazonBilling.consumeAndSave: pass purchase.isAutoRenewing to
  AmazonCache.addSuccessfullyPostedToken
- AmazonCache: forward isAutoRenewing to DeviceCache

Callers without a StoreTransaction (e.g. PurchasesOrchestrator Amazon
receipt path) still default to null.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@facumenzella facumenzella force-pushed the facundo/cc-cache-auto-renewing-status branch from dd668a7 to 1e52b3c Compare March 10, 2026 16:33
@facumenzella facumenzella requested a review from tonidero March 11, 2026 10:44
Prevents null transaction isAutoRenewing from being incorrectly
flagged as a change when compared against a non-null cached value.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

@tonidero tonidero left a comment

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.

Getting there, just some more comments :)

val value = jsonObject.get(key)
if (value is JSONObject) {
TokenCacheEntry(
isAutoRenewing = if (value.isNull(KEY_IS_AUTO_RENEWING)) {

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.

Instead of writing this parsing from/to TokenCacheEntry manually, could be worth making the TokenCacheEntry @Serializable, so we get that kinda for free? I guess we would then need to store the value as a string instead of a map... But that might be ok?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Done in d6a72d9. TokenCacheEntry is now @serializable. getTokenMap/saveTokenMap use Json.decodeFromString/encodeToString with a MapSerializer. Legacy formats (org.json objects, flat booleans) are still readable via a fallback parser.

}

@Synchronized
fun addSuccessfullyPostedToken(token: String, isAutoRenewing: Boolean? = null) {

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'm just wondering if we should make isAutoRenewing non-optional to try to avoid us missing setting the value... I think almost all the time, we should actually have a value, except maybe when posting an amazon purchase when PurchasesAreCompletedBy.MY_APP? In which case, we can pass null then? Lmk if I miss something though!

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

isAutoRenewing is now always explicitly passed in all paths (Google consume/acknowledge, Amazon consume, SyncPurchasesHelper, PostReceiptHelper). The only path that passes null is PurchasesOrchestrator's Amazon receipt posting which only has a raw receipt ID without a StoreTransaction. Keeping the parameter as Boolean? with a default of null to handle that edge case, but all other callers are now explicit.

* after a successful post, so we don't eagerly save before knowing the post succeeded.
*/
@Synchronized
fun updateAutoRenewingStatus(token: String, isAutoRenewing: Boolean?) {

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.

Hmm so I'm wondering if we need to differentiate between updateAutoRenewingStatus and addSuccessfullyPostedToken... Seems they are pretty similar and could be unified?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Done in d6a72d9. Unified into addSuccessfullyPostedToken which is now an upsert: adds new tokens, and updates isAutoRenewing for existing ones (only when the new value is non-null and different). Removed updateAutoRenewingStatus.

- Make TokenCacheEntry @serializable, use kotlinx serialization for the
  token map instead of manual JSONObject parsing/writing
- Merge updateAutoRenewingStatus into addSuccessfullyPostedToken as an
  upsert: adds new tokens, updates isAutoRenewing for existing ones
- Legacy JSON formats (flat boolean values, org.json objects) still
  readable via fallback parser for backwards compatibility

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
facumenzella and others added 2 commits March 11, 2026 15:10
… with null

When a transaction has null isAutoRenewing, skip updating the cached
entry to preserve the previously known value for change detection.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@facumenzella

Copy link
Copy Markdown
Member Author

@tonidero can I get a second look? 🙏

facumenzella and others added 2 commits March 13, 2026 13:17
- Change from internal to @InternalRevenueCatAPI public to allow
  cross-module access from :feature:galaxy
- Pass isAutoRenewing=true in galaxy calls (Galaxy only supports subs)
- Replace method reference with lambda to match new signature
- Update galaxy tests accordingly

@cursor cursor Bot left a comment

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.

Cursor Bugbot has reviewed your changes and found 1 potential issue.

Fix All in Cursor

Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

) {
if (!finishTransactions || purchase.type == ProductType.UNKNOWN) {
deviceCache.addSuccessfullyPostedToken(purchase.purchaseToken)
deviceCache.addSuccessfullyPostedToken(purchase.purchaseToken, isAutoRenewing = true)

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.

Galaxy hardcodes isAutoRenewing instead of using actual value

Medium Severity

GalaxyBillingWrapper.consumeAndSave hardcodes isAutoRenewing = true in both token-caching paths, instead of using purchase.isAutoRenewing like the Google and Amazon wrappers do. This caches an incorrect value when purchase.type == ProductType.UNKNOWN (where isAutoRenewing is false per StoreTransactionConversions), which could cause the auto-renewing change detection to incorrectly flag these tokens for re-sync on the next query cycle.

Additional Locations (1)
Fix in Cursor Fix in Web

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.

This is fine for now since the galaxy integration only supports subscriptions. We should probably add a comment here explaining that, either here or in a follow-up PR

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.

Added comment in 57fa999

@tonidero tonidero Mar 16, 2026

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.

Actually I was rethinking this... Even if it only supports subscriptions, do we know whether the subscription is set to autorenew or not for galaxy subscriptions? This is what this property is meant for and would be great to actually use this for galaxy subscriptions, so we get updated CustomerCenter when a user cancels @fire-at-will

@tonidero tonidero left a comment

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.

A few optimization comments, but nothing really blocking. Thanks for doing this!

@Synchronized
internal fun getPreviouslySentHashedTokens(): Set<String> {
private fun getTokenMap(): Map<String, TokenCacheEntry> {
val json = preferences.getString(tokensCacheKey, null)

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 do see this serialization/deserialization happens quite a bit... I don't expect it to be too long, but I wonder if we should keep an in-memory copy of this and use that at least for fetches, so we avoid deserializing multiple times? We should be careful of it getting out of sync, but I don't think it should be too hard IMO.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Addressed in 563fc97. Added an in-memory cache (tokenMapCache) — populated on first read from SharedPreferences, updated on writes via saveTokenMap. Subsequent reads are served from memory without re-deserializing. Added 4 tests covering cache hit on repeated reads and cache updates from addSuccessfullyPostedToken, cleanPreviouslySentTokens, and saveAutoRenewingStatus.

current[hash] = existing.copy(isAutoRenewing = transaction.isAutoRenewing)
}
}
saveTokenMap(current)

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.

This saves even if there are no changes correct? Maybe we can skip it if not needed?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Addressed in 563fc97. saveAutoRenewingStatus now tracks whether any entry actually changed and skips the saveTokenMap call if nothing was modified.

* Parses the legacy JSON format where values were either `{ "isAutoRenewing": true }` objects
* using org.json, or flat `Boolean?` values (pre-serializable format).
*/
private fun parseLegacyTokenMapJson(json: String): Map<String, TokenCacheEntry> {

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.

Hmm is this needed? Like, none of these changes actually shipped right?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Addressed in 563fc97. Removed parseLegacyTokenMapJson and the flat boolean format handling — those intermediate formats never shipped, so we only need the StringSet migration + the current @Serializable format.

…ng, skip no-op saves

- Add in-memory cache for token map to avoid repeated JSON deserialization
- Remove parseLegacyTokenMapJson (intermediate formats never shipped)
- Skip saveAutoRenewingStatus write when nothing changed
- Add comment explaining hardcoded isAutoRenewing=true in Galaxy wrapper

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@facumenzella facumenzella added this pull request to the merge queue Mar 13, 2026
Merged via the queue into main with commit 054abeb Mar 13, 2026
35 checks passed
@facumenzella facumenzella deleted the facundo/cc-cache-auto-renewing-status branch March 13, 2026 14:48
facumenzella added a commit that referenced this pull request Mar 16, 2026
…PendingTransactionsHelper

PR #3198 added an unconditional addSuccessfullyPostedToken call in
transactionPostSuccess to persist isAutoRenewing status for auto-renewing
change detection. However, this also fired for new purchases, which:

1. Broke integration tests that use a relaxed mock for BillingAbstract
   (consumeAndSave is a no-op), prematurely marking tokens as posted
   during SDK init so later syncs couldn't find pending purchases.
2. Changed production retry behavior: tokens that got a 500 (handled via
   offline entitlements) were marked as posted and never retried.

Now addSuccessfullyPostedToken only fires for tokens reposted due to an
auto-renewing status change. New purchases rely on billing.consumeAndSave
to cache the token, as before #3198.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
val changedTokenHashes = autoRenewingChanged.map { it.purchaseToken.sha1() }
.toSet()
val unchangedTokens = purchasesByHashedToken.minus(changedTokenHashes)
deviceCache.saveAutoRenewingStatus(unchangedTokens)

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.

One thing is whether this is needed at all... As in, it's basically trying to save tokens that haven't changed. And the method doesn't (and shouldn't) add new tokens to the cache yet... So seems like code that won't do anything?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

You're right — removed in 6080deb on #3239. Since #3198 hasn't been released, no users have tokens migrated from the legacy StringSet cache with isAutoRenewing = null (the only case where this would do something). For tokens already cached with isAutoRenewing set, unchanged means same value, so it was always a no-op.

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.

Just for posterity, we ended up bringing this back because it's needed for users with purchases in the legacy StringSet cache, so we set a value so we can get updates to that value later. Sorry for the confusion!

facumenzella added a commit that referenced this pull request Mar 16, 2026
Since #3198 hasn't been released, no users have tokens migrated from
the legacy StringSet cache with isAutoRenewing = null. The call to
saveAutoRenewingStatus(unchangedTokens) was only useful for that
migration case — for tokens already cached with isAutoRenewing set,
it's a no-op by definition (unchanged means same value).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
facumenzella added a commit that referenced this pull request Mar 16, 2026
The legacy StringSet cache predates #3198 — users upgrading from any
older SDK version will have tokens with isAutoRenewing = null. This
call populates that value on the first sync so auto-renewing change
detection works for pre-existing subscriptions.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
github-merge-queue Bot pushed a commit that referenced this pull request Mar 16, 2026
…ctionsHelper (#3239)

## Summary

[#3198](#3198) added
an unconditional `addSuccessfullyPostedToken` call in
`PostPendingTransactionsHelper.transactionPostSuccess` to persist the
`isAutoRenewing` status for auto-renewing change detection. However,
this call was redundant — `billing.consumeAndSave` already calls
`addSuccessfullyPostedToken(token, isAutoRenewing)` on the success path
before `transactionPostSuccess` fires. The extra call caused two issues:

### 1. Integration test failures

The tests
`entersOfflineEntitlementsModeIfCachedCustomerInfoAndPostingPendingPurchasesReturns500`
and `postsPurchasePerformedOnFallbackURLWhenRecoveringToMainServer` use
a relaxed mock for `BillingAbstract` where `consumeAndSave` is a no-op.
The unconditional `addSuccessfullyPostedToken` prematurely marked tokens
as posted during SDK init, so subsequent syncs found no pending
purchases and couldn't enter offline entitlements mode.

### 2. Production retry behavior change

Tokens that received a 500 error (handled via offline entitlements) were
marked as posted and never retried on the next sync. This means the
backend could permanently miss a purchase.

### Fix

Removed the `addSuccessfullyPostedToken` call from
`transactionPostSuccess` entirely. Token caching (including
`isAutoRenewing`) is already handled by `billing.consumeAndSave` on the
success path. On the error path (500 + offline entitlements),
`consumeAndSave` is intentionally not called, so the token stays
uncached for retry on the next sync. All auto-renewing change detection
functionality from #3198 is preserved.

## Test plan

- [x] Existing `PostPendingTransactionsHelperTest` auto-renewing tests
pass
- [x] New test: `synced transactions do not call
addSuccessfullyPostedToken directly`
- [x] `DeviceCacheTest` passes
- [ ]
`entersOfflineEntitlementsModeIfCachedCustomerInfoAndPostingPendingPurchasesReturns500`
passes
- [ ] `postsPurchasePerformedOnFallbackURLWhenRecoveringToMainServer`
passes

🤖 Generated with [Claude Code](https://claude.com/claude-code)

<!-- CURSOR_SUMMARY -->
---

> [!NOTE]
> **Medium Risk**
> Touches purchase sync caching behavior by removing an eager
`addSuccessfullyPostedToken` call; incorrect behavior could affect
retry/sync reliability, but change is small and covered by updated
tests.
> 
> **Overview**
> Removes the `deviceCache.addSuccessfullyPostedToken` side effect from
`PostPendingTransactionsHelper`’s `transactionPostSuccess`, relying on
`billing.consumeAndSave` to persist posted tokens and `isAutoRenewing`
metadata.
> 
> Clarifies the intent of `saveAutoRenewingStatus` for legacy-migrated
tokens and updates tests to assert `addSuccessfullyPostedToken` is *not*
called directly for either auto-renewing-change resyncs or newly-synced
purchases.
> 
> <sup>Written by [Cursor
Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit
b38bab1. This will update automatically
on new commits. Configure
[here](https://cursor.com/dashboard?tab=bugbot).</sup>
<!-- /CURSOR_SUMMARY -->

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
github-merge-queue Bot pushed a commit that referenced this pull request Mar 16, 2026
**This is an automatic release.**

## RevenueCat SDK
### 🐞 Bugfixes
* Fix & Standardize Galaxy Date Parsing Edge Cases (#3216) via Will
Taylor (@fire-at-will)
* Fix addSuccessfullyPostedToken for new purchases in
PostPendingTransactionsHelper (#3239) via Facundo Menzella
(@facumenzella)
* [Galaxy]: Fix race condition when fetching Galaxy products (#3213) via
Will Taylor (@fire-at-will)
* Fixes double padding in PaywallActivity on Android 15+ when
`edgeToEdge` parameter is false (#3227) via Cesar de la Vega (@vegaro)

## RevenueCatUI SDK
### 🐞 Bugfixes
* Fix bold text not rendering in Markdown lists (#3228) via Cesar de la
Vega (@vegaro)
* Fix: Clear in-memory offerings cache on locale override to prevent
stale paywall data (#3225) via Antonio Pallares (@ajpallares)
### Paywallv2
#### ✨ New Features
* Feature: Update default paywall (#3133) via Jacob Rakidzich
(@JZDesign)
#### 🐞 Bugfixes
* Fix V2 paywall safe area in landscape mode (#3221) via Cesar de la
Vega (@vegaro)

### 🔄 Other Changes
* Run integration tests on all branches (#3242) via Toni Rico
(@tonidero)
* Migrate Firebase Test Lab jobs to CircleCI emulators (#3238) via Toni
Rico (@tonidero)
* Run metalava on galaxy module in test-galaxy job (#3235) via Will
Taylor (@fire-at-will)
* Add offering_id to custom paywall impression event (#3230) via Rick
(@rickvdl)
* Cache isAutoRenewing to detect subscription changes without
syncPurchases (#3198) via Facundo Menzella (@facumenzella)
* Bump fastlane-plugin-revenuecat_internal from `e146447` to `3e8c384`
(#3233) via dependabot[bot] (@dependabot[bot])

<!-- CURSOR_SUMMARY -->
---

> [!NOTE]
> **Low Risk**
> Low risk release housekeeping: version string bumps and
documentation/deployment path updates with no functional runtime logic
changes beyond the exposed version constant.
> 
> **Overview**
> Publishes the `9.26.0` release by removing `-SNAPSHOT` across
build/version metadata (root `VERSION_NAME`, `.version`,
`Config.frameworkVersion`, and sample/test app dependency pins).
> 
> Updates release documentation artifacts by adding the `9.26.0` notes
to `CHANGELOG.md`/`CHANGELOG.latest.md`, switching docs deployment in
CircleCI to sync `docs/9.26.0` to S3, and updating `docs/index.html` to
redirect to the new version.
> 
> <sup>Written by [Cursor
Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit
0a30a45. This will update automatically
on new commits. Configure
[here](https://cursor.com/dashboard?tab=bugbot).</sup>
<!-- /CURSOR_SUMMARY -->
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.

3 participants