Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ data class TransactionProduct(
)

constructor(product: StoreProduct) : this(
id = product.productIdentifier,
id = product.fullIdentifier,
price =
Price(
raw = product.price,
Expand Down
Original file line number Diff line number Diff line change
@@ -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,
)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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()
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,10 @@ data class Paywall(
@SerialName("products_v2")
internal var _productItems: List<ProductItem> = emptyList(),
@kotlinx.serialization.Transient()
var productIds: List<String> = _productItems.map { it.compositeId },
var productIds: List<String> =
_productItems.map { it.compositeId }.ifEmpty {
_productItemsV3.map { it.fullProductId }
},
@kotlinx.serialization.Transient()
var responseLoadingInfo: LoadingInfo = LoadingInfo(),
@kotlinx.serialization.Transient()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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}"
}

Expand Down Expand Up @@ -171,6 +172,7 @@ object PlayStoreSerializer : KSerializer<CrossplatformProduct.StoreProduct.PlayS
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(
Expand Down Expand Up @@ -205,13 +207,14 @@ object PlayStoreSerializer : KSerializer<CrossplatformProduct.StoreProduct.PlayS
val offer =
when (type) {
"AUTOMATIC" -> 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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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"
}
Expand Down Expand Up @@ -166,6 +173,7 @@ object PlayStoreProductSerializer : KSerializer<PlayStoreProduct> {
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(
Expand Down Expand Up @@ -212,6 +220,7 @@ object PlayStoreProductSerializer : KSerializer<PlayStoreProduct> {
val offer =
when (type) {
"AUTOMATIC" -> Offer.Automatic()
"NO_OFFER" -> Offer.NoOffer
"SPECIFIED" -> {
val offerIdentifier =
offerJsonObject["offer_identifier"]?.jsonPrimitive?.content
Expand All @@ -225,7 +234,7 @@ object PlayStoreProductSerializer : KSerializer<PlayStoreProduct> {
LogScope.configManager,
"Unknown offer type for $productIdentifier, fallback to none",
)
Offer.Specified(offerIdentifier = "sw-none")
Offer.NoOffer
}
}

Expand Down Expand Up @@ -397,7 +406,9 @@ object ProductItemSerializer : KSerializer<ProductItem> {
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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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 =
Expand Down Expand Up @@ -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}",
)
}
}
Expand All @@ -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 }
Expand All @@ -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
}
Expand Down Expand Up @@ -359,6 +379,11 @@ class AutomaticPurchaseController(
)
return@queryPurchasesAsync
}
purchasesList.forEach {
scope.launch {
Superwall.instance.consume(it.purchaseToken)
}
}
Comment on lines +382 to +386
Copy link

Choose a reason for hiding this comment

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

this consumes ALL purchases including subscriptions - queryPurchasesOfType is called with both SUBS and INAPP product types at line 317-318, so subscription purchases will incorrectly be consumed

Suggested change
purchasesList.forEach {
scope.launch {
Superwall.instance.consume(it.purchaseToken)
}
}
// Only consume in-app purchases, not subscriptions
if (productType == BillingClient.ProductType.INAPP) {
purchasesList.forEach {
scope.launch {
Superwall.instance.consume(it.purchaseToken)
}
}
}
Prompt To Fix With AI
This is a comment left during a code review.
Path: superwall/src/main/java/com/superwall/sdk/store/AutomaticPurchaseController.kt
Line: 382:386

Comment:
this consumes ALL purchases including subscriptions - `queryPurchasesOfType` is called with both `SUBS` and `INAPP` product types at line 317-318, so subscription purchases will incorrectly be consumed

```suggestion
            // Only consume in-app purchases, not subscriptions
            if (productType == BillingClient.ProductType.INAPP) {
                purchasesList.forEach {
                    scope.launch {
                        Superwall.instance.consume(it.purchaseToken)
                    }
                }
            }
```

How can I resolve this? If you propose a fix, please make it concise.


deferred.complete(purchasesList)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
26 changes: 2 additions & 24 deletions superwall/src/main/java/com/superwall/sdk/store/StoreManager.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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] =
Expand All @@ -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.
Expand Down
Loading
Loading