Skip to content

Commit 2575d15

Browse files
facumenzellaclaude
andcommitted
Only save auto-renewing status for changed tokens on post success
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>
1 parent b25aebd commit 2575d15

File tree

4 files changed

+133
-5
lines changed

4 files changed

+133
-5
lines changed

purchases/src/main/kotlin/com/revenuecat/purchases/PostPendingTransactionsHelper.kt

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import com.revenuecat.purchases.common.Dispatcher
66
import com.revenuecat.purchases.common.LogIntent
77
import com.revenuecat.purchases.common.caching.DeviceCache
88
import com.revenuecat.purchases.common.log
9+
import com.revenuecat.purchases.common.sha1
910
import com.revenuecat.purchases.identity.IdentityManager
1011
import com.revenuecat.purchases.models.PurchaseState
1112
import com.revenuecat.purchases.models.StoreTransaction
@@ -57,7 +58,13 @@ internal class PostPendingTransactionsHelper(
5758
val autoRenewingChanged = deviceCache.getPurchasesWithAutoRenewingChange(
5859
purchasesByHashedToken,
5960
)
60-
deviceCache.saveAutoRenewingStatus(purchasesByHashedToken)
61+
// Save auto-renewing status only for tokens NOT being re-synced due to a
62+
// change. Changed tokens' status is saved per-transaction on post success,
63+
// so a failed post preserves the old cached value for retry on next sync.
64+
val changedTokenHashes = autoRenewingChanged.map { it.purchaseToken.sha1() }
65+
.toSet()
66+
val unchangedTokens = purchasesByHashedToken.minus(changedTokenHashes)
67+
deviceCache.saveAutoRenewingStatus(unchangedTokens)
6168
val transactionsToSync = (newPurchases + autoRenewingChanged).distinctBy {
6269
it.purchaseToken
6370
}
@@ -149,7 +156,11 @@ internal class PostPendingTransactionsHelper(
149156
appUserID,
150157
PostReceiptInitiationSource.UNSYNCED_ACTIVE_PURCHASES,
151158
sdkOriginated = false,
152-
transactionPostSuccess = { _, customerInfo ->
159+
transactionPostSuccess = { transaction, customerInfo ->
160+
deviceCache.updateAutoRenewingStatus(
161+
transaction.purchaseToken,
162+
transaction.isAutoRenewing,
163+
)
153164
results.add(Result.Success(customerInfo))
154165
callCompletionFromResults(transactionsToSync, results, onError, onSuccess)
155166
},

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

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -531,6 +531,20 @@ internal open class DeviceCache(
531531
saveTokenMap(current)
532532
}
533533

534+
/**
535+
* Updates the cached auto-renewing status for a single token. Used to persist the status
536+
* after a successful post, so we don't eagerly save before knowing the post succeeded.
537+
*/
538+
@Synchronized
539+
fun updateAutoRenewingStatus(token: String, isAutoRenewing: Boolean?) {
540+
val hashedToken = token.sha1()
541+
val current = getTokenMap().toMutableMap()
542+
if (hashedToken in current) {
543+
current[hashedToken] = isAutoRenewing
544+
saveTokenMap(current)
545+
}
546+
}
547+
534548
// endregion
535549

536550
// region offerings response

purchases/src/test/java/com/revenuecat/purchases/PostPendingTransactionsHelperTest.kt

Lines changed: 82 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,8 @@ class PostPendingTransactionsHelperTest {
6666
lambda<() -> Unit>().captured.invoke()
6767
}
6868

69+
every { deviceCache.updateAutoRenewingStatus(any(), any()) } just Runs
70+
6971
changeBillingConnected()
7072
changeAutoSyncEnabled(true)
7173

@@ -368,7 +370,10 @@ class PostPendingTransactionsHelperTest {
368370
lambda<SuccessfulPurchaseCallback>().captured.invoke(transaction, customerInfo!!)
369371
}
370372
} ?: run {
371-
lambda<SuccessfulPurchaseCallback>().captured.invoke(mockk(), customerInfo!!)
373+
lambda<SuccessfulPurchaseCallback>().captured.invoke(
374+
mockk(relaxed = true),
375+
customerInfo!!,
376+
)
372377
}
373378
}
374379
}
@@ -398,7 +403,7 @@ class PostPendingTransactionsHelperTest {
398403
deviceCache.getPurchasesWithAutoRenewingChange(purchasesByHashedToken)
399404
} returns autoRenewingChanged
400405
every {
401-
deviceCache.saveAutoRenewingStatus(purchasesByHashedToken)
406+
deviceCache.saveAutoRenewingStatus(any())
402407
} just Runs
403408

