Skip to content

Commit 60ef32a

Browse files
committed
Migrate logic to entitlement processor, add priority sorting, tests, ensure it merges with web
1 parent 8a50ea6 commit 60ef32a

29 files changed

+3549
-508
lines changed

superwall/src/main/java/com/superwall/sdk/config/ConfigManager.kt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -336,7 +336,7 @@ open class ConfigManager(
336336
}
337337
}
338338
ioScope.launch {
339-
storeManager.loadPurchasedProducts()
339+
storeManager.loadPurchasedProducts(entitlements.entitlementsByProductId)
340340
}
341341
}
342342

superwall/src/main/java/com/superwall/sdk/customer/CustomerInfoManager.kt

Lines changed: 54 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import com.superwall.sdk.logger.Logger
66
import com.superwall.sdk.misc.IOScope
77
import com.superwall.sdk.models.customer.CustomerInfo
88
import com.superwall.sdk.models.customer.merge
9+
import com.superwall.sdk.models.entitlements.SubscriptionStatus
910
import com.superwall.sdk.storage.*
1011
import kotlinx.coroutines.flow.MutableStateFlow
1112
import kotlinx.coroutines.launch
@@ -20,6 +21,11 @@ import kotlinx.coroutines.launch
2021
* - Updating the public CustomerInfo flow that the SDK exposes
2122
* - Persisting the merged result to storage
2223
*
24+
* When an external purchase controller is present (e.g., RevenueCat, Qonversion):
25+
* - The subscription status is the source of truth for active entitlements
26+
* - Device receipts are only used for inactive entitlements (history)
27+
* - This preserves entitlements set by external controllers that device receipts don't know about
28+
*
2329
* The merge happens whenever:
2430
* - Device receipts are refreshed (via ReceiptManager)
2531
* - Web entitlements are fetched (via WebPaywallRedeemer)
@@ -29,6 +35,8 @@ class CustomerInfoManager(
2935
private val storage: Storage,
3036
private val customerInfoFlow: MutableStateFlow<CustomerInfo>,
3137
private val ioScope: IOScope,
38+
private val hasExternalPurchaseController: () -> Boolean,
39+
private val getSubscriptionStatus: () -> SubscriptionStatus,
3240
) {
3341
/**
3442
* Merges device and web CustomerInfo and updates the public CustomerInfo flow.
@@ -40,40 +48,60 @@ class CustomerInfoManager(
4048
* 4. Persists the merged result to storage
4149
* 5. Updates the public flow so listeners get the latest merged state
4250
*
51+
* When an external purchase controller is present:
52+
* - Uses CustomerInfo.forExternalPurchaseController() instead of standard merge
53+
* - This ensures external controller's entitlements are preserved as source of truth
54+
*
4355
* The merge is performed asynchronously on the IO scope to avoid blocking the caller.
4456
*/
4557
fun updateMergedCustomerInfo() {
4658
ioScope.launch {
47-
// Get device CustomerInfo (from Google Play receipts)
48-
val deviceInfo = storage.read(LatestDeviceCustomerInfo) ?: CustomerInfo.empty()
59+
val merged: CustomerInfo
60+
61+
if (hasExternalPurchaseController()) {
62+
merged =
63+
CustomerInfo.forExternalPurchaseController(
64+
storage = storage,
65+
subscriptionStatus = getSubscriptionStatus(),
66+
)
4967

50-
// Get web CustomerInfo (from Superwall backend)
51-
val webInfo = storage.read(LatestWebCustomerInfo) ?: CustomerInfo.empty()
68+
Logger.debug(
69+
logLevel = LogLevel.debug,
70+
scope = LogScope.superwallCore,
71+
message =
72+
"Built CustomerInfo for external controller - " +
73+
"${merged.subscriptions.size} subs, " +
74+
"${merged.entitlements.size} entitlements",
75+
)
76+
} else {
77+
val deviceInfo = storage.read(LatestDeviceCustomerInfo) ?: CustomerInfo.empty()
78+
val webInfo = storage.read(LatestWebCustomerInfo) ?: CustomerInfo.empty()
5279

53-
Logger.debug(
54-
logLevel = LogLevel.debug,
55-
scope = LogScope.superwallCore,
56-
message =
57-
"Merging CustomerInfo - Device: ${deviceInfo.subscriptions.size} subs, " +
58-
"Web: ${webInfo.subscriptions.size} subs",
59-
)
80+
Logger.debug(
81+
logLevel = LogLevel.debug,
82+
scope = LogScope.superwallCore,
83+
message =
84+
"Merging CustomerInfo - Device: ${deviceInfo.subscriptions.size} subs, " +
85+
"Web: ${webInfo.subscriptions.size} subs",
86+
)
6087

61-
// Merge with optimization: skip merge if one source is blank
62-
val merged =
63-
when {
64-
deviceInfo.isBlank && webInfo.isBlank -> CustomerInfo.empty()
65-
deviceInfo.isBlank -> webInfo
66-
webInfo.isBlank -> deviceInfo
67-
else -> deviceInfo.merge(webInfo) // Apply priority-based merging
68-
}
88+
// Merge with optimization: skip merge if one source is blank
89+
merged =
90+
when {
91+
deviceInfo.isBlank && webInfo.isBlank -> CustomerInfo.empty()
92+
deviceInfo.isBlank -> webInfo
93+
webInfo.isBlank -> deviceInfo
94+
else -> deviceInfo.merge(webInfo) // Apply priority-based merging
95+
}
6996

70-
Logger.debug(
71-
logLevel = LogLevel.debug,
72-
scope = LogScope.superwallCore,
73-
message =
74-
"Merged CustomerInfo - Total: ${merged.subscriptions.size} subs, " +
75-
"${merged.entitlements.size} entitlements",
76-
)
97+
Logger.debug(
98+
logLevel = LogLevel.debug,
99+
scope = LogScope.superwallCore,
100+
message =
101+
"Merged CustomerInfo - Total: ${merged.subscriptions.size} subs, " +
102+
"${merged.entitlements.size} entitlements",
103+
)
104+
}
77105

78106
// Store merged result for caching/offline access
79107
storage.write(LatestCustomerInfo, merged)

superwall/src/main/java/com/superwall/sdk/dependencies/DependencyContainer.kt

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,7 @@ import com.superwall.sdk.store.AutomaticPurchaseController
9999
import com.superwall.sdk.store.Entitlements
100100
import com.superwall.sdk.store.InternalPurchaseController
101101
import com.superwall.sdk.store.StoreManager
102+
import com.superwall.sdk.store.abstractions.product.receipt.ReceiptManager
102103
import com.superwall.sdk.store.abstractions.transactions.GoogleBillingPurchaseTransaction
103104
import com.superwall.sdk.store.abstractions.transactions.StoreTransaction
104105
import com.superwall.sdk.store.transactions.TransactionManager
@@ -154,6 +155,7 @@ class DependencyContainer(
154155
SuperwallScopeFactory,
155156
GoogleBillingWrapper.Factory,
156157
ClassifierDataFactory,
158+
ExperimentalPropertiesFactory,
157159
WebPaywallRedeemer.Factory {
158160
var network: Network
159161
override var api: Api
@@ -242,6 +244,8 @@ class DependencyContainer(
242244
storage = storage,
243245
customerInfoFlow = Superwall.instance._customerInfo,
244246
ioScope = ioScope,
247+
hasExternalPurchaseController = { storeManager.purchaseController.hasExternalPurchaseController },
248+
getSubscriptionStatus = { entitlements.status.value },
245249
)
246250

247251
var purchaseController =
@@ -259,7 +263,7 @@ class DependencyContainer(
259263
storage = storage,
260264
customerInfoManager = { customerInfoManager },
261265
receiptManagerFactory = {
262-
com.superwall.sdk.store.abstractions.product.receipt.ReceiptManager(
266+
ReceiptManager(
263267
delegate = storeManager,
264268
billing = googleBillingWrapper,
265269
storage = storage,
@@ -468,6 +472,9 @@ class DependencyContainer(
468472
entitlementsById = {
469473
entitlements.byProductId(it)
470474
},
475+
allEntitlementsByProductId = {
476+
entitlements.entitlementsByProductId
477+
},
471478
refreshReceipt = {
472479
storeManager.refreshReceipt()
473480
},
@@ -844,6 +851,8 @@ class DependencyContainer(
844851

845852
override fun makeSuperwallOptions(): SuperwallOptions = configManager.options
846853

854+
override fun experimentalProperties(): Map<String, Any> = storeManager.receiptManager.experimentalProperties()
855+
847856
override suspend fun makeTriggers(): Set<String> = configManager.triggersByEventName.keys
848857

849858
override suspend fun provideRuleEvaluator(context: Context): ExpressionEvaluating = evaluator

superwall/src/main/java/com/superwall/sdk/dependencies/FactoryProtocols.kt

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -222,6 +222,10 @@ interface StoreTransactionFactory {
222222
suspend fun activeProductIds(): List<String>
223223
}
224224

225+
interface ExperimentalPropertiesFactory {
226+
fun experimentalProperties(): Map<String, Any>
227+
}
228+
225229
interface OptionsFactory {
226230
fun makeSuperwallOptions(): SuperwallOptions
227231
}

superwall/src/main/java/com/superwall/sdk/models/customer/CustomerInfo.kt

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,11 @@
11
package com.superwall.sdk.models.customer
22

33
import com.superwall.sdk.models.entitlements.Entitlement
4+
import com.superwall.sdk.models.entitlements.SubscriptionStatus
5+
import com.superwall.sdk.models.product.Store
6+
import com.superwall.sdk.storage.LatestDeviceCustomerInfo
7+
import com.superwall.sdk.storage.LatestRedemptionResponse
8+
import com.superwall.sdk.storage.Storage
49
import kotlinx.serialization.SerialName
510
import kotlinx.serialization.Serializable
611
import kotlinx.serialization.Transient
@@ -50,5 +55,69 @@ data class CustomerInfo(
5055
entitlements = emptyList(),
5156
isBlank = true,
5257
)
58+
59+
/**
60+
* Creates a merged CustomerInfo from device, web, and external purchase controller sources.
61+
*
62+
* When using an external purchase controller, the
63+
* subscriptionStatus is the source of truth for active entitlements. This method:
64+
* 1. Merges device and web transactions (subscriptions and nonSubscriptions)
65+
* 2. Takes only inactive device entitlements (for history)
66+
* 3. Takes active Play Store entitlements from subscriptionStatus (source of truth)
67+
* 4. Keeps all web entitlements
68+
* 5. Merges using priority rules
69+
*
70+
* This ensures the external purchase controller's entitlements are preserved even when
71+
* device receipts don't have that information (e.g., RevenueCat granted entitlements
72+
* from cross-platform purchases).
73+
*
74+
* @param storage Storage to read device and web CustomerInfo from
75+
* @param subscriptionStatus The subscription status containing entitlements from external controller
76+
* @return A new CustomerInfo with all sources merged
77+
*/
78+
fun forExternalPurchaseController(
79+
storage: Storage,
80+
subscriptionStatus: SubscriptionStatus,
81+
): CustomerInfo {
82+
// Get web CustomerInfo
83+
val webCustomerInfo =
84+
storage.read(LatestRedemptionResponse)?.customerInfo ?: empty()
85+
86+
// Get device CustomerInfo to preserve history
87+
// Use device-only CustomerInfo to avoid using stale cached web entitlements
88+
val deviceCustomerInfo = storage.read(LatestDeviceCustomerInfo) ?: empty()
89+
90+
// Merge device and web transactions (subscriptions and nonSubscriptions)
91+
// This handles transaction deduplication by transaction ID
92+
val baseCustomerInfo = deviceCustomerInfo.merge(webCustomerInfo)
93+
94+
// For entitlements: only take inactive device entitlements
95+
// Active entitlements come from the external purchase controller (source of truth)
96+
val inactiveDeviceEntitlements = deviceCustomerInfo.entitlements.filter { !it.isActive }
97+
98+
// Get active Play Store entitlements from external controller (the source of truth)
99+
// Filter for PLAY_STORE ones only to avoid duplicating web-granted entitlements
100+
val externalEntitlements: List<Entitlement> =
101+
when (subscriptionStatus) {
102+
is SubscriptionStatus.Active ->
103+
subscriptionStatus.entitlements.filter { it.store == Store.PLAY_STORE }
104+
SubscriptionStatus.Inactive,
105+
SubscriptionStatus.Unknown,
106+
-> emptyList()
107+
}
108+
109+
// Merge: active from external controller + all web + inactive device
110+
// This gives us complete history while respecting external controller as source of truth
111+
val allEntitlements = externalEntitlements + webCustomerInfo.entitlements + inactiveDeviceEntitlements
112+
val finalEntitlements = mergeEntitlementsPrioritized(allEntitlements).sortedBy { it.id }
113+
114+
return CustomerInfo(
115+
subscriptions = baseCustomerInfo.subscriptions,
116+
nonSubscriptions = baseCustomerInfo.nonSubscriptions,
117+
userId = baseCustomerInfo.userId,
118+
entitlements = finalEntitlements,
119+
isBlank = false,
120+
)
121+
}
53122
}
54123
}

superwall/src/main/java/com/superwall/sdk/models/customer/CustomerInfoMerging.kt

Lines changed: 21 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -86,12 +86,18 @@ fun CustomerInfo.merge(other: CustomerInfo): CustomerInfo {
8686
private fun mergeEntitlements(
8787
first: List<Entitlement>,
8888
second: List<Entitlement>,
89-
): List<Entitlement> =
90-
(first + second)
89+
): List<Entitlement> = mergeEntitlementsPrioritized(first + second)
90+
91+
/**
92+
* Merges a list of entitlements, keeping the highest priority entitlement for each ID.
93+
* Uses EntitlementPriorityComparator to determine priority.
94+
*/
95+
internal fun mergeEntitlementsPrioritized(entitlements: List<Entitlement>): List<Entitlement> =
96+
entitlements
9197
.groupBy { it.id }
92-
.map { (_, entitlements) ->
93-
entitlements.maxWithOrNull(EntitlementPriorityComparator)
94-
?: entitlements.first()
98+
.map { (_, group) ->
99+
group.maxWithOrNull(EntitlementPriorityComparator)
100+
?: group.first()
95101
}
96102

97103
/**
@@ -123,8 +129,16 @@ private object SubscriptionTransactionComparator : Comparator<SubscriptionTransa
123129

124130
/**
125131
* Comparator implementing iOS priority rules for entitlements.
132+
*
133+
* Priority order (highest to lowest):
134+
* 1. Active entitlements (isActive = true)
135+
* 2. Has transaction history (startsAt != null)
136+
* 3. Lifetime entitlements (isLifetime = true)
137+
* 4. Latest expiry time (furthest future expiresAt)
138+
* 5. Will renew vs won't renew (willRenew = true)
139+
* 6. Subscription state quality (SUBSCRIBED > GRACE_PERIOD > BILLING_RETRY > EXPIRED)
126140
*/
127-
private object EntitlementPriorityComparator : Comparator<Entitlement> {
141+
internal object EntitlementPriorityComparator : Comparator<Entitlement> {
128142
override fun compare(
129143
a: Entitlement,
130144
b: Entitlement,
@@ -159,7 +173,7 @@ private object EntitlementPriorityComparator : Comparator<Entitlement> {
159173
val aState = a.state
160174
val bState = b.state
161175
if (aState != bState) {
162-
// Prioritize based on state quality
176+
// Prioritize based on latest sub state
163177
val aScore = getStateScore(aState)
164178
val bScore = getStateScore(bState)
165179
if (aScore != bScore) return aScore.compareTo(bScore)
Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,11 @@
11
package com.superwall.sdk.models.entitlements
22

3+
import com.superwall.sdk.models.customer.CustomerInfo
34
import kotlinx.serialization.SerialName
45
import kotlinx.serialization.Serializable
56

67
@Serializable
78
data class WebEntitlements(
8-
@SerialName("entitlements")
9-
val entitlements: List<Entitlement>,
109
@SerialName("customerInfo")
11-
val customerInfo: com.superwall.sdk.models.customer.CustomerInfo? = null,
10+
val customerInfo: CustomerInfo? = null,
1211
)

superwall/src/main/java/com/superwall/sdk/models/internal/WebRedemption.kt

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -31,8 +31,6 @@ data class WebRedemptionResponse(
3131
@SerialName("codes")
3232
val codes: List<RedemptionResult>,
3333
@SerialName("entitlements")
34-
val entitlements: List<Entitlement>,
35-
@SerialName("customerInfo")
3634
val customerInfo: com.superwall.sdk.models.customer.CustomerInfo? = null,
3735
@kotlinx.serialization.Transient
3836
val allCodes: List<Redeemable> = codes.map { Redeemable(it.code, false) },

superwall/src/main/java/com/superwall/sdk/models/product/ProductItem.kt

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,9 @@ enum class Store {
4141
@SerialName("PADDLE")
4242
PADDLE,
4343

44+
@SerialName("SUPERWALL")
45+
SUPERWALL,
46+
4447
@SerialName("OTHER")
4548
OTHER,
4649

@@ -53,6 +56,7 @@ enum class Store {
5356
"APP_STORE" -> APP_STORE
5457
"STRIPE" -> STRIPE
5558
"PADDLE" -> PADDLE
59+
"SUPERWALL" -> SUPERWALL
5660
else -> OTHER
5761
}
5862
}
@@ -303,7 +307,9 @@ object StoreProductSerializer : KSerializer<ProductItem.StoreProductType> {
303307
ProductItem.StoreProductType.Paddle(product)
304308
}
305309

306-
Store.OTHER -> {
310+
Store.SUPERWALL,
311+
Store.OTHER,
312+
-> {
307313
val product =
308314
json.decodeFromJsonElement(UnknownStoreProduct.serializer(), jsonObject)
309315
ProductItem.StoreProductType.Other(product)

superwall/src/main/java/com/superwall/sdk/network/device/DeviceHelper.kt

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import com.superwall.sdk.BuildConfig
1515
import com.superwall.sdk.Superwall
1616
import com.superwall.sdk.analytics.DefaultClassifierDataFactory
1717
import com.superwall.sdk.analytics.DeviceClassifier
18+
import com.superwall.sdk.dependencies.ExperimentalPropertiesFactory
1819
import com.superwall.sdk.dependencies.IdentityInfoFactory
1920
import com.superwall.sdk.dependencies.IdentityManagerFactory
2021
import com.superwall.sdk.dependencies.LocaleIdentifierFactory
@@ -79,6 +80,7 @@ class DeviceHelper(
7980
JsonFactory,
8081
StoreTransactionFactory,
8182
IdentityManagerFactory,
83+
ExperimentalPropertiesFactory,
8284
OptionsFactory
8385

8486
private val json =
@@ -609,6 +611,8 @@ class DeviceHelper(
609611
this.lastEnrichment.value = enrichment
610612
}
611613

614+
fun latestExperimentalDeviceProperties(): Map<String, Any> = factory.experimentalProperties()
615+
612616
suspend fun getEnrichment(
613617
maxRetry: Int,
614618
timeout: Duration,

0 commit comments

Comments
 (0)