From 9f77e64b9f21ca7e9d643decb17d038a2b90088d Mon Sep 17 00:00:00 2001 From: Antonio Pallares Date: Wed, 17 Jun 2026 18:11:15 +0200 Subject: [PATCH] fix: attach cached presented offering context to unsynced active purchases When a purchase is initiated through the SDK, the pending-purchase sync path (queryPurchases) could post the transaction with `unsynced_active_purchases` and no presented offering context before the normal purchase-completion path ran, causing "Unknown" offerings in charts (SDK-4373). PurchaseContext now records the purchase-intent date, and queryPurchases attaches the cached presented offering context to a returned transaction when its product id matches and its purchase time is at or after the intent date. Co-authored-by: Cursor --- .../purchases/google/BillingWrapper.kt | 26 ++++- .../purchases/google/PurchaseContext.kt | 2 + .../purchases/strings/BillingStrings.kt | 2 + .../purchases/google/BillingWrapperTest.kt | 98 +++++++++++++++++++ 4 files changed, 127 insertions(+), 1 deletion(-) diff --git a/purchases/src/main/kotlin/com/revenuecat/purchases/google/BillingWrapper.kt b/purchases/src/main/kotlin/com/revenuecat/purchases/google/BillingWrapper.kt index 1a4f8f2c7c..acbac14350 100644 --- a/purchases/src/main/kotlin/com/revenuecat/purchases/google/BillingWrapper.kt +++ b/purchases/src/main/kotlin/com/revenuecat/purchases/google/BillingWrapper.kt @@ -341,6 +341,7 @@ internal class BillingWrapper( subscriptionOptionId, replaceProductInfo?.replacementMode.toStoreReplacementModeOrNull(), subscriptionOptionIdForProductIDs, + initiationDate = dateProvider.now, ) } executeRequestOnUIThread { @@ -531,7 +532,7 @@ internal class BillingWrapper( diagnosticsTrackerIfEnabled, appInBackground, ), - onSuccess, + { transactions -> onSuccess(attachCachedPurchaseContext(transactions)) }, onError, ::withConnectedClient, ::executeRequestOnUIThread, @@ -872,6 +873,29 @@ internal class BillingWrapper( return stringWriter.toString() } + private fun attachCachedPurchaseContext( + transactions: Map, + ): Map { + return transactions.mapValues { (_, transaction) -> + if (transaction.presentedOfferingContext != null) { + transaction + } else { + val productId = transaction.productIds.firstOrNull() + val context = synchronized(this@BillingWrapper) { + productId?.let { purchaseContext[it] } + } + if (context != null && transaction.purchaseTime >= context.initiationDate.time) { + log(LogIntent.DEBUG) { + BillingStrings.BILLING_WRAPPER_ATTACHING_CACHED_PURCHASE_CONTEXT.format(productId) + } + transaction.originalGooglePurchase?.toStoreTransaction(context) ?: transaction + } else { + transaction + } + } + } + } + private fun getStoreTransaction( purchase: Purchase, completion: (storeTxn: StoreTransaction) -> Unit, diff --git a/purchases/src/main/kotlin/com/revenuecat/purchases/google/PurchaseContext.kt b/purchases/src/main/kotlin/com/revenuecat/purchases/google/PurchaseContext.kt index a19c919390..49f3f4ed9d 100644 --- a/purchases/src/main/kotlin/com/revenuecat/purchases/google/PurchaseContext.kt +++ b/purchases/src/main/kotlin/com/revenuecat/purchases/google/PurchaseContext.kt @@ -3,6 +3,7 @@ package com.revenuecat.purchases.google import com.revenuecat.purchases.PresentedOfferingContext import com.revenuecat.purchases.ProductType import com.revenuecat.purchases.models.StoreReplacementMode +import java.util.Date internal class PurchaseContext( val productType: ProductType, @@ -10,4 +11,5 @@ internal class PurchaseContext( val selectedSubscriptionOptionId: String?, val replacementMode: StoreReplacementMode?, val subscriptionOptionIdForProductIDs: Map?, + val initiationDate: Date, ) diff --git a/purchases/src/main/kotlin/com/revenuecat/purchases/strings/BillingStrings.kt b/purchases/src/main/kotlin/com/revenuecat/purchases/strings/BillingStrings.kt index 98bfd06988..320b3171ea 100644 --- a/purchases/src/main/kotlin/com/revenuecat/purchases/strings/BillingStrings.kt +++ b/purchases/src/main/kotlin/com/revenuecat/purchases/strings/BillingStrings.kt @@ -15,6 +15,8 @@ internal object BillingStrings { "Original error message: %s" const val BILLING_WRAPPER_PURCHASES_ERROR = "BillingWrapper purchases failed to update: %s" const val BILLING_WRAPPER_PURCHASES_UPDATED = "BillingWrapper purchases updated: %s" + const val BILLING_WRAPPER_ATTACHING_CACHED_PURCHASE_CONTEXT = + "Attaching cached purchase context for product %s" const val BILLING_PURCHASE_HISTORY_RECORD_MORE_THAN_ONE_SKU = "There's more than one sku in the " + "PurchaseHistoryRecord, but only one will be used." const val CANNOT_CALL_CAN_MAKE_PAYMENTS = "canMakePayments requires the Google Play Store. Skipping " + diff --git a/purchases/src/test/java/com/revenuecat/purchases/google/BillingWrapperTest.kt b/purchases/src/test/java/com/revenuecat/purchases/google/BillingWrapperTest.kt index dff5629ab8..50686b1311 100644 --- a/purchases/src/test/java/com/revenuecat/purchases/google/BillingWrapperTest.kt +++ b/purchases/src/test/java/com/revenuecat/purchases/google/BillingWrapperTest.kt @@ -1723,6 +1723,104 @@ class BillingWrapperTest { // endregion diagnostics tracking + // region queryPurchases purchase context attachment + + @Test + fun `queryPurchases attaches cached presented offering context when purchase time is at or after initiation date`() { + val productId = "com.revenuecat.lifetime" + val presentedOfferingContext = PresentedOfferingContext("offering_a") + val initiationDate = Date(timestamp0) + val purchaseTime = timestamp123 + + wrapper.purchaseContext[productId] = PurchaseContext( + ProductType.SUBS, + presentedOfferingContext, + null, + null, + null, + initiationDate, + ) + + val purchase = stubGooglePurchase(productIds = listOf(productId), purchaseTime = purchaseTime) + mockClient.mockQueryPurchasesAsync( + billingClientOKResult, + billingClientOKResult, + listOf(purchase), + emptyList(), + ) + + var purchasesByHashedToken: Map? = null + wrapper.queryPurchases( + appUserID = appUserId, + onSuccess = { purchasesByHashedToken = it }, + onError = { fail("should be a success") }, + ) + + assertThat(purchasesByHashedToken).isNotNull + val transaction = purchasesByHashedToken!!.values.first() + assertThat(transaction.presentedOfferingContext).isEqualTo(presentedOfferingContext) + } + + @Test + fun `queryPurchases does not attach cached presented offering context when purchase time is before initiation date`() { + val productId = "com.revenuecat.lifetime" + val initiationDate = Date(timestamp123) + val purchaseTime = timestamp0 + + wrapper.purchaseContext[productId] = PurchaseContext( + ProductType.SUBS, + PresentedOfferingContext("offering_a"), + null, + null, + null, + initiationDate, + ) + + val purchase = stubGooglePurchase(productIds = listOf(productId), purchaseTime = purchaseTime) + mockClient.mockQueryPurchasesAsync( + billingClientOKResult, + billingClientOKResult, + listOf(purchase), + emptyList(), + ) + + var purchasesByHashedToken: Map? = null + wrapper.queryPurchases( + appUserID = appUserId, + onSuccess = { purchasesByHashedToken = it }, + onError = { fail("should be a success") }, + ) + + assertThat(purchasesByHashedToken).isNotNull + val transaction = purchasesByHashedToken!!.values.first() + assertThat(transaction.presentedOfferingContext).isNull() + } + + @Test + fun `queryPurchases returns transaction unchanged when no cached purchase context exists`() { + val productId = "com.revenuecat.lifetime" + val purchase = stubGooglePurchase(productIds = listOf(productId), purchaseTime = timestamp123) + mockClient.mockQueryPurchasesAsync( + billingClientOKResult, + billingClientOKResult, + listOf(purchase), + emptyList(), + ) + + var purchasesByHashedToken: Map? = null + wrapper.queryPurchases( + appUserID = appUserId, + onSuccess = { purchasesByHashedToken = it }, + onError = { fail("should be a success") }, + ) + + assertThat(purchasesByHashedToken).isNotNull + val transaction = purchasesByHashedToken!!.values.first() + assertThat(transaction.presentedOfferingContext).isNull() + } + + // endregion queryPurchases purchase context attachment + // region inapp messages @Test