404409
every {
@@ -849,7 +854,7 @@ class PostPendingTransactionsHelperTest {
849854
}
850855

851856
@Test
852-
fun `auto-renewing status is saved after detecting changes`() {
857+
fun `auto-renewing status is saved for unchanged tokens`() {
853858
val purchase = stubGooglePurchase(
854859
purchaseToken = "token",
855860
productIds = listOf("product"),
@@ -871,5 +876,79 @@ class PostPendingTransactionsHelperTest {
871876
}
872877
}
873878

879+
@Test
880+
fun `auto-renewing status is not eagerly saved for changed tokens`() {
881+
val purchase = stubGooglePurchase(
882+
purchaseToken = "token",
883+
productIds = listOf("product"),
884+
purchaseState = Purchase.PurchaseState.PURCHASED,
885+
)
886+
val transaction = purchase.toStoreTransaction(ProductType.SUBS)
887+
val purchasesByHash = mapOf(purchase.purchaseToken.sha1() to transaction)
888+
889+
mockSuccessfulQueryPurchases(
890+
purchasesByHashedToken = purchasesByHash,
891+
notInCache = emptyList(),
892+
autoRenewingChanged = listOf(transaction),
893+
)
894+
895+
val customerInfoMock = mockk<CustomerInfo>()
896+
mockPostTransactionsSuccessful(customerInfoMock, listOf(transaction))
897+
898+
postPendingTransactionsHelper.syncPendingPurchaseQueue(allowSharingPlayStoreAccount)
899+
900+
// Changed tokens are excluded from the eager save
901+
verify(exactly = 1) {
902+
deviceCache.saveAutoRenewingStatus(emptyMap())
903+
}
904+
// Instead, auto-renewing is saved per-transaction on post success
905+
verify(exactly = 1) {
906+
deviceCache.updateAutoRenewingStatus(transaction.purchaseToken, transaction.isAutoRenewing)
907+
}
908+
}
909+
910+
@Test
911+
fun `failed post of changed auto-renewing does not update cache`() {
912+
val purchase = stubGooglePurchase(
913+
purchaseToken = "token",
914+
productIds = listOf("product"),
915+
purchaseState = Purchase.PurchaseState.PURCHASED,
916+
)
917+
val transaction = purchase.toStoreTransaction(ProductType.SUBS)
918+
val purchasesByHash = mapOf(purchase.purchaseToken.sha1() to transaction)
919+
920+
mockSuccessfulQueryPurchases(
921+
purchasesByHashedToken = purchasesByHash,
922+
notInCache = emptyList(),
923+
autoRenewingChanged = listOf(transaction),
924+
)
925+
926+
val error = PurchasesError(PurchasesErrorCode.NetworkError)
927+
every {
928+
postTransactionWithProductDetailsHelper.postTransactions(
929+
transactions = any(),
930+
allowSharingPlayStoreAccount = allowSharingPlayStoreAccount,
931+
appUserID = appUserId,
932+
initiationSource = initiationSource,
933+
sdkOriginated = false,
934+
transactionPostSuccess = any(),
935+
transactionPostError = captureLambda(),
936+
)
937+
} answers {
938+
lambda<ErrorPurchaseCallback>().captured.invoke(transaction, error)
939+
}
940+
941+
postPendingTransactionsHelper.syncPendingPurchaseQueue(allowSharingPlayStoreAccount)
942+
943+
// Changed token excluded from eager save
944+
verify(exactly = 1) {
945+
deviceCache.saveAutoRenewingStatus(emptyMap())
946+
}
947+
// updateAutoRenewingStatus NOT called since the post failed
948+
verify(exactly = 0) {
949+
deviceCache.updateAutoRenewingStatus(any(), any())
950+
}
951+
}
952+
874953
// endregion
875954
}

purchases/src/test/java/com/revenuecat/purchases/common/DeviceCacheTest.kt

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1055,6 +1055,30 @@ class DeviceCacheTest {
10551055
}
10561056
}
10571057

1058+
@Test
1059+
fun `updateAutoRenewingStatus updates single token`() {
1060+
val sha1 = "token1".sha1()
1061+
mockTokenMap("""{"$sha1":null}""")
1062+
every { mockEditor.apply() } just runs
1063+
1064+
cache.updateAutoRenewingStatus("token1", false)
1065+
verify {
1066+
mockEditor.putString(cache.tokensCacheKey, match { json ->
1067+
val obj = JSONObject(json)
1068+
obj.length() == 1 && obj.getBoolean(sha1) == false
1069+
})
1070+
}
1071+
}
1072+
1073+
@Test
1074+
fun `updateAutoRenewingStatus does nothing for unknown token`() {
1075+
mockTokenMap("""{"hash1":true}""")
1076+
cache.updateAutoRenewingStatus("unknown", false)
1077+
verify(exactly = 0) {
1078+
mockEditor.putString(cache.tokensCacheKey, any())
1079+
}
1080+
}
1081+
10581082
// endregion legacy token migration
10591083

10601084
private fun mockTokenMap(json: String) {

0 commit comments

Comments
 (0)