diff --git a/superwall/src/main/java/com/superwall/sdk/analytics/internal/trackable/TrackableSuperwallEvent.kt b/superwall/src/main/java/com/superwall/sdk/analytics/internal/trackable/TrackableSuperwallEvent.kt index ed9c76e37..bf21cf1a0 100644 --- a/superwall/src/main/java/com/superwall/sdk/analytics/internal/trackable/TrackableSuperwallEvent.kt +++ b/superwall/src/main/java/com/superwall/sdk/analytics/internal/trackable/TrackableSuperwallEvent.kt @@ -532,7 +532,7 @@ sealed class InternalSuperwallEvent( get() { return paywallInfo.audienceFilterParams().let { if (superwallPlacement is SuperwallEvent.TransactionAbandon) { - it.plus("abandoned_product_id" to (product?.productIdentifier ?: "")) + it.plus("abandoned_product_id" to (product?.fullIdentifier ?: "")) } else { it } diff --git a/superwall/src/main/java/com/superwall/sdk/analytics/superwall/TransactionProduct.kt b/superwall/src/main/java/com/superwall/sdk/analytics/superwall/TransactionProduct.kt index 99a1af1ad..bbda2a172 100644 --- a/superwall/src/main/java/com/superwall/sdk/analytics/superwall/TransactionProduct.kt +++ b/superwall/src/main/java/com/superwall/sdk/analytics/superwall/TransactionProduct.kt @@ -48,7 +48,7 @@ data class TransactionProduct( ) constructor(product: StoreProduct) : this( - id = product.productIdentifier, + id = product.fullIdentifier, price = Price( raw = product.price, diff --git a/superwall/src/main/java/com/superwall/sdk/billing/DecomposedProductIds.kt b/superwall/src/main/java/com/superwall/sdk/billing/DecomposedProductIds.kt index 4694b2f89..fe61c757d 100644 --- a/superwall/src/main/java/com/superwall/sdk/billing/DecomposedProductIds.kt +++ b/superwall/src/main/java/com/superwall/sdk/billing/DecomposedProductIds.kt @@ -1,30 +1,40 @@ package com.superwall.sdk.billing +import com.superwall.sdk.store.abstractions.product.BasePlanType import com.superwall.sdk.store.abstractions.product.OfferType +/** + * Represents a decomposed product ID in the format: `productId:basePlan:offer` + * + * [basePlanType] uses [BasePlanType]: + * - [BasePlanType.Auto] when the value is "sw-auto", null, or empty + * - [BasePlanType.Specific] when a specific ID is provided + * + * [offerType] uses [OfferType]: + * - [OfferType.Auto] when the value is "sw-auto", null, or empty + * - [OfferType.None] when the value is "sw-none" (no offer) + * - [OfferType.Specific] when a specific ID is provided + */ data class DecomposedProductIds( val subscriptionId: String, - val basePlanId: String, + val basePlanType: BasePlanType, val offerType: OfferType, val fullId: String, ) { + /** Returns the base plan ID if specific, null if auto */ + val basePlanId: String? get() = basePlanType.specificId + companion object { fun from(productId: String): DecomposedProductIds { val components = productId.split(":") val subscriptionId = components.getOrNull(0) ?: "" - val basePlanId = components.getOrNull(1) ?: "" - val offerId = components.getOrNull(2) + val rawBasePlan = components.getOrNull(1) + val rawOffer = components.getOrNull(2) - val offerType = - if (offerId == "sw-auto" || offerId == null) { - OfferType.Auto - } else { - OfferType.Offer(id = offerId) - } return DecomposedProductIds( subscriptionId = subscriptionId, - basePlanId = basePlanId, - offerType = offerType, + basePlanType = BasePlanType.from(rawBasePlan), + offerType = OfferType.from(rawOffer), fullId = productId, ) } diff --git a/superwall/src/main/java/com/superwall/sdk/billing/QueryProductDetailsUseCase.kt b/superwall/src/main/java/com/superwall/sdk/billing/QueryProductDetailsUseCase.kt index 083035e91..a2672edd0 100644 --- a/superwall/src/main/java/com/superwall/sdk/billing/QueryProductDetailsUseCase.kt +++ b/superwall/src/main/java/com/superwall/sdk/billing/QueryProductDetailsUseCase.kt @@ -75,11 +75,12 @@ internal class QueryProductDetailsUseCase( val rawStoreProduct = RawStoreProduct( underlyingProductDetails = productDetails, - fullIdentifier = productId.fullId ?: "", - basePlanId = productId.basePlanId, + fullIdentifier = productId.fullId, + basePlanType = productId.basePlanType, offerType = productId.offerType, ) - StoreProduct(rawStoreProduct) + val storeProduct = StoreProduct(rawStoreProduct) + storeProduct } ?: emptyList() } diff --git a/superwall/src/main/java/com/superwall/sdk/models/paywall/Paywall.kt b/superwall/src/main/java/com/superwall/sdk/models/paywall/Paywall.kt index 03c4583b3..de4ff1fcb 100644 --- a/superwall/src/main/java/com/superwall/sdk/models/paywall/Paywall.kt +++ b/superwall/src/main/java/com/superwall/sdk/models/paywall/Paywall.kt @@ -84,7 +84,10 @@ data class Paywall( @SerialName("products_v2") internal var _productItems: List = emptyList(), @kotlinx.serialization.Transient() - var productIds: List = _productItems.map { it.compositeId }, + var productIds: List = + _productItems.map { it.compositeId }.ifEmpty { + _productItemsV3.map { it.fullProductId } + }, @kotlinx.serialization.Transient() var responseLoadingInfo: LoadingInfo = LoadingInfo(), @kotlinx.serialization.Transient() diff --git a/superwall/src/main/java/com/superwall/sdk/models/product/CrossplatformProduct.kt b/superwall/src/main/java/com/superwall/sdk/models/product/CrossplatformProduct.kt index f52ea0d77..ce5f3d36c 100644 --- a/superwall/src/main/java/com/superwall/sdk/models/product/CrossplatformProduct.kt +++ b/superwall/src/main/java/com/superwall/sdk/models/product/CrossplatformProduct.kt @@ -64,6 +64,7 @@ data class CrossplatformProduct( get() = when (offer) { is Offer.Automatic -> "$productIdentifier:$basePlanIdentifier:sw-auto" + is Offer.NoOffer -> "$productIdentifier:$basePlanIdentifier:sw-none" is Offer.Specified -> "$productIdentifier:$basePlanIdentifier:${offer.offerIdentifier}" } @@ -171,6 +172,7 @@ object PlayStoreSerializer : KSerializer JsonObject(mapOf("type" to JsonPrimitive(offer.type))) + is Offer.NoOffer -> JsonObject(mapOf("type" to JsonPrimitive(Offer.NoOffer.TYPE))) is Offer.Specified -> JsonObject( mapOf( @@ -205,13 +207,14 @@ object PlayStoreSerializer : KSerializer Offer.Automatic() + "NO_OFFER" -> Offer.NoOffer "SPECIFIED" -> { val offerIdentifier = offerJsonObject["offer_identifier"]?.jsonPrimitive?.content ?: throw SerializationException("offer_identifier is missing") Offer.Specified(offerIdentifier = offerIdentifier) } - else -> Offer.Specified(offerIdentifier = "sw-none") + else -> Offer.NoOffer } return CrossplatformProduct.StoreProduct.PlayStore(productIdentifier, basePlanIdentifier, offer) diff --git a/superwall/src/main/java/com/superwall/sdk/models/product/ProductItem.kt b/superwall/src/main/java/com/superwall/sdk/models/product/ProductItem.kt index 84127fa06..bec6dd6f1 100644 --- a/superwall/src/main/java/com/superwall/sdk/models/product/ProductItem.kt +++ b/superwall/src/main/java/com/superwall/sdk/models/product/ProductItem.kt @@ -68,6 +68,12 @@ sealed class Offer { val type: String = "AUTOMATIC", ) : Offer() + /** Don't select any offer - use base plan/option only */ + @Serializable + object NoOffer : Offer() { + const val TYPE = "NO_OFFER" + } + @Serializable data class Specified( val type: String = "SPECIFIED", @@ -94,6 +100,7 @@ data class PlayStoreProduct( // So this logic is left to detect old IAP's basePlanIdentifier.isEmpty() -> productIdentifier offer is Offer.Automatic -> "$productIdentifier:$basePlanIdentifier:sw-auto" + offer is Offer.NoOffer -> "$productIdentifier:$basePlanIdentifier:sw-none" offer is Offer.Specified -> "$productIdentifier:$basePlanIdentifier:${offer.offerIdentifier}" else -> "$productIdentifier:$basePlanIdentifier" } @@ -166,6 +173,7 @@ object PlayStoreProductSerializer : KSerializer { val offer = when (val offer = value.offer) { is Offer.Automatic -> JsonObject(mapOf("type" to JsonPrimitive(offer.type))) + is Offer.NoOffer -> JsonObject(mapOf("type" to JsonPrimitive(Offer.NoOffer.TYPE))) is Offer.Specified -> JsonObject( mapOf( @@ -212,6 +220,7 @@ object PlayStoreProductSerializer : KSerializer { val offer = when (type) { "AUTOMATIC" -> Offer.Automatic() + "NO_OFFER" -> Offer.NoOffer "SPECIFIED" -> { val offerIdentifier = offerJsonObject["offer_identifier"]?.jsonPrimitive?.content @@ -225,7 +234,7 @@ object PlayStoreProductSerializer : KSerializer { LogScope.configManager, "Unknown offer type for $productIdentifier, fallback to none", ) - Offer.Specified(offerIdentifier = "sw-none") + Offer.NoOffer } } @@ -397,7 +406,9 @@ object ProductItemSerializer : KSerializer { val jsonObject = buildJsonObject { put("product", JsonPrimitive(value.name)) - put("productId", JsonPrimitive(value.fullProductId)) + // Use compositeId (the authoritative ID from server) instead of computed fullProductId + // to ensure the webview sends back the correct ID for cache lookup during purchase + put("productId", JsonPrimitive(value.compositeId)) val storeProductElement = jsonOutput.json.encodeToJsonElement(StoreProductSerializer, value.type) put("store_product", storeProductElement) diff --git a/superwall/src/main/java/com/superwall/sdk/store/AutomaticPurchaseController.kt b/superwall/src/main/java/com/superwall/sdk/store/AutomaticPurchaseController.kt index 2d26b52ce..512d48ee1 100644 --- a/superwall/src/main/java/com/superwall/sdk/store/AutomaticPurchaseController.kt +++ b/superwall/src/main/java/com/superwall/sdk/store/AutomaticPurchaseController.kt @@ -25,6 +25,7 @@ import com.superwall.sdk.logger.Logger import com.superwall.sdk.misc.IOScope import com.superwall.sdk.models.customer.toSet import com.superwall.sdk.models.entitlements.SubscriptionStatus +import com.superwall.sdk.store.abstractions.product.BasePlanType import com.superwall.sdk.store.abstractions.product.OfferType import com.superwall.sdk.store.abstractions.product.RawStoreProduct import com.superwall.sdk.store.transactions.PlayBillingErrors @@ -52,8 +53,9 @@ class AutomaticPurchaseController( BillingClient .newBuilder(ctx) .setListener(listener) - .enablePendingPurchases(PendingPurchasesParams.newBuilder().enableOneTimeProducts().build()) - .build() + .enablePendingPurchases( + PendingPurchasesParams.newBuilder().enableOneTimeProducts().build(), + ).build() } catch (e: Throwable) { Logger.debug( logLevel = LogLevel.error, @@ -172,18 +174,26 @@ class AutomaticPurchaseController( RawStoreProduct( underlyingProductDetails = productDetails, fullIdentifier = fullId, - basePlanId = basePlanId ?: "", - offerType = offerId?.let { OfferType.Offer(id = it) }, + basePlanType = BasePlanType.from(basePlanId), + offerType = OfferType.from(offerId), ) val offerToken = when (val offer = rawStoreProduct.selectedOffer) { is RawStoreProduct.SelectedOfferDetails.Subscription -> offer.underlying.offerToken - is RawStoreProduct.SelectedOfferDetails.OneTime -> offer.underlying.offerToken + is RawStoreProduct.SelectedOfferDetails.OneTime -> { + // For OTP with purchase options, we need the offerToken to specify which + // purchase option to use, even when there's no discount offer (offerId=null). + // Only skip offerToken for legacy OTPs without purchase options. + if (offer.purchaseOptionId != null || offerId != null) { + offer.underlying.offerToken + } else { + null + } + } null -> null } - val isOneTime = productDetails.productType == BillingClient.ProductType.INAPP val hasOfferToken = !offerToken.isNullOrEmpty() val productDetailsParams = @@ -282,7 +292,8 @@ class AutomaticPurchaseController( // For all other response codes, create a Failed result with an exception else -> { PurchaseResult.Failed( - PlayBillingErrors.fromCode(billingResult.responseCode)?.message ?: "Unknown error ${billingResult.responseCode}", + PlayBillingErrors.fromCode(billingResult.responseCode)?.message + ?: "Unknown error ${billingResult.responseCode}", ) } } @@ -305,6 +316,11 @@ class AutomaticPurchaseController( Superwall.instance.configurationStateListener.first { it is ConfigurationStatus.Configured } val subscriptionPurchases = queryPurchasesOfType(BillingClient.ProductType.SUBS) val inAppPurchases = queryPurchasesOfType(BillingClient.ProductType.INAPP) + inAppPurchases.forEach { + it.purchaseToken?.let { + Superwall.instance.consume(it) + } + } val allPurchases = subscriptionPurchases + inAppPurchases val hasActivePurchaseOrSubscription = allPurchases.any { it.purchaseState == Purchase.PurchaseState.PURCHASED } @@ -321,7 +337,11 @@ class AutomaticPurchaseController( .let { entitlements -> entitlementsInfo.activeDeviceEntitlements = entitlements if (entitlements.isNotEmpty()) { - SubscriptionStatus.Active(entitlements.map { it.copy(isActive = true) }.toSet()) + SubscriptionStatus.Active( + entitlements + .map { it.copy(isActive = true) } + .toSet(), + ) } else { SubscriptionStatus.Inactive } @@ -359,6 +379,11 @@ class AutomaticPurchaseController( ) return@queryPurchasesAsync } + purchasesList.forEach { + scope.launch { + Superwall.instance.consume(it.purchaseToken) + } + } deferred.complete(purchasesList) } diff --git a/superwall/src/main/java/com/superwall/sdk/store/Entitlements.kt b/superwall/src/main/java/com/superwall/sdk/store/Entitlements.kt index c21c9d606..ac42184e2 100644 --- a/superwall/src/main/java/com/superwall/sdk/store/Entitlements.kt +++ b/superwall/src/main/java/com/superwall/sdk/store/Entitlements.kt @@ -181,12 +181,12 @@ class Entitlements( return checkFor( listOf( decomposedProductIds.fullId, - "${decomposedProductIds.subscriptionId}:${decomposedProductIds.basePlanId}:${decomposedProductIds.offerType.id ?: ""}", - "${decomposedProductIds.subscriptionId}:${decomposedProductIds.basePlanId}", + "${decomposedProductIds.subscriptionId}:${decomposedProductIds.basePlanId ?: ""}:${decomposedProductIds.offerType.specificId ?: ""}", + "${decomposedProductIds.subscriptionId}:${decomposedProductIds.basePlanId ?: ""}", ), ) ?: checkFor( listOf( - "${decomposedProductIds.subscriptionId}:${decomposedProductIds.basePlanId}:", + "${decomposedProductIds.subscriptionId}:${decomposedProductIds.basePlanId ?: ""}:", decomposedProductIds.subscriptionId, ), isExact = false, diff --git a/superwall/src/main/java/com/superwall/sdk/store/StoreManager.kt b/superwall/src/main/java/com/superwall/sdk/store/StoreManager.kt index f485a72dd..03bc3d4e8 100644 --- a/superwall/src/main/java/com/superwall/sdk/store/StoreManager.kt +++ b/superwall/src/main/java/com/superwall/sdk/store/StoreManager.kt @@ -11,12 +11,10 @@ import com.superwall.sdk.logger.LogScope import com.superwall.sdk.logger.Logger import com.superwall.sdk.models.entitlements.Entitlement import com.superwall.sdk.models.paywall.Paywall -import com.superwall.sdk.models.product.Offer import com.superwall.sdk.models.product.PlayStoreProduct import com.superwall.sdk.models.product.ProductItem import com.superwall.sdk.models.product.ProductVariable import com.superwall.sdk.paywall.request.PaywallRequest -import com.superwall.sdk.store.abstractions.product.OfferType import com.superwall.sdk.store.abstractions.product.StoreProduct import com.superwall.sdk.store.abstractions.product.receipt.ReceiptManager import com.superwall.sdk.store.coordinator.ProductsFetcher @@ -170,17 +168,7 @@ class StoreManager( PlayStoreProduct( productIdentifier = decomposedProductIds.subscriptionId, basePlanIdentifier = decomposedProductIds.basePlanId ?: "", - offer = - decomposedProductIds.offerType.let { offerType -> - when (offerType) { - is OfferType.Offer -> - Offer.Specified( - offerIdentifier = offerType.id, - ) - - is OfferType.Auto -> Offer.Automatic() - } - }, + offer = decomposedProductIds.offerType.toOffer(), ), ) productItems[index] = @@ -196,17 +184,7 @@ class StoreManager( PlayStoreProduct( productIdentifier = decomposedProductIds.subscriptionId, basePlanIdentifier = decomposedProductIds.basePlanId ?: "", - offer = - decomposedProductIds.offerType.let { offerType -> - when (offerType) { - is OfferType.Offer -> - Offer.Specified( - offerIdentifier = offerType.id, - ) - - is OfferType.Auto -> Offer.Automatic() - } - }, + offer = decomposedProductIds.offerType.toOffer(), ), ) // If no existing product found, just append to the list. diff --git a/superwall/src/main/java/com/superwall/sdk/store/abstractions/product/RawStoreProduct.kt b/superwall/src/main/java/com/superwall/sdk/store/abstractions/product/RawStoreProduct.kt index 675874876..877680566 100644 --- a/superwall/src/main/java/com/superwall/sdk/store/abstractions/product/RawStoreProduct.kt +++ b/superwall/src/main/java/com/superwall/sdk/store/abstractions/product/RawStoreProduct.kt @@ -18,16 +18,19 @@ import java.util.Locale class RawStoreProduct( val underlyingProductDetails: ProductDetails, override val fullIdentifier: String, - val basePlanId: String?, - private val offerType: OfferType?, + val basePlanType: BasePlanType, + private val offerType: OfferType, ) : StoreProductType { + /** Returns the base plan ID if specific, null if auto */ + val basePlanId: String? get() = basePlanType.specificId + companion object { fun from(details: ProductDetails): RawStoreProduct { val ids = DecomposedProductIds.from(details.productId) return RawStoreProduct( underlyingProductDetails = details, fullIdentifier = details.productId, - basePlanId = ids.basePlanId, + basePlanType = ids.basePlanType, offerType = ids.offerType, ) } @@ -327,54 +330,57 @@ class RawStoreProduct( } private fun getSelectedOfferDetails(): SelectedOfferDetails? { - // Handle one-time purchase products + // Handle one-time purchase products with purchase options list if (!underlyingProductDetails.oneTimePurchaseOfferDetailsList.isNullOrEmpty()) { val list = underlyingProductDetails.oneTimePurchaseOfferDetailsList ?: emptyList() - val hasSpecificPurchaseOption = !basePlanId.isNullOrEmpty() - val offerIdFromType = (offerType as? OfferType.Offer)?.id - val hasSpecificOffer = offerIdFromType != null + val hasSpecificPurchaseOption = basePlanType is BasePlanType.Specific + val offerIdFromType = offerType.specificId + val hasSpecificOffer = offerType is OfferType.Specific + val isOfferNone = offerType is OfferType.None val selected: ProductDetails.OneTimePurchaseOfferDetails? = when { + // Case 0: Offer is None (sw-none) - select purchase option without any discount offer + isOfferNone -> { + val optionsWithoutOffer = + if (hasSpecificPurchaseOption) { + list.filter { it.purchaseOptionId == basePlanId && it.offerId.isNullOrEmpty() } + } else { + list.filter { it.offerId.isNullOrEmpty() } + } + optionsWithoutOffer.minByOrNull { it.priceAmountMicros } + } + // Case 1: Specific purchase option + specific offer - // → Look for exact match, fallback to just purchase option hasSpecificPurchaseOption && hasSpecificOffer -> { - val exactMatch = - list.firstOrNull { - it.purchaseOptionId == basePlanId && it.offerId == offerIdFromType - } - val result = exactMatch ?: list.firstOrNull { it.purchaseOptionId == basePlanId } - result + val exactMatch = list.firstOrNull { it.purchaseOptionId == basePlanId && it.offerId == offerIdFromType } + exactMatch ?: list.firstOrNull { it.purchaseOptionId == basePlanId } } // Case 2: Auto purchase option + specific offer - // → Look for all options with that offer, select cheapest !hasSpecificPurchaseOption && hasSpecificOffer -> { - val optionsWithOffer = list.filter { it.offerId == offerIdFromType } - val result = optionsWithOffer.minByOrNull { it.priceAmountMicros } - result + list.filter { it.offerId == offerIdFromType }.minByOrNull { it.priceAmountMicros } } // Case 3: Specific purchase option + auto offer - // → Look for all offers within that purchase option, select cheapest - hasSpecificPurchaseOption && !hasSpecificOffer -> { - val optionsForPurchaseOption = list.filter { it.purchaseOptionId == basePlanId } - val result = optionsForPurchaseOption.minByOrNull { it.priceAmountMicros } - result + hasSpecificPurchaseOption -> { + list.filter { it.purchaseOptionId == basePlanId }.minByOrNull { it.priceAmountMicros } } // Case 4: Auto purchase option + auto offer - // → Prefer cheapest with both option+offer, fallback to cheapest overall else -> { - // First try: options that have both purchaseOptionId AND offerId val optionsWithBoth = list.filter { !it.purchaseOptionId.isNullOrEmpty() && !it.offerId.isNullOrEmpty() } - val cheapestWithBoth = optionsWithBoth.minByOrNull { it.priceAmountMicros } - cheapestWithBoth ?: list.minByOrNull { it.priceAmountMicros } + optionsWithBoth.minByOrNull { it.priceAmountMicros } ?: list.minByOrNull { it.priceAmountMicros } } } - // Fallback to default if nothing found - val finalSelected = selected ?: underlyingProductDetails.oneTimePurchaseOfferDetails + // Only use legacy fallback if we're NOT explicitly requesting no offer (sw-none) + val finalSelected = + if (selected == null && !isOfferNone) { + underlyingProductDetails.oneTimePurchaseOfferDetails + } else { + selected + } return finalSelected?.let { SelectedOfferDetails.OneTime(it, it.purchaseOptionId, it.offerId) } } @@ -384,40 +390,22 @@ class RawStoreProduct( } // Handle subscription products - val subscriptionOfferDetails = - underlyingProductDetails.subscriptionOfferDetails ?: return null + val subscriptionOfferDetails = underlyingProductDetails.subscriptionOfferDetails ?: return null - // Default to first base plan we come across if base plan is an empty string + // Default to first base plan if base plan is empty if (basePlanId.isNullOrEmpty()) { - return subscriptionOfferDetails - .firstOrNull { it.pricingPhases.pricingPhaseList.size == 1 } - ?.let { SelectedOfferDetails.Subscription(it) } + val firstBasePlan = subscriptionOfferDetails.firstOrNull { it.pricingPhases.pricingPhaseList.size == 1 } + return firstBasePlan?.let { SelectedOfferDetails.Subscription(it) } } - // Get the offers that match the given base plan ID. val offersForBasePlan = subscriptionOfferDetails.filter { it.basePlanId == basePlanId } - - // In offers that match base plan, if there's only 1 pricing phase then this offer represents the base plan. - val basePlan = - offersForBasePlan.firstOrNull { - it.pricingPhases.pricingPhaseList.size == 1 - } ?: return null + val basePlan = offersForBasePlan.firstOrNull { it.pricingPhases.pricingPhaseList.size == 1 } ?: return null val selectedSubscriptionOffer: SubscriptionOfferDetails = when (offerType) { - is OfferType.Auto -> { - automaticallySelectSubscriptionOffer()?.underlying ?: basePlan - } - - is OfferType.Offer -> { - // If an offer ID is given, return that one. Otherwise fallback to base plan. - offersForBasePlan.firstOrNull { it.offerId == offerType.id } ?: basePlan - } - - null -> { - // If no offer, return base plan - basePlan - } + is OfferType.Auto -> automaticallySelectSubscriptionOffer()?.underlying ?: basePlan + is OfferType.None -> basePlan + is OfferType.Specific -> offersForBasePlan.firstOrNull { it.offerId == offerType.id } ?: basePlan } return SelectedOfferDetails.Subscription(selectedSubscriptionOffer) @@ -433,7 +421,9 @@ class RawStoreProduct( */ private fun automaticallySelectSubscriptionOffer(): SelectedOfferDetails.Subscription? { val subscriptionOfferDetails = - underlyingProductDetails.subscriptionOfferDetails ?: return null + underlyingProductDetails.subscriptionOfferDetails ?: run { + return null + } // Get the offers that match the given base plan ID. val offersForBasePlan = subscriptionOfferDetails.filter { it.basePlanId == basePlanId } @@ -445,10 +435,11 @@ class RawStoreProduct( // Ignore those with a tag that contains "ignore-offer" .filter { !it.offerTags.any { it.contains("-ignore-offer") } } - return (findLongestFreeTrial(validOffers) ?: findLowestNonFreeOffer(validOffers)) - ?.let { - SelectedOfferDetails.Subscription(it) - } + val longestTrialOffer = findLongestFreeTrial(validOffers) + val cheapestOffer = findLowestNonFreeOffer(validOffers) + val selected = longestTrialOffer ?: cheapestOffer + + return selected?.let { SelectedOfferDetails.Subscription(it) } } private fun findLongestFreeTrial(offers: List): SubscriptionOfferDetails? = diff --git a/superwall/src/main/java/com/superwall/sdk/store/abstractions/product/StoreProduct.kt b/superwall/src/main/java/com/superwall/sdk/store/abstractions/product/StoreProduct.kt index ea6764139..a67ce73dc 100644 --- a/superwall/src/main/java/com/superwall/sdk/store/abstractions/product/StoreProduct.kt +++ b/superwall/src/main/java/com/superwall/sdk/store/abstractions/product/StoreProduct.kt @@ -1,24 +1,79 @@ package com.superwall.sdk.store.abstractions.product +import com.superwall.sdk.models.product.Offer import kotlinx.serialization.Serializable import java.math.BigDecimal import java.util.* -// TODO: Consolidate OfferType and Offer without breaking changes +/** + * Selection type for base plan/purchase option. + * When [Auto], the SDK will automatically select the best base plan. + * When [Specific], the SDK will use the exact base plan ID provided. + */ +@Serializable +sealed class BasePlanType { + /** Auto-select the best base plan */ + object Auto : BasePlanType() + + /** Use a specific base plan by ID */ + data class Specific( + val id: String, + ) : BasePlanType() + + /** Returns the ID if this is a Specific selection, null otherwise */ + val specificId: String? + get() = (this as? Specific)?.id + + companion object { + /** Parse a string value - "sw-auto"/null/empty means Auto, otherwise Specific */ + fun from(value: String?): BasePlanType = + when { + value.isNullOrEmpty() || value == "sw-auto" -> Auto + else -> Specific(value) + } + } +} + +/** + * Selection type for offer selection. + * When [Auto], the SDK will automatically select the best offer. + * When [None], no offer will be selected (use base plan/option only). + * When [Specific], the SDK will use the exact offer ID provided. + */ @Serializable sealed class OfferType { + /** Auto-select the best offer (e.g., longest free trial or cheapest) */ object Auto : OfferType() - data class Offer( - override val id: String, + /** Don't select any offer - use base plan/option only */ + object None : OfferType() + + /** Use a specific offer by ID */ + data class Specific( + val id: String, ) : OfferType() - open val id: String? - get() = - when (this) { - is Offer -> id - else -> null + /** Returns the ID if this is a Specific selection, null otherwise */ + val specificId: String? + get() = (this as? Specific)?.id + + /** Converts to the Offer type used in ProductItem */ + fun toOffer(): Offer = + when (this) { + is Auto -> Offer.Automatic() + is None -> Offer.NoOffer + is Specific -> Offer.Specified(offerIdentifier = id) + } + + companion object { + /** Parse a string value - "sw-auto"/null/empty means Auto, "sw-none" means None, otherwise Specific */ + fun from(value: String?): OfferType = + when { + value.isNullOrEmpty() || value == "sw-auto" -> Auto + value == "sw-none" -> None + else -> Specific(value) } + } } class StoreProduct( diff --git a/superwall/src/main/java/com/superwall/sdk/store/abstractions/product/StoreProductType.kt b/superwall/src/main/java/com/superwall/sdk/store/abstractions/product/StoreProductType.kt index ecf3dd7c7..272686cab 100644 --- a/superwall/src/main/java/com/superwall/sdk/store/abstractions/product/StoreProductType.kt +++ b/superwall/src/main/java/com/superwall/sdk/store/abstractions/product/StoreProductType.kt @@ -81,7 +81,8 @@ interface StoreProductType { attributes["languageCode"] = languageCode ?: "n/a" attributes["currencyCode"] = currencyCode ?: "n/a" attributes["currencySymbol"] = currencySymbol ?: "n/a" - attributes["identifier"] = productIdentifier + attributes["identifier"] = fullIdentifier + attributes["productIdentifier"] = productIdentifier return attributes } diff --git a/superwall/src/test/java/com/superwall/sdk/analytics/internal/trackable/InternalSuperwallEventTest.kt b/superwall/src/test/java/com/superwall/sdk/analytics/internal/trackable/InternalSuperwallEventTest.kt index 6c9f3d0bc..6f0fb84c9 100644 --- a/superwall/src/test/java/com/superwall/sdk/analytics/internal/trackable/InternalSuperwallEventTest.kt +++ b/superwall/src/test/java/com/superwall/sdk/analytics/internal/trackable/InternalSuperwallEventTest.kt @@ -540,7 +540,7 @@ class InternalSuperwallEventTest { runTest { Given("an abandoned transaction") { val paywallInfo = stubPaywallInfo() - val product = stubStoreProduct(productId = "prod_1") + val product = stubStoreProduct(productId = "prod_1", fullId = "prod_1:option:offer") val event = InternalSuperwallEvent.Transaction( state = InternalSuperwallEvent.Transaction.State.Abandon(product), @@ -556,8 +556,8 @@ class InternalSuperwallEventTest { When("audience filters are requested") { val filters = event.audienceFilterParams - Then("the abandoned product identifier is included") { - assertEquals("prod_1", filters["abandoned_product_id"]) + Then("the abandoned product full identifier is included") { + assertEquals("prod_1:option:offer", filters["abandoned_product_id"]) } } } diff --git a/superwall/src/test/java/com/superwall/sdk/billing/DecomposedProductIdsTest.kt b/superwall/src/test/java/com/superwall/sdk/billing/DecomposedProductIdsTest.kt new file mode 100644 index 000000000..af16a19a8 --- /dev/null +++ b/superwall/src/test/java/com/superwall/sdk/billing/DecomposedProductIdsTest.kt @@ -0,0 +1,199 @@ +package com.superwall.sdk.billing + +import com.superwall.sdk.store.abstractions.product.BasePlanType +import com.superwall.sdk.store.abstractions.product.OfferType +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +/** + * Tests for [DecomposedProductIds] parsing with all permutations of: + * - Product ID only + * - Product ID + base plan (specific, sw-auto, empty) + * - Product ID + base plan + offer (specific, sw-auto, null) + */ +class DecomposedProductIdsTest { + // ==================== PRODUCT ID ONLY ==================== + + @Test + fun `productId only - returns Auto for both basePlan and offer`() { + val result = DecomposedProductIds.from("my_product") + + assertEquals("my_product", result.subscriptionId) + assertEquals(BasePlanType.Auto, result.basePlanType) + assertEquals(OfferType.Auto, result.offerType) + assertNull(result.basePlanId) + assertNull(result.offerType.specificId) + assertEquals("my_product", result.fullId) + } + + // ==================== SPECIFIC BASE PLAN + NULL OFFER ==================== + + @Test + fun `specific basePlan and null offer - returns Specific basePlan and Auto offer`() { + val result = DecomposedProductIds.from("my_product:monthly_plan") + + assertEquals("my_product", result.subscriptionId) + assertEquals(BasePlanType.Specific("monthly_plan"), result.basePlanType) + assertEquals(OfferType.Auto, result.offerType) + assertEquals("monthly_plan", result.basePlanId) + assertNull(result.offerType.specificId) + } + + // ==================== SPECIFIC BASE PLAN + SW-AUTO OFFER ==================== + + @Test + fun `specific basePlan and sw-auto offer - returns Specific basePlan and Auto offer`() { + val result = DecomposedProductIds.from("my_product:monthly_plan:sw-auto") + + assertEquals("my_product", result.subscriptionId) + assertEquals(BasePlanType.Specific("monthly_plan"), result.basePlanType) + assertEquals(OfferType.Auto, result.offerType) + assertEquals("monthly_plan", result.basePlanId) + assertNull(result.offerType.specificId) + } + + // ==================== SPECIFIC BASE PLAN + SPECIFIC OFFER ==================== + + @Test + fun `specific basePlan and specific offer - returns both Specific`() { + val result = DecomposedProductIds.from("my_product:monthly_plan:free_trial_offer") + + assertEquals("my_product", result.subscriptionId) + assertEquals(BasePlanType.Specific("monthly_plan"), result.basePlanType) + assertEquals(OfferType.Specific("free_trial_offer"), result.offerType) + assertEquals("monthly_plan", result.basePlanId) + assertEquals("free_trial_offer", result.offerType.specificId) + } + + // ==================== SW-AUTO BASE PLAN + NULL OFFER ==================== + + @Test + fun `sw-auto basePlan and null offer - returns Auto for both`() { + val result = DecomposedProductIds.from("my_product:sw-auto") + + assertEquals("my_product", result.subscriptionId) + assertEquals(BasePlanType.Auto, result.basePlanType) + assertEquals(OfferType.Auto, result.offerType) + assertNull(result.basePlanId) + assertNull(result.offerType.specificId) + } + + // ==================== SW-AUTO BASE PLAN + SW-AUTO OFFER ==================== + + @Test + fun `sw-auto basePlan and sw-auto offer - returns Auto for both`() { + val result = DecomposedProductIds.from("my_product:sw-auto:sw-auto") + + assertEquals("my_product", result.subscriptionId) + assertEquals(BasePlanType.Auto, result.basePlanType) + assertEquals(OfferType.Auto, result.offerType) + assertNull(result.basePlanId) + assertNull(result.offerType.specificId) + } + + // ==================== SW-AUTO BASE PLAN + SPECIFIC OFFER ==================== + + @Test + fun `sw-auto basePlan and specific offer - returns Auto basePlan and Specific offer`() { + val result = DecomposedProductIds.from("my_product:sw-auto:promo_offer") + + assertEquals("my_product", result.subscriptionId) + assertEquals(BasePlanType.Auto, result.basePlanType) + assertEquals(OfferType.Specific("promo_offer"), result.offerType) + assertNull(result.basePlanId) + assertEquals("promo_offer", result.offerType.specificId) + } + + // ==================== EMPTY BASE PLAN + NULL OFFER ==================== + + @Test + fun `empty basePlan and null offer - returns Auto for both`() { + val result = DecomposedProductIds.from("my_product:") + + assertEquals("my_product", result.subscriptionId) + assertEquals(BasePlanType.Auto, result.basePlanType) + assertEquals(OfferType.Auto, result.offerType) + assertNull(result.basePlanId) + } + + // ==================== EMPTY BASE PLAN + SW-AUTO OFFER ==================== + + @Test + fun `empty basePlan and sw-auto offer - returns Auto for both`() { + val result = DecomposedProductIds.from("my_product::sw-auto") + + assertEquals("my_product", result.subscriptionId) + assertEquals(BasePlanType.Auto, result.basePlanType) + assertEquals(OfferType.Auto, result.offerType) + assertNull(result.basePlanId) + } + + // ==================== EMPTY BASE PLAN + SPECIFIC OFFER ==================== + + @Test + fun `empty basePlan and specific offer - returns Auto basePlan and Specific offer`() { + val result = DecomposedProductIds.from("my_product::discount_offer") + + assertEquals("my_product", result.subscriptionId) + assertEquals(BasePlanType.Auto, result.basePlanType) + assertEquals(OfferType.Specific("discount_offer"), result.offerType) + assertNull(result.basePlanId) + assertEquals("discount_offer", result.offerType.specificId) + } + + // ==================== SW-NONE OFFER (NO OFFER) ==================== + + @Test + fun `specific basePlan and sw-none offer - returns Specific basePlan and None offer`() { + val result = DecomposedProductIds.from("my_product:monthly_plan:sw-none") + + assertEquals("my_product", result.subscriptionId) + assertEquals(BasePlanType.Specific("monthly_plan"), result.basePlanType) + assertEquals(OfferType.None, result.offerType) + assertEquals("monthly_plan", result.basePlanId) + assertNull(result.offerType.specificId) + } + + @Test + fun `sw-auto basePlan and sw-none offer - returns Auto basePlan and None offer`() { + val result = DecomposedProductIds.from("my_product:sw-auto:sw-none") + + assertEquals("my_product", result.subscriptionId) + assertEquals(BasePlanType.Auto, result.basePlanType) + assertEquals(OfferType.None, result.offerType) + assertNull(result.basePlanId) + assertNull(result.offerType.specificId) + } + + @Test + fun `empty basePlan and sw-none offer - returns Auto basePlan and None offer`() { + val result = DecomposedProductIds.from("my_product::sw-none") + + assertEquals("my_product", result.subscriptionId) + assertEquals(BasePlanType.Auto, result.basePlanType) + assertEquals(OfferType.None, result.offerType) + assertNull(result.basePlanId) + } + + // ==================== FULL ID PRESERVATION ==================== + + @Test + fun `fullId is preserved exactly as input`() { + val inputs = + listOf( + "product", + "product:plan", + "product:plan:offer", + "product:sw-auto", + "product:sw-auto:sw-auto", + "product:plan:sw-none", + "product::offer", + ) + + inputs.forEach { input -> + val result = DecomposedProductIds.from(input) + assertEquals(input, result.fullId) + } + } +} diff --git a/superwall/src/test/java/com/superwall/sdk/models/paywall/PaywallProductIdsTest.kt b/superwall/src/test/java/com/superwall/sdk/models/paywall/PaywallProductIdsTest.kt new file mode 100644 index 000000000..c6728adfd --- /dev/null +++ b/superwall/src/test/java/com/superwall/sdk/models/paywall/PaywallProductIdsTest.kt @@ -0,0 +1,246 @@ +package com.superwall.sdk.models.paywall + +import kotlinx.serialization.json.Json +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * Tests for [Paywall.productIds] initialization behavior. + * + * The issue: When the server sends only `products_v3` (not `products_v2`), + * `productIds` should still be populated from `products_v3` data. + * Otherwise, no products would be fetched from Google Play. + */ +class PaywallProductIdsTest { + private val json = + Json { + ignoreUnknownKeys = true + } + + // Base paywall fields required for deserialization + private fun basePaywallFields() = + """ + "id": "test-id", + "identifier": "test-paywall", + "name": "Test Paywall", + "url": "https://example.com", + "paywalljs_event": "", + "presentation_style_v2": null, + "presentation_style_v3": null, + "presentation_delay": 0, + "presentation_condition": "CHECK_USER_SUBSCRIPTION", + "background_color_hex": "#FFFFFF", + "cache_key": "test-cache-key", + "build_id": "test-build" + """.trimIndent() + + @Test + fun `productIds populated from products_v2 when both v2 and v3 exist`() { + // Given: A paywall JSON with both products_v2 and products_v3 + val paywallJson = + """ + { + ${basePaywallFields()}, + "products_v2": [ + { + "reference_name": "primary", + "sw_composite_product_id": "product1:plan1:offer1", + "store_product": { + "store": "PLAY_STORE", + "product_identifier": "product1", + "base_plan_identifier": "plan1", + "offer": {"type": "SPECIFIED", "offer_identifier": "offer1"} + } + } + ], + "products_v3": [ + { + "sw_composite_product_id": "product1:plan1:offer1", + "reference_name": "primary", + "store_product": { + "store": "PLAY_STORE", + "product_identifier": "product1", + "base_plan_identifier": "plan1", + "offer": {"type": "SPECIFIED", "offer_identifier": "offer1"} + }, + "entitlements": [] + } + ] + } + """.trimIndent() + + // When: Parsing the paywall + val paywall = json.decodeFromString(paywallJson) + + // Then: productIds should be populated from products_v2 + assertEquals(1, paywall.productIds.size) + assertEquals("product1:plan1:offer1", paywall.productIds[0]) + } + + @Test + fun `productIds populated from products_v3 when products_v2 is empty`() { + // Given: A paywall JSON with only products_v3 (products_v2 is empty) + val paywallJson = + """ + { + ${basePaywallFields()}, + "products_v2": [], + "products_v3": [ + { + "sw_composite_product_id": "product1:plan1:offer1", + "reference_name": "primary", + "store_product": { + "store": "PLAY_STORE", + "product_identifier": "product1", + "base_plan_identifier": "plan1", + "offer": {"type": "SPECIFIED", "offer_identifier": "offer1"} + }, + "entitlements": [] + }, + { + "sw_composite_product_id": "product1:plan2:offer2", + "reference_name": "secondary", + "store_product": { + "store": "PLAY_STORE", + "product_identifier": "product1", + "base_plan_identifier": "plan2", + "offer": {"type": "SPECIFIED", "offer_identifier": "offer2"} + }, + "entitlements": [] + } + ] + } + """.trimIndent() + + // When: Parsing the paywall + val paywall = json.decodeFromString(paywallJson) + + // Then: productIds should be populated from products_v3 (fallback) + assertEquals(2, paywall.productIds.size) + assertTrue(paywall.productIds.contains("product1:plan1:offer1")) + assertTrue(paywall.productIds.contains("product1:plan2:offer2")) + } + + @Test + fun `productIds populated from products_v3 when products_v2 is missing`() { + // Given: A paywall JSON without products_v2 field at all + val paywallJson = + """ + { + ${basePaywallFields()}, + "products_v3": [ + { + "sw_composite_product_id": "product1:monthly:trial", + "reference_name": "monthly", + "store_product": { + "store": "PLAY_STORE", + "product_identifier": "product1", + "base_plan_identifier": "monthly", + "offer": {"type": "SPECIFIED", "offer_identifier": "trial"} + }, + "entitlements": [] + } + ] + } + """.trimIndent() + + // When: Parsing the paywall + val paywall = json.decodeFromString(paywallJson) + + // Then: productIds should be populated from products_v3 (fallback) + assertEquals(1, paywall.productIds.size) + assertEquals("product1:monthly:trial", paywall.productIds[0]) + } + + @Test + fun `productIds correctly populated for multiple products with same productId but different plans`() { + // Given: A paywall with products that share the same Google productId but different plans + // This is the scenario that was causing issues: "productid:plan:offer" vs "productid:plan2:offer" + val paywallJson = + """ + { + ${basePaywallFields()}, + "products_v3": [ + { + "sw_composite_product_id": "my_subscription:monthly:free_trial", + "reference_name": "Monthly with Trial", + "store_product": { + "store": "PLAY_STORE", + "product_identifier": "my_subscription", + "base_plan_identifier": "monthly", + "offer": {"type": "SPECIFIED", "offer_identifier": "free_trial"} + }, + "entitlements": [] + }, + { + "sw_composite_product_id": "my_subscription:annual:free_trial", + "reference_name": "Annual with Trial", + "store_product": { + "store": "PLAY_STORE", + "product_identifier": "my_subscription", + "base_plan_identifier": "annual", + "offer": {"type": "SPECIFIED", "offer_identifier": "free_trial"} + }, + "entitlements": [] + } + ] + } + """.trimIndent() + + // When: Parsing the paywall + val paywall = json.decodeFromString(paywallJson) + + // Then: Both product IDs should be present (different plans for same product) + assertEquals(2, paywall.productIds.size) + assertTrue(paywall.productIds.contains("my_subscription:monthly:free_trial")) + assertTrue(paywall.productIds.contains("my_subscription:annual:free_trial")) + + // And: playStoreProducts should also have both + assertEquals(2, paywall.playStoreProducts.size) + } + + @Test + fun `productIds uses fullProductId computed from store product details`() { + // Given: A paywall where compositeId might differ from computed fullProductId + // This tests that the fallback uses fullProductId (computed from store product details) + val paywallJson = + """ + { + ${basePaywallFields()}, + "products_v3": [ + { + "sw_composite_product_id": "product:plan:sw-auto", + "reference_name": "Auto Offer", + "store_product": { + "store": "PLAY_STORE", + "product_identifier": "product", + "base_plan_identifier": "plan", + "offer": {"type": "AUTOMATIC"} + }, + "entitlements": [] + }, + { + "sw_composite_product_id": "product:plan:sw-none", + "reference_name": "No Offer", + "store_product": { + "store": "PLAY_STORE", + "product_identifier": "product", + "base_plan_identifier": "plan", + "offer": {"type": "NO_OFFER"} + }, + "entitlements": [] + } + ] + } + """.trimIndent() + + // When: Parsing the paywall + val paywall = json.decodeFromString(paywallJson) + + // Then: productIds should contain the compositeIds (which are the fullProductIds) + assertEquals(2, paywall.productIds.size) + assertTrue(paywall.productIds.contains("product:plan:sw-auto")) + assertTrue(paywall.productIds.contains("product:plan:sw-none")) + } +} diff --git a/superwall/src/test/java/com/superwall/sdk/products/ProductFetcherTest.kt b/superwall/src/test/java/com/superwall/sdk/products/ProductFetcherTest.kt index 318976238..d468703cf 100644 --- a/superwall/src/test/java/com/superwall/sdk/products/ProductFetcherTest.kt +++ b/superwall/src/test/java/com/superwall/sdk/products/ProductFetcherTest.kt @@ -4,6 +4,7 @@ package com.superwall.sdk.products import com.android.billingclient.api.BillingClient.ProductType import com.android.billingclient.api.ProductDetails.RecurrenceMode +import com.superwall.sdk.store.abstractions.product.BasePlanType import com.superwall.sdk.store.abstractions.product.OfferType import com.superwall.sdk.store.abstractions.product.RawStoreProduct import com.superwall.sdk.store.abstractions.product.StoreProduct @@ -18,6 +19,8 @@ import java.util.Currency import java.util.Locale import kotlin.test.assertEquals import kotlin.test.assertFalse +import kotlin.test.assertNotNull +import kotlin.test.assertNull import kotlin.test.assertTrue class ProductFetcherInstrumentedTest { @@ -308,8 +311,8 @@ class ProductFetcherInstrumentedTest { RawStoreProduct( underlyingProductDetails = productDetails, fullIdentifier = "com.ui_tests.quarterly2:test-2:free-trial-offer", - basePlanId = "test-2", - offerType = OfferType.Offer(id = "free-trial-offer"), + basePlanType = BasePlanType.Specific("test-2"), + offerType = OfferType.Specific("free-trial-offer"), ), ) assertTrue(storeProduct.hasFreeTrial) @@ -359,8 +362,8 @@ class ProductFetcherInstrumentedTest { RawStoreProduct( underlyingProductDetails = productDetails, fullIdentifier = "com.ui_tests.quarterly2:test-2:trial-and-paid-offer", - basePlanId = "test-2", - offerType = OfferType.Offer(id = "trial-and-paid-offer"), + basePlanType = BasePlanType.Specific("test-2"), + offerType = OfferType.Specific("trial-and-paid-offer"), ), ) assertTrue(storeProduct.hasFreeTrial) @@ -409,8 +412,8 @@ class ProductFetcherInstrumentedTest { RawStoreProduct( underlyingProductDetails = productDetails, fullIdentifier = "com.ui_tests.quarterly2:test-4:paid-offer", - basePlanId = "test-4", - offerType = OfferType.Offer(id = "paid-offer"), + basePlanType = BasePlanType.Specific("test-4"), + offerType = OfferType.Specific("paid-offer"), ), ) assertTrue(storeProduct.hasFreeTrial) @@ -461,7 +464,7 @@ class ProductFetcherInstrumentedTest { RawStoreProduct( underlyingProductDetails = productDetails, fullIdentifier = "com.ui_tests.quarterly2:test-2:sw-auto", - basePlanId = "test-2", + basePlanType = BasePlanType.Specific("test-2"), offerType = OfferType.Auto, ), ) @@ -512,7 +515,7 @@ class ProductFetcherInstrumentedTest { RawStoreProduct( underlyingProductDetails = productDetails, fullIdentifier = "com.ui_tests.quarterly2:test-3:sw-auto", - basePlanId = "test-3", + basePlanType = BasePlanType.Specific("test-3"), offerType = OfferType.Auto, ), ) @@ -564,7 +567,7 @@ class ProductFetcherInstrumentedTest { RawStoreProduct( underlyingProductDetails = productDetails, fullIdentifier = "com.ui_tests.quarterly2:test-5:sw-auto", - basePlanId = "test-5", + basePlanType = BasePlanType.Specific("test-5"), offerType = OfferType.Auto, ), ) @@ -609,8 +612,8 @@ class ProductFetcherInstrumentedTest { RawStoreProduct( underlyingProductDetails = productDetails, fullIdentifier = "com.ui_tests.quarterly2:test-5", - basePlanId = "test-5", - offerType = null, + basePlanType = BasePlanType.Specific("test-5"), + offerType = OfferType.Auto, ), ) assertFalse(storeProduct.hasFreeTrial) @@ -654,8 +657,8 @@ class ProductFetcherInstrumentedTest { RawStoreProduct( underlyingProductDetails = productDetails, fullIdentifier = "com.ui_tests.quarterly2:test-5", - basePlanId = "test-5", - offerType = OfferType.Offer(id = "doesnt-exist"), + basePlanType = BasePlanType.Specific("test-5"), + offerType = OfferType.Specific("doesnt-exist"), ), ) assertFalse(storeProduct.hasFreeTrial) @@ -702,8 +705,8 @@ class ProductFetcherInstrumentedTest { RawStoreProduct( underlyingProductDetails = productDetails, fullIdentifier = "com.ui_tests.quarterly2", - basePlanId = null, - offerType = null, + basePlanType = BasePlanType.Auto, + offerType = OfferType.Auto, ), ) assertFalse(storeProduct.hasFreeTrial) @@ -747,8 +750,8 @@ class ProductFetcherInstrumentedTest { RawStoreProduct( underlyingProductDetails = oneTimePurchaseProduct, fullIdentifier = "pro_test_8999_year", - basePlanId = null, - offerType = null, + basePlanType = BasePlanType.Auto, + offerType = OfferType.Auto, ), ) assertFalse(storeProduct.hasFreeTrial) @@ -841,8 +844,8 @@ class ProductFetcherInstrumentedTest { RawStoreProduct( underlyingProductDetails = otpWithPurchaseOptionsProduct, fullIdentifier = "otp_with_options:first-buy-option:fifty-off", - basePlanId = "first-buy-option", - offerType = OfferType.Offer(id = "fifty-off"), + basePlanType = BasePlanType.Specific("first-buy-option"), + offerType = OfferType.Specific("fifty-off"), ), ) @@ -866,8 +869,8 @@ class ProductFetcherInstrumentedTest { RawStoreProduct( underlyingProductDetails = otpWithPurchaseOptionsProduct, fullIdentifier = "otp_with_options:second-buy-option:nonexistent-offer", - basePlanId = "second-buy-option", - offerType = OfferType.Offer(id = "nonexistent-offer"), + basePlanType = BasePlanType.Specific("second-buy-option"), + offerType = OfferType.Specific("nonexistent-offer"), ), ) @@ -887,8 +890,8 @@ class ProductFetcherInstrumentedTest { RawStoreProduct( underlyingProductDetails = otpWithPurchaseOptionsProduct, fullIdentifier = "otp_with_options::fifty-off", - basePlanId = "", // Auto/empty - offerType = OfferType.Offer(id = "fifty-off"), + basePlanType = BasePlanType.Auto, + offerType = OfferType.Specific("fifty-off"), ), ) @@ -908,7 +911,7 @@ class ProductFetcherInstrumentedTest { RawStoreProduct( underlyingProductDetails = otpWithPurchaseOptionsProduct, fullIdentifier = "otp_with_options:first-buy-option:sw-auto", - basePlanId = "first-buy-option", + basePlanType = BasePlanType.Specific("first-buy-option"), offerType = OfferType.Auto, ), ) @@ -929,7 +932,7 @@ class ProductFetcherInstrumentedTest { RawStoreProduct( underlyingProductDetails = otpWithPurchaseOptionsProduct, fullIdentifier = "otp_with_options:second-buy-option:sw-auto", - basePlanId = "second-buy-option", + basePlanType = BasePlanType.Specific("second-buy-option"), offerType = OfferType.Auto, ), ) @@ -950,7 +953,7 @@ class ProductFetcherInstrumentedTest { RawStoreProduct( underlyingProductDetails = otpWithPurchaseOptionsProduct, fullIdentifier = "otp_with_options::sw-auto", - basePlanId = "", // Auto + basePlanType = BasePlanType.Auto, offerType = OfferType.Auto, ), ) @@ -1001,7 +1004,7 @@ class ProductFetcherInstrumentedTest { RawStoreProduct( underlyingProductDetails = otpNoOffersProduct, fullIdentifier = "otp_no_offers::sw-auto", - basePlanId = "", + basePlanType = BasePlanType.Auto, offerType = OfferType.Auto, ), ) @@ -1021,8 +1024,8 @@ class ProductFetcherInstrumentedTest { RawStoreProduct( underlyingProductDetails = otpWithPurchaseOptionsProduct, fullIdentifier = "otp_with_options:first-buy-option:fifty-off", - basePlanId = "first-buy-option", - offerType = OfferType.Offer(id = "fifty-off"), + basePlanType = BasePlanType.Specific("first-buy-option"), + offerType = OfferType.Specific("fifty-off"), ), ) @@ -1037,6 +1040,41 @@ class ProductFetcherInstrumentedTest { assertEquals("fifty-off", oneTimeOffer.offerId, "Expected offerId fifty-off") } + /** + * Test that OTP with specific purchase option but no discount offer + * has purchaseOptionId set and offerId null. + * This is important because the offerToken is still needed to purchase + * the correct purchase option even without a discount offer. + */ + @Test + fun test_storeProduct_otp_specificOption_noOffer_hasPurchaseOptionIdWithNullOfferId() { + val storeProduct = + StoreProduct( + rawStoreProduct = + RawStoreProduct( + underlyingProductDetails = otpWithPurchaseOptionsProduct, + fullIdentifier = "otp_with_options:second-buy-option:sw-auto", + basePlanType = BasePlanType.Specific("second-buy-option"), + offerType = OfferType.Auto, + ), + ) + + val selectedOffer = storeProduct.rawStoreProduct.selectedOffer + assertNotNull(selectedOffer, "Expected selectedOffer to be non-null") + assertTrue( + selectedOffer is RawStoreProduct.SelectedOfferDetails.OneTime, + "Expected SelectedOfferDetails.OneTime, got ${selectedOffer?.javaClass?.simpleName}", + ) + + val oneTimeOffer = selectedOffer as RawStoreProduct.SelectedOfferDetails.OneTime + // purchaseOptionId should be set even without a discount offer + assertEquals("second-buy-option", oneTimeOffer.purchaseOptionId, "Expected purchaseOptionId second-buy-option") + // offerId should be null since we're using auto offer and there's no discount + assertNull(oneTimeOffer.offerId, "Expected offerId to be null for purchase option without discount") + // The offerToken should still be available for Google Play billing + assertNotNull(oneTimeOffer.underlying.offerToken, "Expected offerToken to be non-null") + } + /** * Test that OTP properties return expected values (no subscription-specific values) */ @@ -1048,8 +1086,8 @@ class ProductFetcherInstrumentedTest { RawStoreProduct( underlyingProductDetails = otpWithPurchaseOptionsProduct, fullIdentifier = "otp_with_options:first-buy-option:fifty-off", - basePlanId = "first-buy-option", - offerType = OfferType.Offer(id = "fifty-off"), + basePlanType = BasePlanType.Specific("first-buy-option"), + offerType = OfferType.Specific("fifty-off"), ), ) diff --git a/superwall/src/test/java/com/superwall/sdk/store/abstractions/product/OfferTypeTest.kt b/superwall/src/test/java/com/superwall/sdk/store/abstractions/product/OfferTypeTest.kt new file mode 100644 index 000000000..556aef087 --- /dev/null +++ b/superwall/src/test/java/com/superwall/sdk/store/abstractions/product/OfferTypeTest.kt @@ -0,0 +1,135 @@ +package com.superwall.sdk.store.abstractions.product + +import com.superwall.sdk.models.product.Offer +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * Tests for [OfferType] factory method and conversions. + */ +class OfferTypeTest { + // ==================== FROM() FACTORY METHOD ==================== + + @Test + fun `from null returns Auto`() { + val result = OfferType.from(null) + assertEquals(OfferType.Auto, result) + } + + @Test + fun `from empty string returns Auto`() { + val result = OfferType.from("") + assertEquals(OfferType.Auto, result) + } + + @Test + fun `from sw-auto returns Auto`() { + val result = OfferType.from("sw-auto") + assertEquals(OfferType.Auto, result) + } + + @Test + fun `from sw-none returns None`() { + val result = OfferType.from("sw-none") + assertEquals(OfferType.None, result) + } + + @Test + fun `from specific value returns Specific with that value`() { + val result = OfferType.from("monthly_plan") + assertEquals(OfferType.Specific("monthly_plan"), result) + assertEquals("monthly_plan", result.specificId) + } + + @Test + fun `from whitespace only returns Specific (not treated as empty)`() { + // Whitespace is treated as a specific value, not empty + val result = OfferType.from(" ") + assertTrue(result is OfferType.Specific) + assertEquals(" ", result.specificId) + } + + // ==================== SPECIFIC ID ==================== + + @Test + fun `Auto specificId returns null`() { + assertNull(OfferType.Auto.specificId) + } + + @Test + fun `None specificId returns null`() { + assertNull(OfferType.None.specificId) + } + + @Test + fun `Specific specificId returns the id`() { + val specific = OfferType.Specific("my_plan") + assertEquals("my_plan", specific.specificId) + } + + // ==================== TO OFFER CONVERSION ==================== + + @Test + fun `Auto toOffer returns Offer Automatic`() { + val result = OfferType.Auto.toOffer() + assertTrue(result is Offer.Automatic) + } + + @Test + fun `None toOffer returns Offer NoOffer`() { + val result = OfferType.None.toOffer() + assertTrue(result is Offer.NoOffer) + } + + @Test + fun `Specific toOffer returns Offer Specified with correct id`() { + val result = OfferType.Specific("promo_offer").toOffer() + assertTrue(result is Offer.Specified) + assertEquals("promo_offer", (result as Offer.Specified).offerIdentifier) + } + + // ==================== EQUALITY ==================== + + @Test + fun `Auto equals Auto`() { + assertEquals(OfferType.Auto, OfferType.Auto) + } + + @Test + fun `None equals None`() { + assertEquals(OfferType.None, OfferType.None) + } + + @Test + fun `Specific with same id are equal`() { + val a = OfferType.Specific("plan_a") + val b = OfferType.Specific("plan_a") + assertEquals(a, b) + } + + @Test + fun `Specific with different ids are not equal`() { + val a = OfferType.Specific("plan_a") + val b = OfferType.Specific("plan_b") + assertTrue(a != b) + } + + @Test + fun `Auto and Specific are not equal`() { + val auto = OfferType.Auto + val specific = OfferType.Specific("plan") + assertTrue(auto != specific) + } + + @Test + fun `Auto and None are not equal`() { + assertTrue(OfferType.Auto != OfferType.None) + } + + @Test + fun `None and Specific are not equal`() { + assertTrue(OfferType.None != OfferType.Specific("plan")) + } +}