diff --git a/.github/badges/branches.svg b/.github/badges/branches.svg
index 02d615398..95275ca45 100644
--- a/.github/badges/branches.svg
+++ b/.github/badges/branches.svg
@@ -1 +1 @@
-
\ No newline at end of file
+
\ No newline at end of file
diff --git a/.github/badges/jacoco.svg b/.github/badges/jacoco.svg
index 69a9ecc43..ec50def2a 100644
--- a/.github/badges/jacoco.svg
+++ b/.github/badges/jacoco.svg
@@ -1 +1 @@
-
\ No newline at end of file
+
\ No newline at end of file
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 5a7f5434e..6f58e9a40 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,6 +2,19 @@
The changelog for `Superwall`. Also see the [releases](https://github.com/superwall/Superwall-Android/releases) on GitHub.
+## 2.8.0
+
+## Enhancements
+
+- Adds support for custom store products. Products configured on a custom store in the Superwall dashboard (e.g. Stripe or your own payment backend) can now be attached to paywalls: their metadata (price, subscription period, trial) is fetched from the Superwall API instead of Google Play and templated into the paywall like any other product. Purchases are routed through your `PurchaseController`, bypassing Google Play Billing entirely — check `product.isCustomProduct` to fulfill them via your own payment flow, and grant their entitlements with `Superwall.instance.setSubscriptionStatus(...)` on success. Requires configuring the SDK with a `PurchaseController`.
+- Adds a unified `PurchaseController.purchase(activity, product: StoreProduct, basePlanId, offerId)` method that handles both Google Play and custom store products. For Play products, the underlying `ProductDetails` are available via `product.rawStoreProduct`.
+- Custom purchases produce full transaction analytics (`transaction_start`/`transaction_complete`, `subscriptionStart`/`freeTrialStart`) with an SDK-generated transaction identifier exposed as `StoreProduct.customTransactionId`, and free-trial eligibility for custom products is derived from the customer's entitlement history.
+- Renames `TestStoreProduct` to `ApiStoreProduct`, now shared between test mode and custom store products.
+
+## Deprecations
+
+- Deprecates `PurchaseController.purchase(activity, productDetails, basePlanId, offerId)` in favor of the `StoreProduct`-based method above. Existing implementations keep working unchanged — the new method's default implementation routes Google Play purchases to the deprecated one — but purchasing custom store products requires implementing the new method.
+
## 2.7.23
## Fixes
diff --git a/app/src/main/java/com/superwall/superapp/purchase/RevenueCatPurchaseController.kt b/app/src/main/java/com/superwall/superapp/purchase/RevenueCatPurchaseController.kt
index 83a06aadd..6eba3f344 100644
--- a/app/src/main/java/com/superwall/superapp/purchase/RevenueCatPurchaseController.kt
+++ b/app/src/main/java/com/superwall/superapp/purchase/RevenueCatPurchaseController.kt
@@ -2,6 +2,7 @@ package com.superwall.superapp.purchase
import android.app.Activity
import android.content.Context
+import android.util.Log
import com.android.billingclient.api.ProductDetails
import com.revenuecat.purchases.CustomerInfo
import com.revenuecat.purchases.LogLevel
@@ -27,11 +28,14 @@ import com.superwall.sdk.delegate.RestorationResult
import com.superwall.sdk.delegate.subscription_controller.PurchaseController
import com.superwall.sdk.models.entitlements.Entitlement
import com.superwall.sdk.models.entitlements.SubscriptionStatus
+import com.superwall.sdk.store.Entitlements
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
+import kotlin.String
+import kotlin.collections.mutableListOf
// Extension function to convert callback to suspend function
suspend fun Purchases.awaitProducts(productIds: List): List {
@@ -115,8 +119,10 @@ class RevenueCatPurchaseController(
UpdatedCustomerInfoListener {
private var superwallCustomerInfoJob: Job? = null
private val scope = CoroutineScope(Dispatchers.Main)
+ val purchased: MutableList
init {
+ purchased = mutableListOf()
Purchases.logLevel = LogLevel.DEBUG
Purchases.configure(
PurchasesConfiguration
@@ -175,7 +181,7 @@ class RevenueCatPurchaseController(
emptySet()
}
- val allEntitlements = rcEntitlements + webEntitlements
+ val allEntitlements = rcEntitlements + webEntitlements + purchased.map { Entitlement(it) }
if (allEntitlements.isNotEmpty()) {
setSubscriptionStatus(SubscriptionStatus.Active(allEntitlements))
@@ -189,10 +195,27 @@ class RevenueCatPurchaseController(
*/
override suspend fun purchase(
activity: Activity,
- productDetails: ProductDetails,
+ product: com.superwall.sdk.store.abstractions.product.StoreProduct,
basePlanId: String?,
offerId: String?,
): PurchaseResult {
+ // Custom store products aren't on Google Play — fulfill them through your own
+ // payment flow and grant the entitlements yourself.
+ if (product.isCustomProduct) {
+ // Example only, save the entitlements to storage here to persist
+ purchased.add("custom_entitlement")
+
+ // Sync your subscription status
+ Purchases.sharedInstance.getCustomerInfoWith {
+ updateSubscriptionStatus(it)
+ }
+ return PurchaseResult.Purchased()
+ }
+
+ val productDetails =
+ product.rawStoreProduct?.underlyingProductDetails
+ ?: return PurchaseResult.Failed("Missing product details for ${product.fullIdentifier}")
+
// Find products matching productId from RevenueCat
val products = Purchases.sharedInstance.awaitProducts(listOf(productDetails.productId))
// Choose the product which matches the given base plan.
diff --git a/superwall/src/androidTest/java/com/superwall/sdk/analytics/internal/TrackingLogicTest.kt b/superwall/src/androidTest/java/com/superwall/sdk/analytics/internal/TrackingLogicTest.kt
index 9f0f73bd0..345dc6b20 100644
--- a/superwall/src/androidTest/java/com/superwall/sdk/analytics/internal/TrackingLogicTest.kt
+++ b/superwall/src/androidTest/java/com/superwall/sdk/analytics/internal/TrackingLogicTest.kt
@@ -57,6 +57,12 @@ class TrackingLogicTest {
override suspend fun makeStoreTransaction(transaction: Purchase): StoreTransaction = mockk()
+ override suspend fun makeStoreTransaction(
+ customTransactionId: String,
+ productIdentifier: String,
+ purchaseDate: java.util.Date,
+ ): StoreTransaction = mockk()
+
override suspend fun activeProductIds(): List = emptyList()
override suspend fun makeIdentityManager(): IdentityManager = mockk()
diff --git a/superwall/src/androidTest/java/com/superwall/sdk/store/transactions/TransactionManagerTest.kt b/superwall/src/androidTest/java/com/superwall/sdk/store/transactions/TransactionManagerTest.kt
index cca618f7a..909f00961 100644
--- a/superwall/src/androidTest/java/com/superwall/sdk/store/transactions/TransactionManagerTest.kt
+++ b/superwall/src/androidTest/java/com/superwall/sdk/store/transactions/TransactionManagerTest.kt
@@ -294,7 +294,7 @@ class TransactionManagerTest {
coEvery {
purchaseController.purchase(
any(),
- any(),
+ any(),
"basePlan",
any(),
)
@@ -400,7 +400,7 @@ class TransactionManagerTest {
coEvery {
purchaseController.purchase(
any(),
- any(),
+ any(),
"basePlan",
any(),
)
@@ -525,7 +525,7 @@ class TransactionManagerTest {
coEvery {
purchaseController.purchase(
any(),
- any(),
+ any(),
"basePlan",
any(),
)
@@ -581,7 +581,7 @@ class TransactionManagerTest {
coEvery {
purchaseController.purchase(
any(),
- any(),
+ any(),
"basePlan",
any(),
)
@@ -631,7 +631,7 @@ class TransactionManagerTest {
coEvery {
purchaseController.purchase(
any(),
- any(),
+ any(),
"basePlan",
any(),
)
@@ -676,7 +676,7 @@ class TransactionManagerTest {
coEvery {
purchaseController.purchase(
any(),
- any(),
+ any(),
"basePlan",
any(),
)
@@ -722,7 +722,7 @@ class TransactionManagerTest {
coEvery {
purchaseController.purchase(
any(),
- any(),
+ any(),
any(),
any(),
)
@@ -911,7 +911,7 @@ class TransactionManagerTest {
coEvery {
purchaseController.purchase(
any(),
- any(),
+ any(),
"basePlan",
any(),
)
@@ -957,7 +957,7 @@ class TransactionManagerTest {
coEvery {
purchaseController.purchase(
any(),
- any(),
+ any(),
any(),
any(),
)
@@ -1002,7 +1002,7 @@ class TransactionManagerTest {
coEvery {
purchaseController.purchase(
any(),
- any(),
+ any(),
any(),
any(),
)
@@ -1050,7 +1050,7 @@ class TransactionManagerTest {
coEvery {
purchaseController.purchase(
any(),
- any(),
+ any(),
"basePlan",
any(),
)
@@ -1098,7 +1098,7 @@ class TransactionManagerTest {
coEvery {
purchaseController.purchase(
any(),
- any(),
+ any(),
"basePlan",
any(),
)
diff --git a/superwall/src/main/java/com/superwall/sdk/config/options/SuperwallOptions.kt b/superwall/src/main/java/com/superwall/sdk/config/options/SuperwallOptions.kt
index abb2abda7..5124f69c3 100644
--- a/superwall/src/main/java/com/superwall/sdk/config/options/SuperwallOptions.kt
+++ b/superwall/src/main/java/com/superwall/sdk/config/options/SuperwallOptions.kt
@@ -27,7 +27,7 @@ class SuperwallOptions() {
open val hostDomain: String,
) {
open val baseHost: String
- get() = "api.$hostDomain"
+ get() = "ir-feat-custom-store-and.prd.us-east-1.review-lab.superwall-services.com"
open val collectorHost: String
get() = "collector.$hostDomain"
diff --git a/superwall/src/main/java/com/superwall/sdk/delegate/subscription_controller/PurchaseController.kt b/superwall/src/main/java/com/superwall/sdk/delegate/subscription_controller/PurchaseController.kt
index 41f769372..2ae61f647 100644
--- a/superwall/src/main/java/com/superwall/sdk/delegate/subscription_controller/PurchaseController.kt
+++ b/superwall/src/main/java/com/superwall/sdk/delegate/subscription_controller/PurchaseController.kt
@@ -5,6 +5,7 @@ import androidx.annotation.MainThread
import com.android.billingclient.api.ProductDetails
import com.superwall.sdk.delegate.PurchaseResult
import com.superwall.sdk.delegate.RestorationResult
+import com.superwall.sdk.store.abstractions.product.StoreProduct
/**
* The interface that handles Superwall's subscription-related logic.
@@ -26,19 +27,74 @@ interface PurchaseController {
* Add your purchase logic here and return its result. You can use Android's Billing APIs,
* or if you use RevenueCat, you can call [`Purchases.shared.purchase(product)`](https://revenuecat.github.io/purchases-android-docs/documentation/revenuecat/purchases/purchase(product,completion)).
*
- * @param productDefaults The `ProductDetails` the user would like to purchase.
+ * @param productDetails The `ProductDetails` the user would like to purchase.
* @param basePlanId An optional base plan identifier of the product that's being purchased.
* @param offerId An optional offer identifier of the product that's being purchased.
* @return A `PurchaseResult` object, which is the result of your purchase logic.
* **Note**: Make sure you handle all cases of `PurchaseResult`.
*/
+ @Deprecated(
+ "Implement purchase(activity, product, basePlanId, offerId) instead. " +
+ "It receives a StoreProduct, which also supports custom store products.",
+ ReplaceWith("purchase(activity, product, basePlanId, offerId)"),
+ )
@MainThread
suspend fun purchase(
activity: Activity,
productDetails: ProductDetails,
basePlanId: String?,
offerId: String?,
- ): PurchaseResult
+ ): PurchaseResult =
+ PurchaseResult.Failed(
+ "This PurchaseController does not implement a purchase method. " +
+ "Override purchase(activity, product, basePlanId, offerId).",
+ )
+
+ /**
+ * Called when the user initiates purchasing of a product.
+ *
+ * Add your purchase logic here and return its result. You can use Android's Billing APIs,
+ * or if you use RevenueCat, you can call [`Purchases.shared.purchase(product)`](https://revenuecat.github.io/purchases-android-docs/documentation/revenuecat/purchases/purchase(product,completion)).
+ *
+ * For Google Play products, the underlying `ProductDetails` are available via
+ * [StoreProduct.rawStoreProduct]'s `underlyingProductDetails`.
+ *
+ * **Custom products**: when [StoreProduct.isCustomProduct] is true, the product's metadata
+ * is sourced from the Superwall API rather than Google Play — route the purchase through
+ * your own payment system (e.g. Stripe, web checkout). The SDK does not grant entitlements
+ * for custom products: on a successful purchase you must update the user's entitlements
+ * yourself by calling `Superwall.instance.setSubscriptionStatus(...)`. The product's
+ * [StoreProduct.customTransactionId] is pre-generated by the SDK and used as the original
+ * transaction identifier in analytics.
+ *
+ * The default implementation routes Google Play products to the deprecated
+ * `purchase(activity, productDetails, basePlanId, offerId)` so existing implementations
+ * keep working, and fails for custom products.
+ *
+ * @param activity The `Activity` from which the purchase was initiated.
+ * @param product The Superwall [StoreProduct] the user would like to purchase.
+ * @param basePlanId An optional base plan identifier of the product that's being purchased.
+ * @param offerId An optional offer identifier of the product that's being purchased.
+ * @return A `PurchaseResult` object, which is the result of your purchase logic.
+ * **Note**: Make sure you handle all cases of `PurchaseResult`.
+ */
+ @MainThread
+ suspend fun purchase(
+ activity: Activity,
+ product: StoreProduct,
+ basePlanId: String?,
+ offerId: String?,
+ ): PurchaseResult {
+ val rawStoreProduct =
+ product.rawStoreProduct
+ ?: return PurchaseResult.Failed(
+ "This PurchaseController does not handle custom products. Override " +
+ "purchase(activity, product, basePlanId, offerId) and handle products " +
+ "where isCustomProduct == true to purchase ${product.fullIdentifier}.",
+ )
+ @Suppress("DEPRECATION")
+ return purchase(activity, rawStoreProduct.underlyingProductDetails, basePlanId, offerId)
+ }
/**
* Called when the user initiates a restore.
diff --git a/superwall/src/main/java/com/superwall/sdk/dependencies/DependencyContainer.kt b/superwall/src/main/java/com/superwall/sdk/dependencies/DependencyContainer.kt
index 886f5eca7..86d047bee 100644
--- a/superwall/src/main/java/com/superwall/sdk/dependencies/DependencyContainer.kt
+++ b/superwall/src/main/java/com/superwall/sdk/dependencies/DependencyContainer.kt
@@ -314,7 +314,7 @@ class DependencyContainer(
InternalPurchaseController(
kotlinPurchaseController =
purchaseController
- ?: AutomaticPurchaseController(context, ioScope, entitlements),
+ ?: AutomaticPurchaseController(context, ioScope, { entitlements }),
javaPurchaseController = null,
context,
)
@@ -330,6 +330,7 @@ class DependencyContainer(
customerInfoManager = { customerInfoManager },
)
},
+ getSuperwallProducts = { network.getSuperwallProducts() },
testMode = testMode,
)
@@ -1073,6 +1074,19 @@ class DependencyContainer(
appSessionId = appSessionManager.appSession.id,
)
+ override suspend fun makeStoreTransaction(
+ customTransactionId: String,
+ productIdentifier: String,
+ purchaseDate: Date,
+ ): StoreTransaction =
+ StoreTransaction(
+ customTransactionId = customTransactionId,
+ productIdentifier = productIdentifier,
+ purchaseDate = purchaseDate,
+ configRequestId = configManager.config?.requestId ?: "",
+ appSessionId = appSessionManager.appSession.id,
+ )
+
override suspend fun activeProductIds(): List =
storeManager.receiptManager.purchases.toList()
diff --git a/superwall/src/main/java/com/superwall/sdk/dependencies/FactoryProtocols.kt b/superwall/src/main/java/com/superwall/sdk/dependencies/FactoryProtocols.kt
index 27cc6a212..8f597ca53 100644
--- a/superwall/src/main/java/com/superwall/sdk/dependencies/FactoryProtocols.kt
+++ b/superwall/src/main/java/com/superwall/sdk/dependencies/FactoryProtocols.kt
@@ -49,6 +49,7 @@ import com.superwall.sdk.store.abstractions.transactions.StoreTransaction
import com.superwall.sdk.store.testmode.TestMode
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.StateFlow
+import java.util.Date
interface ApiFactory : JsonFactory {
// TODO: Think of an alternative way such that we don't need to do this:
@@ -238,6 +239,17 @@ interface ConfigManagerFactory {
interface StoreTransactionFactory {
suspend fun makeStoreTransaction(transaction: Purchase): StoreTransaction
+ /**
+ * Builds a StoreTransaction for a custom-product purchase (no Google Play receipt).
+ * [customTransactionId] is the pre-generated UUID used as both original and
+ * store transaction identifier.
+ */
+ suspend fun makeStoreTransaction(
+ customTransactionId: String,
+ productIdentifier: String,
+ purchaseDate: Date,
+ ): StoreTransaction
+
suspend fun activeProductIds(): List
}
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 4cb102c72..16b9a188c 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
@@ -168,6 +168,9 @@ data class Paywall(
val paddleProducts: List
get() = _productItemsV3.filter { it.storeProduct is CrossplatformProduct.StoreProduct.Paddle }
+ val customProducts: List
+ get() = _productItemsV3.filter { it.storeProduct is CrossplatformProduct.StoreProduct.Custom }
+
// Public getter for productItems
var productItems: List
get() = (
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 ce5f3d36c..9f525c656 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
@@ -122,6 +122,18 @@ data class CrossplatformProduct(
)
}
+ @Serializable(with = CustomSerializer::class)
+ @SerialName("CUSTOM")
+ data class Custom(
+ @SerialName("product_identifier")
+ val productIdentifier: String,
+ ) : StoreProduct() {
+ override fun toStoreProductType(): ProductItem.StoreProductType =
+ ProductItem.StoreProductType.Custom(
+ CustomStoreProduct(productIdentifier = productIdentifier),
+ )
+ }
+
@Serializable(with = OtherSerializer::class)
@SerialName("OTHER")
data class Other(
@@ -150,6 +162,7 @@ data class CrossplatformProduct(
is StoreProduct.AppStore -> storeProduct.productIdentifier
is StoreProduct.Stripe -> storeProduct.productIdentifier
is StoreProduct.Paddle -> storeProduct.productIdentifier
+ is StoreProduct.Custom -> storeProduct.productIdentifier
is StoreProduct.Other -> ""
}
}
@@ -331,6 +344,38 @@ object PaddleSerializer : KSerializer
}
}
+object CustomSerializer : KSerializer {
+ override val descriptor: SerialDescriptor = buildClassSerialDescriptor("Custom")
+
+ override fun serialize(
+ encoder: Encoder,
+ value: CrossplatformProduct.StoreProduct.Custom,
+ ) {
+ val jsonEncoder =
+ encoder as? JsonEncoder
+ ?: throw SerializationException("This class can be saved only by Json")
+ val jsonObj =
+ buildJsonObject {
+ put("store", JsonPrimitive("CUSTOM"))
+ put("product_identifier", JsonPrimitive(value.productIdentifier))
+ }
+ jsonEncoder.encodeJsonElement(jsonObj)
+ }
+
+ override fun deserialize(decoder: Decoder): CrossplatformProduct.StoreProduct.Custom {
+ val jsonDecoder =
+ decoder as? JsonDecoder
+ ?: throw SerializationException("This class can be loaded only by Json")
+ val jsonObject = jsonDecoder.decodeJsonElement() as JsonObject
+
+ val productId =
+ jsonObject["product_identifier"]?.jsonPrimitive?.content
+ ?: throw SerializationException("product_identifier is missing")
+
+ return CrossplatformProduct.StoreProduct.Custom(productId)
+ }
+}
+
object OtherSerializer : KSerializer {
override val descriptor: SerialDescriptor = buildClassSerialDescriptor("Other")
@@ -407,7 +452,17 @@ object CrossplatformProductSerializer : KSerializer {
"APP_STORE" -> decoder.json.decodeFromJsonElement(storeProductJsonObject)
"STRIPE" -> decoder.json.decodeFromJsonElement(storeProductJsonObject)
"PADDLE" -> decoder.json.decodeFromJsonElement(storeProductJsonObject)
- "OTHER" -> decoder.json.decodeFromJsonElement(storeProductJsonObject)
+ "CUSTOM" -> decoder.json.decodeFromJsonElement(storeProductJsonObject)
+ "OTHER" -> {
+ // API contract: custom store products are sent with store == OTHER so
+ // SDKs that predate CUSTOM ignore them. Try the custom product shape
+ // first and fall back to Other if the fields don't match.
+ try {
+ decoder.json.decodeFromJsonElement(storeProductJsonObject)
+ } catch (e: SerializationException) {
+ decoder.json.decodeFromJsonElement(storeProductJsonObject)
+ }
+ }
else ->
CrossplatformProduct.StoreProduct.Other(
storeType ?: "OTHER",
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 bec6dd6f1..650c704e0 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
@@ -10,6 +10,8 @@ import kotlinx.serialization.Serializable
import kotlinx.serialization.SerializationException
import kotlinx.serialization.Serializer
import kotlinx.serialization.builtins.ListSerializer
+import kotlinx.serialization.descriptors.PrimitiveKind
+import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor
import kotlinx.serialization.descriptors.SerialDescriptor
import kotlinx.serialization.descriptors.buildClassSerialDescriptor
import kotlinx.serialization.descriptors.listSerialDescriptor
@@ -27,7 +29,7 @@ import kotlinx.serialization.json.jsonArray
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
-@Serializable
+@Serializable(with = StoreSerializer::class)
enum class Store {
@SerialName("PLAY_STORE")
PLAY_STORE,
@@ -44,6 +46,9 @@ enum class Store {
@SerialName("SUPERWALL")
SUPERWALL,
+ @SerialName("CUSTOM")
+ CUSTOM,
+
@SerialName("OTHER")
OTHER,
@@ -57,11 +62,25 @@ enum class Store {
"STRIPE" -> STRIPE
"PADDLE" -> PADDLE
"SUPERWALL" -> SUPERWALL
+ "CUSTOM" -> CUSTOM
else -> OTHER
}
}
}
+// Unknown store types from the backend decode as OTHER instead of throwing,
+// regardless of how the decoding Json instance is configured.
+object StoreSerializer : KSerializer {
+ override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor("Store", PrimitiveKind.STRING)
+
+ override fun serialize(
+ encoder: Encoder,
+ value: Store,
+ ) = encoder.encodeString(value.name)
+
+ override fun deserialize(decoder: Decoder): Store = Store.fromValue(decoder.decodeString())
+}
+
sealed class Offer {
@Serializable
data class Automatic(
@@ -147,6 +166,17 @@ data class PaddleProduct(
get() = productIdentifier
}
+@Serializable
+data class CustomStoreProduct(
+ @SerialName("store")
+ val store: Store = Store.CUSTOM,
+ @SerialName("product_identifier")
+ val productIdentifier: String,
+) {
+ val fullIdentifier: String
+ get() = productIdentifier
+}
+
@Serializable
data class UnknownStoreProduct(
@SerialName("product_identifier")
@@ -269,6 +299,9 @@ object StoreProductSerializer : KSerializer {
is ProductItem.StoreProductType.Paddle ->
jsonEncoder.json.encodeToJsonElement(PaddleProduct.serializer(), value.product)
+ is ProductItem.StoreProductType.Custom ->
+ jsonEncoder.json.encodeToJsonElement(CustomStoreProduct.serializer(), value.product)
+
is ProductItem.StoreProductType.Other ->
jsonEncoder.json.encodeToJsonElement(
UnknownStoreProduct.serializer(),
@@ -319,13 +352,31 @@ object StoreProductSerializer : KSerializer {
ProductItem.StoreProductType.Paddle(product)
}
- Store.SUPERWALL,
- Store.OTHER,
- -> {
+ Store.CUSTOM -> {
+ val product = json.decodeFromJsonElement(CustomStoreProduct.serializer(), jsonObject)
+ ProductItem.StoreProductType.Custom(product)
+ }
+
+ Store.SUPERWALL -> {
val product =
json.decodeFromJsonElement(UnknownStoreProduct.serializer(), jsonObject)
ProductItem.StoreProductType.Other(product)
}
+
+ Store.OTHER -> {
+ // API contract: custom store products are sent with store == OTHER so SDKs
+ // that predate CUSTOM ignore them. Try the custom product shape first and
+ // fall back to Other if the fields don't match.
+ try {
+ val product =
+ json.decodeFromJsonElement(CustomStoreProduct.serializer(), jsonObject)
+ ProductItem.StoreProductType.Custom(product)
+ } catch (e: SerializationException) {
+ val product =
+ json.decodeFromJsonElement(UnknownStoreProduct.serializer(), jsonObject)
+ ProductItem.StoreProductType.Other(product)
+ }
+ }
}
}
}
@@ -366,6 +417,11 @@ data class ProductItem(
val product: PaddleProduct,
) : StoreProductType()
+ @Serializable
+ data class Custom(
+ val product: CustomStoreProduct,
+ ) : StoreProductType()
+
@Serializable
data class Other(
val product: UnknownStoreProduct,
@@ -379,6 +435,7 @@ data class ProductItem(
is StoreProductType.AppStore -> type.product.fullIdentifier
is StoreProductType.Stripe -> type.product.fullIdentifier
is StoreProductType.Paddle -> type.product.fullIdentifier
+ is StoreProductType.Custom -> type.product.fullIdentifier
is StoreProductType.Other -> type.product.productIdentifier
}
@@ -447,6 +504,7 @@ object ProductItemSerializer : KSerializer {
is ProductItem.StoreProductType.AppStore -> storeProductType.product.fullIdentifier
is ProductItem.StoreProductType.Stripe -> storeProductType.product.fullIdentifier
is ProductItem.StoreProductType.Paddle -> storeProductType.product.fullIdentifier
+ is ProductItem.StoreProductType.Custom -> storeProductType.product.fullIdentifier
is ProductItem.StoreProductType.Other -> storeProductType.product.productIdentifier
}
diff --git a/superwall/src/main/java/com/superwall/sdk/paywall/request/PaywallLogic.kt b/superwall/src/main/java/com/superwall/sdk/paywall/request/PaywallLogic.kt
index bde46b80e..12932335e 100644
--- a/superwall/src/main/java/com/superwall/sdk/paywall/request/PaywallLogic.kt
+++ b/superwall/src/main/java/com/superwall/sdk/paywall/request/PaywallLogic.kt
@@ -161,9 +161,47 @@ object PaywallLogic {
customerInfo = customerInfo,
introOfferEligibility = introOfferEligibility,
)
+
+ is ProductItem.StoreProductType.Custom -> {
+ // Custom product trial info lives on the cached StoreProduct's
+ // subscription metadata (fetched from /products). Use the same
+ // entitlement-history check as web products.
+ val trialDays = productsByFullId[productItem.fullProductId]?.trialPeriodDays ?: 0
+ isWebTrialAvailable(
+ name = productItem.name,
+ trialDays = trialDays,
+ entitlements = productItem.entitlements,
+ customerInfo = customerInfo,
+ introOfferEligibility = introOfferEligibility,
+ )
+ }
}
}
+ /**
+ * Entitlement-history-based trial eligibility for a custom (store == CUSTOM) product,
+ * mirroring the check used for web products in [computeHasFreeTrial]. Used at purchase
+ * time so a repeat purchaser who already consumed their trial does not generate a
+ * spurious `freeTrialStart` event — the external payment system charges them immediately.
+ *
+ * Returns false when the product has no trial metadata, when eligibility can't be
+ * verified (e.g. no entitlements or customer info not yet loaded), or when the customer
+ * has ever held one of the product's entitlements.
+ */
+ fun isFreeTrialEligibleForCustomProduct(
+ product: StoreProduct,
+ entitlements: Set,
+ customerInfo: CustomerInfo,
+ introOfferEligibility: IntroOfferEligibility = IntroOfferEligibility.AUTOMATIC,
+ ): Boolean =
+ isWebTrialAvailable(
+ name = product.fullIdentifier,
+ trialDays = product.trialPeriodDays,
+ entitlements = entitlements,
+ customerInfo = customerInfo,
+ introOfferEligibility = introOfferEligibility,
+ )
+
private fun isWebTrialAvailable(
name: String,
trialDays: Int?,
diff --git a/superwall/src/main/java/com/superwall/sdk/paywall/request/PaywallRequestManager.kt b/superwall/src/main/java/com/superwall/sdk/paywall/request/PaywallRequestManager.kt
index 8199fe731..0f099e806 100644
--- a/superwall/src/main/java/com/superwall/sdk/paywall/request/PaywallRequestManager.kt
+++ b/superwall/src/main/java/com/superwall/sdk/paywall/request/PaywallRequestManager.kt
@@ -18,6 +18,7 @@ import com.superwall.sdk.misc.then
import com.superwall.sdk.models.customer.CustomerInfo
import com.superwall.sdk.models.events.EventData
import com.superwall.sdk.models.paywall.Paywall
+import com.superwall.sdk.models.product.ProductItem
import com.superwall.sdk.network.Network
import com.superwall.sdk.paywall.presentation.PaywallInfo
import com.superwall.sdk.paywall.presentation.internal.request.ProductOverride
@@ -297,17 +298,47 @@ class PaywallRequestManager(
var paywall = paywall
paywall = trackProductsLoadStart(paywall, request)
- paywall =
- try {
- getProducts(paywall, request)
- } catch (error: Throwable) {
- throw error
- }
+ try {
+ // Custom products (store == CUSTOM) come from /products, not Play Billing.
+ // A /products failure is fatal — mirror BillingNotAvailable below.
+ fetchAndCacheCustomProducts(paywall)
+ } catch (error: Throwable) {
+ paywall.productsLoadingInfo.failAt = Date()
+ track(
+ InternalSuperwallEvent.PaywallProductsLoad(
+ state = InternalSuperwallEvent.PaywallProductsLoad.State.Fail(error.message),
+ paywallInfo = paywall.getInfo(request.eventData),
+ eventData = request.eventData,
+ ),
+ )
+ throw error
+ }
+ paywall = getProducts(paywall, request)
paywall = trackProductsLoadFinish(paywall, request.eventData)
return@withContext paywall
}
+ /**
+ * Fetches custom products (ProductItem.StoreProductType.Custom) from the Superwall
+ * /products endpoint and caches them in StoreManager so the downstream getProducts
+ * flow finds them already loaded.
+ *
+ * Idempotent: skips entirely when no custom products need refreshing. A /products
+ * failure is propagated (required = true) so [addProducts] surfaces it as a tracked
+ * product-load failure instead of presenting a paywall with missing prices.
+ */
+ private suspend fun fetchAndCacheCustomProducts(paywall: Paywall) {
+ val customIds =
+ paywall.productItems
+ .filter { it.type is ProductItem.StoreProductType.Custom }
+ .map { it.fullProductId }
+ .toSet()
+ if (customIds.isEmpty()) return
+
+ storeManager.fetchAndCacheCustomProducts(customIds, required = true)
+ }
+
private suspend fun getProducts(
paywall: Paywall,
request: PaywallRequest,
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 a3afcb12b..1c2f523c8 100644
--- a/superwall/src/main/java/com/superwall/sdk/store/AutomaticPurchaseController.kt
+++ b/superwall/src/main/java/com/superwall/sdk/store/AutomaticPurchaseController.kt
@@ -29,6 +29,7 @@ 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.abstractions.product.StoreProduct
import com.superwall.sdk.store.transactions.PlayBillingErrors
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.CoroutineScope
@@ -50,7 +51,7 @@ private val BILLING_INSANTIATION_ERROR =
class AutomaticPurchaseController(
var context: Context,
val scope: IOScope,
- val entitlementsInfo: Entitlements,
+ val entitlementsInfo: () -> Entitlements = { Superwall.instance.dependencyContainer.entitlements },
val getBilling: (Context, PurchasesUpdatedListener) -> BillingClient = { ctx, listener ->
try {
BillingClient
@@ -110,7 +111,7 @@ class AutomaticPurchaseController(
LogLevel.error,
LogScope.nativePurchaseController,
"ExternalNativePurchaseController billing client disconnected, " +
- "retrying in $reconnectMilliseconds milliseconds",
+ "retrying in $reconnectMilliseconds milliseconds",
)
CoroutineScope(Dispatchers.IO).launch {
@@ -161,6 +162,13 @@ class AutomaticPurchaseController(
offerId?.let { append(":$it") }
}
+ // The billing flow lives on the deprecated ProductDetails signature; the
+ // PurchaseController default bridges purchase(activity, product, ...) to it.
+ @Deprecated(
+ "Implement purchase(activity, product, basePlanId, offerId) instead. " +
+ "It receives a StoreProduct, which also supports custom store products.",
+ ReplaceWith("purchase(activity, product, basePlanId, offerId)"),
+ )
override suspend fun purchase(
activity: Activity,
productDetails: ProductDetails,
@@ -349,7 +357,7 @@ class AutomaticPurchaseController(
it.products
}.toSet()
.flatMap {
- val res = entitlementsInfo.byProductId(it)
+ val res = entitlementsInfo().byProductId(it)
res
}.toSet()
.let { entitlements ->
@@ -359,7 +367,7 @@ class AutomaticPurchaseController(
message = "Found entitlements: ${entitlements.joinToString { it.id }}",
)
- entitlementsInfo.activeDeviceEntitlements = entitlements
+ entitlementsInfo().activeDeviceEntitlements = entitlements
if (entitlements.isNotEmpty()) {
SubscriptionStatus.Active(
entitlements.map { it.copy(isActive = true) }.toSet(),
diff --git a/superwall/src/main/java/com/superwall/sdk/store/InternalPurchaseController.kt b/superwall/src/main/java/com/superwall/sdk/store/InternalPurchaseController.kt
index f74f12532..5a79661a7 100644
--- a/superwall/src/main/java/com/superwall/sdk/store/InternalPurchaseController.kt
+++ b/superwall/src/main/java/com/superwall/sdk/store/InternalPurchaseController.kt
@@ -7,6 +7,7 @@ import com.superwall.sdk.delegate.PurchaseResult
import com.superwall.sdk.delegate.RestorationResult
import com.superwall.sdk.delegate.subscription_controller.PurchaseController
import com.superwall.sdk.delegate.subscription_controller.PurchaseControllerJava
+import com.superwall.sdk.store.abstractions.product.StoreProduct
import kotlin.coroutines.resume
import kotlin.coroutines.suspendCoroutine
@@ -21,44 +22,59 @@ class InternalPurchaseController(
val hasInternalPurchaseController: Boolean
get() = kotlinPurchaseController is AutomaticPurchaseController
+ @Deprecated(
+ "Implement purchase(activity, product, basePlanId, offerId) instead. " +
+ "It receives a StoreProduct, which also supports custom store products.",
+ ReplaceWith("purchase(activity, product, basePlanId, offerId)"),
+ )
override suspend fun purchase(
activity: Activity,
productDetails: ProductDetails,
basePlanId: String?,
offerId: String?,
): PurchaseResult {
- // TODO: Await beginPurchase with purchasing coordinator: https://linear.app/superwall/issue/SW-2415/[android]-implement-purchasingcoordinator
-
if (kotlinPurchaseController != null) {
+ @Suppress("DEPRECATION")
return kotlinPurchaseController.purchase(activity, productDetails, basePlanId, offerId)
- } else if (javaPurchaseController != null) {
+ }
+ return purchaseWithJavaController(productDetails, basePlanId, offerId)
+ }
+
+ override suspend fun purchase(
+ activity: Activity,
+ product: StoreProduct,
+ basePlanId: String?,
+ offerId: String?,
+ ): PurchaseResult {
+ if (kotlinPurchaseController != null) {
+ return kotlinPurchaseController.purchase(activity, product, basePlanId, offerId)
+ }
+
+ // PurchaseControllerJava predates StoreProduct — bridge Play products to its
+ // ProductDetails signature; custom products can't be represented there.
+ val productDetails = product.rawStoreProduct?.underlyingProductDetails
+ if (productDetails != null) {
+ return purchaseWithJavaController(productDetails, basePlanId, offerId)
+ }
+ return PurchaseResult.Failed(
+ "No PurchaseController configured to handle custom product purchase.",
+ )
+ }
+
+ private suspend fun purchaseWithJavaController(
+ productDetails: ProductDetails,
+ basePlanId: String?,
+ offerId: String?,
+ ): PurchaseResult {
+ if (javaPurchaseController != null) {
return suspendCoroutine { continuation ->
javaPurchaseController.purchase(productDetails, basePlanId, offerId) { result ->
continuation.resume(result)
}
}
- } else {
- // Here is where we would implement our own product purchaser.
- return PurchaseResult.Cancelled()
}
-
-//
-// kotlinPurchaseController?.let {
-// return it.purchase(currentActivity as Activity, sk1Product.skuDetails)
-// }
-//
-// // There used to be a failure if raw store product wasn't present but there isn't anymore...
-// // not sure why
-// }
-//
-// // SW-2217
-// // https://linear.app/superwall/issue/SW-2217/%5Bandroid%5D-%5Bv1%5D-add-back-support-for-javanon-kotlinxcoroutines-purchase
-// // javaPurchaseController?.let {
-// // product.sk1Product?.let { sk1Product ->
-// // return it.purchase(sk1Product)
-// // } ?: return PurchaseResult.failed(PurchaseError.productUnavailable)
-// // }
-// return PurchaseResult.Cancelled()
+ // Here is where we would implement our own product purchaser.
+ return PurchaseResult.Cancelled()
}
override suspend fun restorePurchases(): RestorationResult {
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 744171660..698e6bc4f 100644
--- a/superwall/src/main/java/com/superwall/sdk/store/StoreManager.kt
+++ b/superwall/src/main/java/com/superwall/sdk/store/StoreManager.kt
@@ -9,16 +9,21 @@ import com.superwall.sdk.billing.DecomposedProductIds
import com.superwall.sdk.logger.LogLevel
import com.superwall.sdk.logger.LogScope
import com.superwall.sdk.logger.Logger
+import com.superwall.sdk.misc.Either
+import com.superwall.sdk.misc.fold
import com.superwall.sdk.models.entitlements.Entitlement
import com.superwall.sdk.models.paywall.Paywall
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.network.NetworkError
import com.superwall.sdk.paywall.request.PaywallRequest
+import com.superwall.sdk.store.abstractions.product.ApiStoreProduct
import com.superwall.sdk.store.abstractions.product.StoreProduct
import com.superwall.sdk.store.abstractions.product.receipt.ReceiptManager
import com.superwall.sdk.store.coordinator.ProductsFetcher
import com.superwall.sdk.store.testmode.TestMode
+import com.superwall.sdk.store.testmode.models.SuperwallProductsResponse
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.awaitAll
import java.util.Date
@@ -31,6 +36,9 @@ class StoreManager(
private val track: suspend (InternalSuperwallEvent) -> Unit = {
Superwall.instance.track(it)
},
+ private val getSuperwallProducts: suspend () -> Either = {
+ Either.Success(SuperwallProductsResponse(emptyList()))
+ },
var testMode: TestMode? = null,
) : ProductsFetcher,
StoreKit {
@@ -71,8 +79,10 @@ class StoreManager(
request = request,
)
+ // Iterate all product items (not just Play Store) so custom products surface variables
+ // too; only DebugView consumes this, but a custom product should still show there.
val productAttributes =
- paywall.playStoreProducts.mapNotNull { productItem ->
+ paywall.productItems.mapNotNull { productItem ->
output.productsByFullId[productItem.fullProductId]?.let { storeProduct ->
ProductVariable(
name = productItem.name,
@@ -101,15 +111,88 @@ class StoreManager(
)
val productsById = processingResult.substituteProductsById.toMutableMap()
- val fetchResult = fetchOrAwaitProducts(processingResult.fullProductIdsToLoad)
- for ((id, product) in fetchResult) {
- productsById[id] = product
+ // Try Play Billing first so Play-only lookups never pay for a /products round-trip.
+ val billingError =
+ try {
+ for ((id, product) in fetchOrAwaitProducts(processingResult.fullProductIdsToLoad)) {
+ productsById[id] = product
+ }
+ null
+ } catch (e: Throwable) {
+ e
+ }
+
+ // Anything Play couldn't resolve may be a custom (store == CUSTOM) product, which
+ // doesn't need Play Billing — probe /products only for those.
+ val unresolved = processingResult.fullProductIdsToLoad - productsById.keys
+ if (unresolved.isNotEmpty()) {
+ fetchAndCacheCustomProducts(unresolved, required = false)
+ for (id in unresolved) {
+ getProductFromCache(id)?.let { productsById[id] = it }
+ }
+ }
+
+ // Surface the billing error only if /products didn't cover the gap.
+ if (billingError != null && (processingResult.fullProductIdsToLoad - productsById.keys).isNotEmpty()) {
+ throw billingError
}
return productsById
}
+ /**
+ * Fetches custom (store == CUSTOM) products from the Superwall /products endpoint and
+ * caches them so downstream lookups resolve them from cache instead of Google Play
+ * Billing. Ids that are already cached, empty, or absent from /products are left
+ * untouched (the latter fall through to billing).
+ *
+ * @param candidateIds The product identifiers that may be custom products.
+ * @param required When true — a paywall declared these ids as custom products — a
+ * /products failure is rethrown so the caller can surface a product-load failure.
+ * When false — a best-effort probe where the ids may well be Play products — the
+ * failure is logged and swallowed so the ids fall through to billing.
+ */
+ suspend fun fetchAndCacheCustomProducts(
+ candidateIds: Set,
+ required: Boolean,
+ ) {
+ val idsNeedingRefresh =
+ candidateIds
+ .filterNot { it.isEmpty() }
+ .filterNot { hasCached(it) }
+ .toSet()
+ if (idsNeedingRefresh.isEmpty()) return
+
+ getSuperwallProducts().fold(
+ onSuccess = { response ->
+ val seenIds = mutableSetOf()
+ response.data
+ .filter { it.identifier in idsNeedingRefresh }
+ .forEach { superwallProduct ->
+ if (!seenIds.add(superwallProduct.identifier)) {
+ Logger.debug(
+ LogLevel.warn,
+ LogScope.productsManager,
+ "Duplicate custom product id from /products: ${superwallProduct.identifier}",
+ )
+ return@forEach
+ }
+ val apiProduct = ApiStoreProduct(superwallProduct)
+ cacheProduct(superwallProduct.identifier, StoreProduct.custom(apiProduct))
+ }
+ },
+ onFailure = { error ->
+ Logger.debug(
+ LogLevel.error,
+ LogScope.productsManager,
+ "Failed to fetch custom products: ${error.message}",
+ )
+ if (required) throw error
+ },
+ )
+ }
+
override suspend fun getProducts(
substituteProducts: Map?,
paywall: Paywall,
diff --git a/superwall/src/main/java/com/superwall/sdk/store/testmode/TestStoreProduct.kt b/superwall/src/main/java/com/superwall/sdk/store/abstractions/product/ApiStoreProduct.kt
similarity index 95%
rename from superwall/src/main/java/com/superwall/sdk/store/testmode/TestStoreProduct.kt
rename to superwall/src/main/java/com/superwall/sdk/store/abstractions/product/ApiStoreProduct.kt
index a69e34aad..3a47afafb 100644
--- a/superwall/src/main/java/com/superwall/sdk/store/testmode/TestStoreProduct.kt
+++ b/superwall/src/main/java/com/superwall/sdk/store/abstractions/product/ApiStoreProduct.kt
@@ -1,9 +1,7 @@
-package com.superwall.sdk.store.testmode
+package com.superwall.sdk.store.abstractions.product
-import com.superwall.sdk.store.abstractions.product.PriceFormatterProvider
-import com.superwall.sdk.store.abstractions.product.StoreProductType
-import com.superwall.sdk.store.abstractions.product.SubscriptionPeriod
import com.superwall.sdk.store.testmode.models.SuperwallProduct
+import com.superwall.sdk.store.testmode.models.SuperwallProductPlatform
import com.superwall.sdk.store.testmode.models.SuperwallSubscriptionPeriod
import com.superwall.sdk.utilities.DateUtils
import com.superwall.sdk.utilities.localizedDateFormat
@@ -13,9 +11,18 @@ import java.util.Calendar
import java.util.Date
import java.util.Locale
-class TestStoreProduct(
+/**
+ * StoreProductType backed by a Superwall API product (from the /products endpoint).
+ * Shared between test mode products and custom (CUSTOM-store) products that are not
+ * fetched from Google Play.
+ */
+class ApiStoreProduct(
private val superwallProduct: SuperwallProduct,
) : StoreProductType {
+ /** The platform this product belongs to, as reported by the /products endpoint. */
+ val platform: SuperwallProductPlatform
+ get() = superwallProduct.platform
+
private val priceFormatterProvider = PriceFormatterProvider()
private val priceFormatter by lazy {
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 0cbb1c0e5..af51d222b 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,6 +1,7 @@
package com.superwall.sdk.store.abstractions.product
import com.superwall.sdk.models.product.Offer
+import com.superwall.sdk.store.testmode.models.SuperwallProductPlatform
import kotlinx.serialization.Serializable
import java.util.*
@@ -81,5 +82,30 @@ class StoreProduct private constructor(
) : StoreProductType by backingProduct {
constructor(rawStoreProduct: RawStoreProduct) : this(rawStoreProduct, rawStoreProduct)
+ /**
+ * Whether this is a custom (store == CUSTOM) product — an API-backed product whose
+ * platform is `custom`, purchased through the developer's PurchaseController rather
+ * than Google Play Billing.
+ */
+ val isCustomProduct: Boolean
+ get() = (backingProduct as? ApiStoreProduct)?.platform == SuperwallProductPlatform.CUSTOM
+
constructor(storeProductType: StoreProductType) : this(null, storeProductType)
+
+ /**
+ * Pre-generated transaction id for the current custom-product (store == CUSTOM) purchase,
+ * exposed so the developer can read it inside `purchase(activity, product, ...)`. Regenerated on
+ * each attempt. Because custom instances are cached and shared, this field is only reliable
+ * within a single non-overlapping flow; analytics use a local copy so they stay correct.
+ */
+ var customTransactionId: String? = null
+
+ companion object {
+ /** Builds a StoreProduct backed by an ApiStoreProduct (custom or test-mode product). */
+ fun custom(apiStoreProduct: ApiStoreProduct): StoreProduct =
+ StoreProduct(
+ rawStoreProduct = null,
+ backingProduct = apiStoreProduct,
+ )
+ }
}
diff --git a/superwall/src/main/java/com/superwall/sdk/store/abstractions/transactions/StoreTransaction.kt b/superwall/src/main/java/com/superwall/sdk/store/abstractions/transactions/StoreTransaction.kt
index e8cf0fb75..138911898 100644
--- a/superwall/src/main/java/com/superwall/sdk/store/abstractions/transactions/StoreTransaction.kt
+++ b/superwall/src/main/java/com/superwall/sdk/store/abstractions/transactions/StoreTransaction.kt
@@ -17,6 +17,43 @@ class StoreTransaction(
@SerialName("app_session_id")
val appSessionId: String,
) : StoreTransactionType {
+ /**
+ * Builds a StoreTransaction representing a custom (non-Play-Billing) purchase
+ * completed by an external PurchaseController. The pre-generated
+ * [customTransactionId] is used as both [originalTransactionIdentifier] and
+ * [storeTransactionId], mirroring the iOS CustomStoreTransaction.
+ */
+ constructor(
+ customTransactionId: String,
+ productIdentifier: String,
+ purchaseDate: Date,
+ configRequestId: String,
+ appSessionId: String,
+ ) : this(
+ transaction =
+ GoogleBillingPurchaseTransaction(
+ underlyingSK2Transaction = null,
+ transactionDate = purchaseDate,
+ originalTransactionIdentifier = customTransactionId,
+ state = StoreTransactionState.Purchased,
+ storeTransactionId = customTransactionId,
+ originalTransactionDate = purchaseDate,
+ webOrderLineItemID = null,
+ appBundleId = null,
+ subscriptionGroupId = null,
+ isUpgraded = null,
+ expirationDate = null,
+ offerId = null,
+ revocationDate = null,
+ appAccountToken = null,
+ purchaseToken = "",
+ payment = StorePayment(productIdentifier = productIdentifier, quantity = 1, discountIdentifier = null),
+ signature = null,
+ ),
+ configRequestId = configRequestId,
+ appSessionId = appSessionId,
+ )
+
val id = UUID.randomUUID().toString()
override val transactionDate: Date? get() = transaction.transactionDate
diff --git a/superwall/src/main/java/com/superwall/sdk/store/testmode/TestMode.kt b/superwall/src/main/java/com/superwall/sdk/store/testmode/TestMode.kt
index cef1689fa..2699e365f 100644
--- a/superwall/src/main/java/com/superwall/sdk/store/testmode/TestMode.kt
+++ b/superwall/src/main/java/com/superwall/sdk/store/testmode/TestMode.kt
@@ -18,6 +18,7 @@ import com.superwall.sdk.storage.Storage
import com.superwall.sdk.storage.StoredTestModeSettings
import com.superwall.sdk.storage.TestModeSettings
import com.superwall.sdk.store.Entitlements
+import com.superwall.sdk.store.abstractions.product.ApiStoreProduct
import com.superwall.sdk.store.abstractions.product.StoreProduct
import com.superwall.sdk.store.testmode.models.SuperwallEntitlementRef
import com.superwall.sdk.store.testmode.models.SuperwallProduct
@@ -326,7 +327,7 @@ class TestMode(
val productsByFullId =
androidProducts.associate { superwallProduct ->
- val testProduct = TestStoreProduct(superwallProduct)
+ val testProduct = ApiStoreProduct(superwallProduct)
superwallProduct.identifier to StoreProduct(testProduct)
}
setTestProducts(productsByFullId)
diff --git a/superwall/src/main/java/com/superwall/sdk/store/testmode/models/SuperwallProduct.kt b/superwall/src/main/java/com/superwall/sdk/store/testmode/models/SuperwallProduct.kt
index 19acc928a..39628896a 100644
--- a/superwall/src/main/java/com/superwall/sdk/store/testmode/models/SuperwallProduct.kt
+++ b/superwall/src/main/java/com/superwall/sdk/store/testmode/models/SuperwallProduct.kt
@@ -72,4 +72,7 @@ enum class SuperwallProductPlatform {
@SerialName("superwall")
SUPERWALL,
+
+ @SerialName("custom")
+ CUSTOM,
}
diff --git a/superwall/src/main/java/com/superwall/sdk/store/transactions/TransactionManager.kt b/superwall/src/main/java/com/superwall/sdk/store/transactions/TransactionManager.kt
index 30f19d13e..5d95f9e20 100644
--- a/superwall/src/main/java/com/superwall/sdk/store/transactions/TransactionManager.kt
+++ b/superwall/src/main/java/com/superwall/sdk/store/transactions/TransactionManager.kt
@@ -32,9 +32,11 @@ import com.superwall.sdk.misc.ActivityProvider
import com.superwall.sdk.misc.AlertControllerFactory.AlertProps
import com.superwall.sdk.misc.IOScope
import com.superwall.sdk.misc.launchWithTracking
+import com.superwall.sdk.models.customer.CustomerInfo
import com.superwall.sdk.models.entitlements.Entitlement
import com.superwall.sdk.models.entitlements.SubscriptionStatus
import com.superwall.sdk.paywall.presentation.PaywallInfo
+import com.superwall.sdk.paywall.request.PaywallLogic
import com.superwall.sdk.paywall.presentation.internal.state.PaywallResult
import com.superwall.sdk.paywall.view.PaywallView
import com.superwall.sdk.paywall.view.PaywallViewState
@@ -56,6 +58,8 @@ import kotlinx.coroutines.flow.dropWhile
import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.launch
import kotlinx.coroutines.withTimeoutOrNull
+import java.util.Date
+import java.util.UUID
import java.util.concurrent.ConcurrentHashMap
class TransactionManager(
@@ -79,6 +83,9 @@ class TransactionManager(
private val webEntitlements: () -> Set = {
Superwall.instance.entitlements.web
},
+ private val currentCustomerInfo: () -> CustomerInfo = {
+ Superwall.instance.getCustomerInfo()
+ },
private val showRestoreDialogForWeb: suspend () -> Unit,
private val notifyBackendOfReceipts: suspend () -> Unit = {},
private val refreshReceipt: () -> Unit,
@@ -354,6 +361,12 @@ class TransactionManager(
return result
}
+ // Custom product flow: store == CUSTOM products are handled by the external
+ // PurchaseController, not Google Play Billing.
+ if (product.isCustomProduct) {
+ return handleCustomProductPurchase(product, purchaseSource, shouldDismiss)
+ }
+
val rawStoreProduct =
product.rawStoreProduct
?: return PurchaseResult.Failed("Missing raw store product for ${product.fullIdentifier}")
@@ -361,7 +374,6 @@ class TransactionManager(
message =
"!!! Purchasing product ${rawStoreProduct.hasFreeTrial}",
)
- val productDetails = rawStoreProduct.underlyingProductDetails
val activity =
activityProvider.getCurrentActivity()
?: return PurchaseResult.Failed("Activity not found - required for starting the billing flow")
@@ -370,7 +382,7 @@ class TransactionManager(
val result =
storeManager.purchaseController.purchase(
activity = activity,
- productDetails = productDetails,
+ product = product,
offerId = rawStoreProduct.offerId,
basePlanId = rawStoreProduct.basePlanId,
)
@@ -435,6 +447,163 @@ class TransactionManager(
return result
}
+ /**
+ * Handles purchase of a custom (store == CUSTOM) product. Requires an external
+ * PurchaseController; fails fast with a clear error otherwise. Pre-generates a
+ * UUID transaction identifier on each attempt, routes the purchase through
+ * [PurchaseController.purchase(activity, product, basePlanId, offerId)], and
+ * constructs a StoreTransaction without touching Google Play Billing / receipts.
+ */
+ private suspend fun handleCustomProductPurchase(
+ product: StoreProduct,
+ purchaseSource: PurchaseSource,
+ shouldDismiss: Boolean,
+ ): PurchaseResult {
+ if (!factory.makeHasExternalPurchaseController()) {
+ val message =
+ "Custom product \"${product.fullIdentifier}\" can only be purchased using a " +
+ "PurchaseController. Set one via Superwall.configure(..., purchaseController:) " +
+ "and handle products where isCustomProduct == true in " +
+ "purchase(activity, product, basePlanId, offerId)."
+ log(message = message, error = Error(message))
+ trackFailure(message, product, purchaseSource)
+ if (purchaseSource is PurchaseSource.Internal) {
+ // Surface the misconfiguration on the paywall, mirroring other purchase
+ // failures, instead of silently hiding the spinner.
+ val options = factory.makeSuperwallOptions()
+ val transactionFailExists =
+ factory.makeTriggers().contains(SuperwallEvents.TransactionFail.rawName)
+ if (options.paywalls.shouldShowPurchaseFailureAlert && !transactionFailExists) {
+ presentAlert(Error(message), product, purchaseSource.state)
+ } else {
+ updateState(
+ purchaseSource.paywallInfo.cacheKey,
+ PaywallViewState.Updates.ToggleSpinner(hidden = true),
+ )
+ }
+ }
+ return PurchaseResult.Failed(message)
+ }
+
+ // Local copy keeps this flow consistent even if a concurrent purchase overwrites the
+ // shared product's field; product.customTransactionId is set for the dev-facing contract.
+ val txnId = UUID.randomUUID().toString()
+ product.customTransactionId = txnId
+
+ // Entitlement-history-based eligibility, not raw trial metadata: a repeat purchaser who
+ // already used their trial is charged immediately and must not emit freeTrialStart.
+ val didStartFreeTrial =
+ PaywallLogic.isFreeTrialEligibleForCustomProduct(
+ product = product,
+ entitlements = entitlementsById(product.fullIdentifier),
+ customerInfo = currentCustomerInfo(),
+ )
+
+ prepareToPurchase(product, purchaseSource)
+
+ // prepareToPurchase skips the Start event for an external-only controller, but a custom
+ // purchase is otherwise invisible to Superwall — emit Start so it pairs with the Complete
+ // tracked on success and transaction funnels stay balanced.
+ val isExternalOnly =
+ purchaseSource is PurchaseSource.ExternalPurchase &&
+ factory.makeHasExternalPurchaseController() &&
+ !factory.makeHasInternalPurchaseController()
+ if (isExternalOnly) {
+ track(
+ InternalSuperwallEvent.Transaction(
+ InternalSuperwallEvent.Transaction.State.Start(product),
+ PaywallInfo.empty(),
+ product,
+ null,
+ isObserved = false,
+ source = TransactionSource.EXTERNAL,
+ demandScore = factory.demandScore(),
+ demandTier = factory.demandTier(),
+ ),
+ )
+ }
+
+ val activity =
+ activityProvider.getCurrentActivity()
+ ?: return PurchaseResult.Failed("Activity not found - required for starting the purchase flow")
+ val result =
+ storeManager.purchaseController.purchase(
+ activity = activity,
+ product = product,
+ basePlanId = null,
+ offerId = null,
+ )
+
+ // For an external-only controller the dev's flow owns the rest of the lifecycle; still
+ // build + track the transaction so analytics include the custom txn id.
+ if (isExternalOnly) {
+ if (result is PurchaseResult.Purchased) {
+ val transaction =
+ factory.makeStoreTransaction(
+ customTransactionId = txnId,
+ productIdentifier = product.fullIdentifier,
+ purchaseDate = Date(),
+ )
+ trackTransactionDidSucceed(transaction, product, purchaseSource, didStartFreeTrial)
+ }
+ return result
+ }
+
+ when (result) {
+ is PurchaseResult.Purchased -> {
+ val transaction =
+ factory.makeStoreTransaction(
+ customTransactionId = txnId,
+ productIdentifier = product.fullIdentifier,
+ purchaseDate = Date(),
+ )
+ // Skip storeManager.loadPurchasedProducts — no Play receipt for custom products.
+ trackTransactionDidSucceed(transaction, product, purchaseSource, didStartFreeTrial)
+ if (shouldDismiss &&
+ purchaseSource is PurchaseSource.Internal &&
+ factory.makeSuperwallOptions().paywalls.automaticallyDismiss
+ ) {
+ dismiss(
+ purchaseSource.paywallInfo.cacheKey,
+ PaywallResult.Purchased(product.fullIdentifier),
+ )
+ } else if (purchaseSource is PurchaseSource.Internal) {
+ updateState(
+ purchaseSource.paywallInfo.cacheKey,
+ PaywallViewState.Updates.SetLoadingState(PaywallLoadingState.Ready),
+ )
+ }
+ }
+
+ is PurchaseResult.Failed -> {
+ trackFailure(result.errorMessage, product, purchaseSource)
+ if (purchaseSource is PurchaseSource.Internal) {
+ val options = factory.makeSuperwallOptions()
+ val triggers = factory.makeTriggers()
+ val transactionFailExists =
+ triggers.contains(SuperwallEvents.TransactionFail.rawName)
+ if (options.paywalls.shouldShowPurchaseFailureAlert && !transactionFailExists) {
+ presentAlert(Error(result.errorMessage), product, purchaseSource.state)
+ } else {
+ updateState(
+ purchaseSource.paywallInfo.cacheKey,
+ PaywallViewState.Updates.ToggleSpinner(hidden = true),
+ )
+ }
+ }
+ }
+
+ is PurchaseResult.Cancelled -> {
+ trackCancelled(product, purchaseSource)
+ }
+
+ is PurchaseResult.Pending -> {
+ handlePendingTransaction(purchaseSource)
+ }
+ }
+ return result
+ }
+
private suspend fun didRestore(
product: StoreProduct? = null,
purchaseSource: PurchaseSource,
diff --git a/superwall/src/test/java/com/superwall/sdk/billing/StorefrontTest.kt b/superwall/src/test/java/com/superwall/sdk/billing/StorefrontTest.kt
index 37649d0ba..9fb4a61c4 100644
--- a/superwall/src/test/java/com/superwall/sdk/billing/StorefrontTest.kt
+++ b/superwall/src/test/java/com/superwall/sdk/billing/StorefrontTest.kt
@@ -14,7 +14,7 @@ import com.superwall.sdk.misc.AppLifecycleObserver
import com.superwall.sdk.misc.IOScope
import com.superwall.sdk.paywall.presentation.PaywallInfo
import com.superwall.sdk.store.StoreManager
-import com.superwall.sdk.store.testmode.TestStoreProduct
+import com.superwall.sdk.store.abstractions.product.ApiStoreProduct
import com.superwall.sdk.store.testmode.models.SuperwallProduct
import com.superwall.sdk.store.testmode.models.SuperwallProductPlatform
import io.mockk.Runs
@@ -135,7 +135,7 @@ class StorefrontTest {
}
private val someProduct =
- TestStoreProduct(
+ ApiStoreProduct(
SuperwallProduct(
identifier = "test_product",
platform = SuperwallProductPlatform.ANDROID,
@@ -193,7 +193,7 @@ class StorefrontTest {
fun testStoreProduct_exposesBackendProvidedStorefront() {
Given("a test mode product with a backend-provided storefront") {
val product =
- TestStoreProduct(
+ ApiStoreProduct(
SuperwallProduct(
identifier = "test_product",
platform = SuperwallProductPlatform.ANDROID,
@@ -212,7 +212,7 @@ class StorefrontTest {
fun storeProduct_withoutStorefront_fallsBackToNa() {
Given("a product without any storefront information") {
val product =
- TestStoreProduct(
+ ApiStoreProduct(
SuperwallProduct(
identifier = "test_product",
platform = SuperwallProductPlatform.ANDROID,
diff --git a/superwall/src/test/java/com/superwall/sdk/models/product/CustomStoreProductTest.kt b/superwall/src/test/java/com/superwall/sdk/models/product/CustomStoreProductTest.kt
new file mode 100644
index 000000000..ed6f36f1a
--- /dev/null
+++ b/superwall/src/test/java/com/superwall/sdk/models/product/CustomStoreProductTest.kt
@@ -0,0 +1,173 @@
+@file:Suppress("ktlint:standard:function-naming")
+
+package com.superwall.sdk.models.product
+
+import com.superwall.sdk.Given
+import com.superwall.sdk.Then
+import com.superwall.sdk.When
+import kotlinx.serialization.json.Json
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertTrue
+import org.junit.Test
+
+class CustomStoreProductTest {
+ private val json =
+ Json {
+ ignoreUnknownKeys = true
+ explicitNulls = false
+ encodeDefaults = true
+ }
+
+ @Test
+ fun `deserializes a CUSTOM store product item via the polymorphic serializer`() {
+ Given("a JSON store_product payload with store CUSTOM") {
+ val payload =
+ """
+ {
+ "store": "CUSTOM",
+ "product_identifier": "stripe_pro_monthly"
+ }
+ """.trimIndent()
+
+ When("decoded via StoreProductSerializer") {
+ val decoded = json.decodeFromString(StoreProductSerializer, payload)
+
+ Then("the result is a Custom variant with the right identifier") {
+ assertTrue(decoded is ProductItem.StoreProductType.Custom)
+ val custom = (decoded as ProductItem.StoreProductType.Custom).product
+ assertEquals("stripe_pro_monthly", custom.productIdentifier)
+ assertEquals(Store.CUSTOM, custom.store)
+ assertEquals("stripe_pro_monthly", custom.fullIdentifier)
+ }
+ }
+ }
+ }
+
+ @Test
+ fun `round-trips CustomStoreProduct through the serializer`() {
+ Given("a Custom store product type") {
+ val original =
+ ProductItem.StoreProductType.Custom(
+ CustomStoreProduct(productIdentifier = "stripe_pro_yearly"),
+ )
+
+ When("encoded then decoded") {
+ val encoded = json.encodeToString(StoreProductSerializer, original)
+ val decoded = json.decodeFromString(StoreProductSerializer, encoded)
+
+ Then("the result equals the original") {
+ assertTrue(decoded is ProductItem.StoreProductType.Custom)
+ assertEquals(
+ original.product.productIdentifier,
+ (decoded as ProductItem.StoreProductType.Custom).product.productIdentifier,
+ )
+ }
+ }
+ }
+ }
+
+ @Test
+ fun `deserializes an OTHER store product with custom fields as Custom`() {
+ Given("a store_product payload with store OTHER carrying a product_identifier") {
+ // API contract: the backend resolves custom store products to OTHER so SDKs
+ // that predate CUSTOM ignore them.
+ val payload =
+ """
+ {
+ "store": "OTHER",
+ "product_identifier": "custom_year_10_trial_week"
+ }
+ """.trimIndent()
+
+ When("decoded via StoreProductSerializer") {
+ val decoded = json.decodeFromString(StoreProductSerializer, payload)
+
+ Then("the result is a Custom variant preserving the wire store value") {
+ assertTrue(decoded is ProductItem.StoreProductType.Custom)
+ val custom = (decoded as ProductItem.StoreProductType.Custom).product
+ assertEquals("custom_year_10_trial_week", custom.productIdentifier)
+ assertEquals(Store.OTHER, custom.store)
+ }
+ }
+ }
+ }
+
+ @Test
+ fun `deserializes a products_v2 OTHER entry as a Custom product item`() {
+ Given("a full products_v2 entry as served by the paywall endpoint") {
+ val payload =
+ """
+ {
+ "sw_composite_product_id": "custom_year_10_trial_week",
+ "reference_name": "primary",
+ "store_product": {
+ "store": "OTHER",
+ "product_identifier": "custom_year_10_trial_week"
+ },
+ "entitlements": [
+ { "identifier": "default", "type": "SERVICE_LEVEL" }
+ ]
+ }
+ """.trimIndent()
+
+ When("decoded as a ProductItem") {
+ val decoded = json.decodeFromString(ProductItemSerializer, payload)
+
+ Then("it is a Custom product with the composite id") {
+ assertTrue(decoded.type is ProductItem.StoreProductType.Custom)
+ assertEquals("custom_year_10_trial_week", decoded.compositeId)
+ assertEquals("custom_year_10_trial_week", decoded.fullProductId)
+ }
+ }
+ }
+ }
+
+ @Test
+ fun `deserializes a crossplatform OTHER store product with custom fields as Custom`() {
+ Given("a crossplatform product payload with store OTHER carrying a product_identifier") {
+ val payload =
+ """
+ {
+ "sw_composite_product_id": "custom_year_10_trial_week",
+ "store_product": {
+ "store": "OTHER",
+ "product_identifier": "custom_year_10_trial_week"
+ },
+ "entitlements": []
+ }
+ """.trimIndent()
+
+ When("decoded as a CrossplatformProduct") {
+ val decoded = json.decodeFromString(CrossplatformProduct.serializer(), payload)
+
+ Then("the store product is the Custom variant") {
+ assertTrue(decoded.storeProduct is CrossplatformProduct.StoreProduct.Custom)
+ assertEquals(
+ "custom_year_10_trial_week",
+ (decoded.storeProduct as CrossplatformProduct.StoreProduct.Custom).productIdentifier,
+ )
+ }
+ }
+ }
+ }
+
+ @Test
+ fun `ProductItem fullProductId returns the identifier for Custom`() {
+ Given("a ProductItem wrapping a Custom store product") {
+ val item =
+ ProductItem(
+ compositeId = "stripe_pro_monthly",
+ name = "pro",
+ type =
+ ProductItem.StoreProductType.Custom(
+ CustomStoreProduct(productIdentifier = "stripe_pro_monthly"),
+ ),
+ entitlements = emptySet(),
+ )
+
+ Then("fullProductId is the underlying identifier") {
+ assertEquals("stripe_pro_monthly", item.fullProductId)
+ }
+ }
+ }
+}
diff --git a/superwall/src/test/java/com/superwall/sdk/paywall/request/PaywallLogicTest.kt b/superwall/src/test/java/com/superwall/sdk/paywall/request/PaywallLogicTest.kt
index 531bff726..7af458f39 100644
--- a/superwall/src/test/java/com/superwall/sdk/paywall/request/PaywallLogicTest.kt
+++ b/superwall/src/test/java/com/superwall/sdk/paywall/request/PaywallLogicTest.kt
@@ -4,6 +4,7 @@ import com.superwall.sdk.models.customer.CustomerInfo
import com.superwall.sdk.models.entitlements.Entitlement
import com.superwall.sdk.models.events.EventData
import com.superwall.sdk.models.paywall.IntroOfferEligibility
+import com.superwall.sdk.models.product.CustomStoreProduct
import com.superwall.sdk.models.product.PaddleProduct
import com.superwall.sdk.models.product.ProductItem
import com.superwall.sdk.models.product.Store
@@ -598,6 +599,97 @@ class PaywallLogicTest {
assertFalse(outcome.isFreeTrialAvailable)
}
+ // ---- Custom (store == CUSTOM) product trial eligibility ----
+
+ private fun customItem(
+ entitlements: Set = setOf(proEntitlement),
+ productId: String = "custom_pro_1",
+ ): ProductItem {
+ val customType =
+ ProductItem.StoreProductType.Custom(
+ CustomStoreProduct(productIdentifier = productId),
+ )
+ return mockk {
+ every { name } returns "primary"
+ every { fullProductId } returns productId
+ every { type } returns customType
+ every { this@mockk.entitlements } returns entitlements
+ }
+ }
+
+ private fun storeProductWithTrialDays(trialDays: Int): StoreProduct =
+ mockk {
+ every { attributes } returns emptyMap()
+ every { trialPeriodDays } returns trialDays
+ }
+
+ @Test
+ fun test_custom_trialAvailable_whenCustomerHasNoEntitlementHistory() {
+ val outcome =
+ PaywallLogic.getVariablesAndFreeTrial(
+ productItems = listOf(customItem()),
+ productsByFullId = mapOf("custom_pro_1" to storeProductWithTrialDays(7)),
+ isFreeTrialAvailableOverride = null,
+ customerInfo = customerInfoWithEntitlements(),
+ )
+
+ assertTrue(outcome.isFreeTrialAvailable)
+ }
+
+ @Test
+ fun test_custom_trialBlocked_whenEntitlementAlreadyHeld() {
+ val consumed = proEntitlement.copy(latestProductId = "custom_pro_old", store = Store.CUSTOM)
+
+ val outcome =
+ PaywallLogic.getVariablesAndFreeTrial(
+ productItems = listOf(customItem()),
+ productsByFullId = mapOf("custom_pro_1" to storeProductWithTrialDays(7)),
+ isFreeTrialAvailableOverride = null,
+ customerInfo = customerInfoWithEntitlements(consumed),
+ )
+
+ assertFalse(outcome.isFreeTrialAvailable)
+ }
+
+ @Test
+ fun test_custom_noTrial_whenTrialDaysZero() {
+ val outcome =
+ PaywallLogic.getVariablesAndFreeTrial(
+ productItems = listOf(customItem()),
+ productsByFullId = mapOf("custom_pro_1" to storeProductWithTrialDays(0)),
+ isFreeTrialAvailableOverride = null,
+ customerInfo = customerInfoWithEntitlements(),
+ )
+
+ assertFalse(outcome.isFreeTrialAvailable)
+ }
+
+ @Test
+ fun test_custom_noTrial_whenEntitlementsEmpty() {
+ val outcome =
+ PaywallLogic.getVariablesAndFreeTrial(
+ productItems = listOf(customItem(entitlements = emptySet())),
+ productsByFullId = mapOf("custom_pro_1" to storeProductWithTrialDays(7)),
+ isFreeTrialAvailableOverride = null,
+ customerInfo = customerInfoWithEntitlements(),
+ )
+
+ assertFalse(outcome.isFreeTrialAvailable)
+ }
+
+ @Test
+ fun test_custom_trialBlocked_whenCustomerInfoIsPlaceholder() {
+ val outcome =
+ PaywallLogic.getVariablesAndFreeTrial(
+ productItems = listOf(customItem()),
+ productsByFullId = mapOf("custom_pro_1" to storeProductWithTrialDays(7)),
+ isFreeTrialAvailableOverride = null,
+ customerInfo = CustomerInfo.empty(),
+ )
+
+ assertFalse(outcome.isFreeTrialAvailable)
+ }
+
@Test
fun test_override_winsOverIneligible() {
val outcome =
@@ -611,4 +703,83 @@ class PaywallLogicTest {
assertTrue(outcome.isFreeTrialAvailable)
}
+
+ // ---- isFreeTrialEligibleForCustomProduct (purchase-time eligibility) ----
+
+ private fun customStoreProduct(
+ trialDays: Int,
+ id: String = "custom_pro_1",
+ ): StoreProduct =
+ mockk {
+ every { fullIdentifier } returns id
+ every { trialPeriodDays } returns trialDays
+ }
+
+ @Test
+ fun test_customEligibility_true_whenTrialAndNoHistory() {
+ assertTrue(
+ PaywallLogic.isFreeTrialEligibleForCustomProduct(
+ product = customStoreProduct(trialDays = 7),
+ entitlements = setOf(proEntitlement),
+ customerInfo = customerInfoWithEntitlements(),
+ ),
+ )
+ }
+
+ @Test
+ fun test_customEligibility_false_whenTrialAlreadyConsumed() {
+ val consumed = proEntitlement.copy(latestProductId = "custom_pro_old", store = Store.CUSTOM)
+ assertFalse(
+ PaywallLogic.isFreeTrialEligibleForCustomProduct(
+ product = customStoreProduct(trialDays = 7),
+ entitlements = setOf(proEntitlement),
+ customerInfo = customerInfoWithEntitlements(consumed),
+ ),
+ )
+ }
+
+ @Test
+ fun test_customEligibility_false_whenNoTrialDays() {
+ assertFalse(
+ PaywallLogic.isFreeTrialEligibleForCustomProduct(
+ product = customStoreProduct(trialDays = 0),
+ entitlements = setOf(proEntitlement),
+ customerInfo = customerInfoWithEntitlements(),
+ ),
+ )
+ }
+
+ @Test
+ fun test_customEligibility_false_whenNoEntitlements() {
+ assertFalse(
+ PaywallLogic.isFreeTrialEligibleForCustomProduct(
+ product = customStoreProduct(trialDays = 7),
+ entitlements = emptySet(),
+ customerInfo = customerInfoWithEntitlements(),
+ ),
+ )
+ }
+
+ @Test
+ fun test_customEligibility_false_whenCustomerInfoIsPlaceholder() {
+ assertFalse(
+ PaywallLogic.isFreeTrialEligibleForCustomProduct(
+ product = customStoreProduct(trialDays = 7),
+ entitlements = setOf(proEntitlement),
+ customerInfo = CustomerInfo.empty(),
+ ),
+ )
+ }
+
+ @Test
+ fun test_customEligibility_false_whenIntroOfferIneligible() {
+ assertFalse(
+ PaywallLogic.isFreeTrialEligibleForCustomProduct(
+ product = customStoreProduct(trialDays = 7),
+ entitlements = setOf(proEntitlement),
+ customerInfo = customerInfoWithEntitlements(),
+ introOfferEligibility = IntroOfferEligibility.INELIGIBLE,
+ ),
+ )
+ }
}
diff --git a/superwall/src/test/java/com/superwall/sdk/store/InternalPurchaseControllerTest.kt b/superwall/src/test/java/com/superwall/sdk/store/InternalPurchaseControllerTest.kt
index f172ec1c0..9c9388bc1 100644
--- a/superwall/src/test/java/com/superwall/sdk/store/InternalPurchaseControllerTest.kt
+++ b/superwall/src/test/java/com/superwall/sdk/store/InternalPurchaseControllerTest.kt
@@ -104,6 +104,61 @@ class InternalPurchaseControllerTest {
}
}
+ @Test
+ fun `test unified purchase delegates to Kotlin controller`() =
+ runTest {
+ Given("an InternalPurchaseController with an external Kotlin controller") {
+ val externalController = mockk()
+ val product = mockk()
+ val expectedResult = PurchaseResult.Purchased()
+
+ coEvery {
+ externalController.purchase(mockActivity, product, "base_plan", "offer")
+ } returns expectedResult
+
+ val controller =
+ InternalPurchaseController(
+ kotlinPurchaseController = externalController,
+ javaPurchaseController = null,
+ context = mockContext,
+ )
+
+ When("calling purchase with a StoreProduct") {
+ val result = controller.purchase(mockActivity, product, "base_plan", "offer")
+
+ Then("it should delegate to the external controller") {
+ assertEquals(expectedResult, result)
+ coVerify(exactly = 1) {
+ externalController.purchase(mockActivity, product, "base_plan", "offer")
+ }
+ }
+ }
+ }
+ }
+
+ @Test
+ fun `test unified purchase of a custom product fails without a Kotlin controller`() =
+ runTest {
+ Given("an InternalPurchaseController with no Kotlin controller") {
+ val product = mockk()
+ every { product.rawStoreProduct } returns null
+ val controller =
+ InternalPurchaseController(
+ kotlinPurchaseController = null,
+ javaPurchaseController = null,
+ context = mockContext,
+ )
+
+ When("calling purchase with a custom product") {
+ val result = controller.purchase(mockActivity, product, null, null)
+
+ Then("it should return Failed") {
+ assertTrue(result is PurchaseResult.Failed)
+ }
+ }
+ }
+ }
+
@Test
fun `test purchase delegates to Kotlin controller`() =
runTest {
diff --git a/superwall/src/test/java/com/superwall/sdk/store/StoreManagerTest.kt b/superwall/src/test/java/com/superwall/sdk/store/StoreManagerTest.kt
index a411b611f..5e8403f16 100644
--- a/superwall/src/test/java/com/superwall/sdk/store/StoreManagerTest.kt
+++ b/superwall/src/test/java/com/superwall/sdk/store/StoreManagerTest.kt
@@ -7,14 +7,21 @@ import com.superwall.sdk.When
import com.superwall.sdk.assertTrue
import com.superwall.sdk.billing.Billing
import com.superwall.sdk.billing.BillingError
+import com.superwall.sdk.misc.Either
import com.superwall.sdk.models.entitlements.Entitlement
import com.superwall.sdk.models.paywall.Paywall
import com.superwall.sdk.models.product.CrossplatformProduct
import com.superwall.sdk.models.product.Offer
+import com.superwall.sdk.network.NetworkError
import com.superwall.sdk.paywall.request.PaywallRequest
import com.superwall.sdk.store.abstractions.product.StoreProduct
import com.superwall.sdk.store.testmode.TestMode
import com.superwall.sdk.store.testmode.TestModeBehavior
+import com.superwall.sdk.store.testmode.models.SuperwallProduct
+import com.superwall.sdk.store.testmode.models.SuperwallProductPlatform
+import com.superwall.sdk.store.testmode.models.SuperwallProductSubscription
+import com.superwall.sdk.store.testmode.models.SuperwallProductsResponse
+import com.superwall.sdk.store.testmode.models.SuperwallSubscriptionPeriod
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.every
@@ -658,6 +665,60 @@ class StoreManagerTest {
}
}
+ // ---- Custom (store == CUSTOM) products ----
+
+ private fun customProductsResponse(vararg ids: String): SuperwallProductsResponse =
+ SuperwallProductsResponse(
+ data =
+ ids.map {
+ SuperwallProduct(
+ identifier = it,
+ platform = SuperwallProductPlatform.CUSTOM,
+ subscription =
+ SuperwallProductSubscription(
+ period = SuperwallSubscriptionPeriod.MONTH,
+ trialPeriodDays = 7,
+ ),
+ )
+ },
+ )
+
+ private fun storeManagerWith(getSuperwallProducts: suspend () -> Either): StoreManager =
+ StoreManager(
+ purchaseController = purchaseController,
+ billing = billing,
+ receiptManagerFactory = { mockk(relaxed = true) },
+ track = {},
+ getSuperwallProducts = getSuperwallProducts,
+ )
+
+ @Test
+ fun `getProductsWithoutPaywall resolves a custom product via products when billing misses`() =
+ runTest {
+ Given("billing that doesn't know the id and a /products response that does") {
+ var productsCalls = 0
+ val manager =
+ storeManagerWith {
+ productsCalls++
+ Either.Success(customProductsResponse("custom_1"))
+ }
+ coEvery { billing.awaitGetProducts(any()) } returns emptySet()
+
+ When("getProductsWithoutPaywall is called for the custom id") {
+ val result = manager.getProductsWithoutPaywall(listOf("custom_1"))
+
+ Then("it resolves from /products as a custom product") {
+ assertEquals("custom_1", result["custom_1"]?.fullIdentifier)
+ junitAssertTrue(result["custom_1"]?.isCustomProduct == true)
+ }
+
+ And("/products was consulted exactly once") {
+ assertEquals(1, productsCalls)
+ }
+ }
+ }
+ }
+
@Test
fun `test getProducts in test mode returns partial test catalog when billing is unavailable`() =
runTest {
@@ -686,6 +747,30 @@ class StoreManagerTest {
}
}
+ @Test
+ fun `getProductsWithoutPaywall does not consult products when billing resolves everything`() =
+ runTest {
+ Given("billing that resolves the Play product") {
+ var productsCalls = 0
+ val manager =
+ storeManagerWith {
+ productsCalls++
+ Either.Success(SuperwallProductsResponse(emptyList()))
+ }
+ val play = mockk { every { fullIdentifier } returns "play_1" }
+ coEvery { billing.awaitGetProducts(any()) } returns setOf(play)
+
+ When("getProductsWithoutPaywall is called") {
+ val result = manager.getProductsWithoutPaywall(listOf("play_1"))
+
+ Then("the Play product is returned without a /products round-trip") {
+ assertEquals(play, result["play_1"])
+ assertEquals(0, productsCalls)
+ }
+ }
+ }
+ }
+
@Test
fun `test getProducts waits for the test catalog instead of racing activation`() =
runTest {
@@ -725,4 +810,53 @@ class StoreManagerTest {
}
}
}
+
+ @Test
+ fun `getProductsWithoutPaywall resolves a custom product even when billing is unavailable`() =
+ runTest {
+ Given("billing that throws and /products that has the custom product") {
+ val manager = storeManagerWith { Either.Success(customProductsResponse("custom_1")) }
+ coEvery { billing.awaitGetProducts(any()) } throws BillingError.BillingNotAvailable("nope")
+
+ When("getProductsWithoutPaywall is called for the custom id") {
+ val result = manager.getProductsWithoutPaywall(listOf("custom_1"))
+
+ Then("it still resolves the custom product without throwing") {
+ junitAssertTrue(result["custom_1"]?.isCustomProduct == true)
+ }
+ }
+ }
+ }
+
+ @Test
+ fun `fetchAndCacheCustomProducts rethrows on products failure when required`() =
+ runTest {
+ Given("a /products endpoint that fails") {
+ val manager = storeManagerWith { Either.Failure(NetworkError.Unknown(Exception("boom"))) }
+
+ When("fetchAndCacheCustomProducts is called with required = true") {
+ Then("it rethrows so the paywall can surface a product-load failure") {
+ assertThrows(NetworkError.Unknown::class.java) {
+ runBlocking { manager.fetchAndCacheCustomProducts(setOf("custom_1"), required = true) }
+ }
+ }
+ }
+ }
+ }
+
+ @Test
+ fun `fetchAndCacheCustomProducts swallows products failure when not required`() =
+ runTest {
+ Given("a /products endpoint that fails") {
+ val manager = storeManagerWith { Either.Failure(NetworkError.Unknown(Exception("boom"))) }
+
+ When("fetchAndCacheCustomProducts is called with required = false") {
+ manager.fetchAndCacheCustomProducts(setOf("custom_1"), required = false)
+
+ Then("it does not throw and caches nothing") {
+ assertNull(manager.getProductFromCache("custom_1"))
+ }
+ }
+ }
+ }
}
diff --git a/superwall/src/test/java/com/superwall/sdk/store/testmode/TestStoreProductTest.kt b/superwall/src/test/java/com/superwall/sdk/store/abstractions/product/ApiStoreProductTest.kt
similarity index 78%
rename from superwall/src/test/java/com/superwall/sdk/store/testmode/TestStoreProductTest.kt
rename to superwall/src/test/java/com/superwall/sdk/store/abstractions/product/ApiStoreProductTest.kt
index 686609ace..c746e059f 100644
--- a/superwall/src/test/java/com/superwall/sdk/store/testmode/TestStoreProductTest.kt
+++ b/superwall/src/test/java/com/superwall/sdk/store/abstractions/product/ApiStoreProductTest.kt
@@ -1,6 +1,6 @@
@file:Suppress("ktlint:standard:function-naming")
-package com.superwall.sdk.store.testmode
+package com.superwall.sdk.store.abstractions.product
import com.superwall.sdk.Given
import com.superwall.sdk.Then
@@ -20,7 +20,7 @@ import org.junit.Assert.assertTrue
import org.junit.Test
import java.math.BigDecimal
-class TestStoreProductTest {
+class ApiStoreProductTest {
private fun makeProduct(
identifier: String = "com.test.product",
amountCents: Int = 999,
@@ -63,7 +63,7 @@ class TestStoreProductTest {
@Test
fun `price converts from cents correctly`() {
Given("a product with price 999 cents") {
- val testProduct = TestStoreProduct(makeProduct(amountCents = 999))
+ val testProduct = ApiStoreProduct(makeProduct(amountCents = 999))
Then("price is 9.99") {
assertEquals(0, testProduct.price.compareTo(BigDecimal("9.99")))
@@ -74,7 +74,7 @@ class TestStoreProductTest {
@Test
fun `price converts zero cents`() {
Given("a product with price 0 cents") {
- val testProduct = TestStoreProduct(makeProduct(amountCents = 0))
+ val testProduct = ApiStoreProduct(makeProduct(amountCents = 0))
Then("price is 0.00") {
assertEquals(0, testProduct.price.compareTo(BigDecimal.ZERO))
@@ -85,7 +85,7 @@ class TestStoreProductTest {
@Test
fun `price converts large amount`() {
Given("a product with price 99999 cents") {
- val testProduct = TestStoreProduct(makeProduct(amountCents = 99999))
+ val testProduct = ApiStoreProduct(makeProduct(amountCents = 99999))
Then("price is 999.99") {
assertEquals(0, testProduct.price.compareTo(BigDecimal("999.99")))
@@ -96,7 +96,7 @@ class TestStoreProductTest {
@Test
fun `price converts single digit cents`() {
Given("a product with price 5 cents") {
- val testProduct = TestStoreProduct(makeProduct(amountCents = 5))
+ val testProduct = ApiStoreProduct(makeProduct(amountCents = 5))
Then("price is 0.05") {
assertEquals(0, testProduct.price.compareTo(BigDecimal("0.05")))
@@ -111,7 +111,7 @@ class TestStoreProductTest {
@Test
fun `subscription period maps daily correctly`() {
Given("a daily product") {
- val testProduct = TestStoreProduct(makeProduct(period = SuperwallSubscriptionPeriod.DAY, periodCount = 1))
+ val testProduct = ApiStoreProduct(makeProduct(period = SuperwallSubscriptionPeriod.DAY, periodCount = 1))
Then("subscription period is 1 day") {
val period = testProduct.subscriptionPeriod
@@ -125,7 +125,7 @@ class TestStoreProductTest {
@Test
fun `subscription period maps weekly correctly`() {
Given("a weekly product") {
- val testProduct = TestStoreProduct(makeProduct(period = SuperwallSubscriptionPeriod.WEEK, periodCount = 1))
+ val testProduct = ApiStoreProduct(makeProduct(period = SuperwallSubscriptionPeriod.WEEK, periodCount = 1))
Then("subscription period is 1 week") {
val period = testProduct.subscriptionPeriod
@@ -139,7 +139,7 @@ class TestStoreProductTest {
@Test
fun `subscription period maps monthly correctly`() {
Given("a monthly product") {
- val testProduct = TestStoreProduct(makeProduct(period = SuperwallSubscriptionPeriod.MONTH, periodCount = 1))
+ val testProduct = ApiStoreProduct(makeProduct(period = SuperwallSubscriptionPeriod.MONTH, periodCount = 1))
Then("subscription period is 1 month") {
val period = testProduct.subscriptionPeriod
@@ -153,7 +153,7 @@ class TestStoreProductTest {
@Test
fun `subscription period maps yearly correctly`() {
Given("a yearly product") {
- val testProduct = TestStoreProduct(makeProduct(period = SuperwallSubscriptionPeriod.YEAR, periodCount = 1))
+ val testProduct = ApiStoreProduct(makeProduct(period = SuperwallSubscriptionPeriod.YEAR, periodCount = 1))
Then("subscription period is 1 year") {
val period = testProduct.subscriptionPeriod
@@ -167,7 +167,7 @@ class TestStoreProductTest {
@Test
fun `subscription period respects periodCount`() {
Given("a product with 3 month period") {
- val testProduct = TestStoreProduct(makeProduct(period = SuperwallSubscriptionPeriod.MONTH, periodCount = 3))
+ val testProduct = ApiStoreProduct(makeProduct(period = SuperwallSubscriptionPeriod.MONTH, periodCount = 3))
Then("subscription period has value 3") {
val period = testProduct.subscriptionPeriod
@@ -181,7 +181,7 @@ class TestStoreProductTest {
@Test
fun `subscription period is null for non-subscription product`() {
Given("a non-subscription product") {
- val testProduct = TestStoreProduct(makeNonSubscriptionProduct())
+ val testProduct = ApiStoreProduct(makeNonSubscriptionProduct())
Then("subscriptionPeriod is null") {
assertNull(testProduct.subscriptionPeriod)
@@ -196,7 +196,7 @@ class TestStoreProductTest {
@Test
fun `free trial is detected correctly`() {
Given("a product with a 7-day trial") {
- val testProduct = TestStoreProduct(makeProduct(trialPeriodDays = 7))
+ val testProduct = ApiStoreProduct(makeProduct(trialPeriodDays = 7))
Then("hasFreeTrial is true") {
assertTrue(testProduct.hasFreeTrial)
@@ -207,7 +207,7 @@ class TestStoreProductTest {
@Test
fun `no free trial when trialPeriodDays is null`() {
Given("a product with no trial") {
- val testProduct = TestStoreProduct(makeProduct(trialPeriodDays = null))
+ val testProduct = ApiStoreProduct(makeProduct(trialPeriodDays = null))
Then("hasFreeTrial is false") {
assertFalse(testProduct.hasFreeTrial)
@@ -218,7 +218,7 @@ class TestStoreProductTest {
@Test
fun `no free trial when trialPeriodDays is zero`() {
Given("a product with 0-day trial") {
- val testProduct = TestStoreProduct(makeProduct(trialPeriodDays = 0))
+ val testProduct = ApiStoreProduct(makeProduct(trialPeriodDays = 0))
Then("hasFreeTrial is false") {
assertFalse(testProduct.hasFreeTrial)
@@ -229,7 +229,7 @@ class TestStoreProductTest {
@Test
fun `trialPeriodText formats correctly`() {
Given("a product with a 7-day trial") {
- val testProduct = TestStoreProduct(makeProduct(trialPeriodDays = 7))
+ val testProduct = ApiStoreProduct(makeProduct(trialPeriodDays = 7))
Then("trialPeriodText is '7-day'") {
assertEquals("7-day", testProduct.trialPeriodText)
@@ -240,7 +240,7 @@ class TestStoreProductTest {
@Test
fun `trialPeriodText is empty when no trial`() {
Given("a product with no trial") {
- val testProduct = TestStoreProduct(makeProduct(trialPeriodDays = null))
+ val testProduct = ApiStoreProduct(makeProduct(trialPeriodDays = null))
Then("trialPeriodText is empty") {
assertEquals("", testProduct.trialPeriodText)
@@ -251,7 +251,7 @@ class TestStoreProductTest {
@Test
fun `trialPeriodEndDate is set when trial exists`() {
Given("a product with a 14-day trial") {
- val testProduct = TestStoreProduct(makeProduct(trialPeriodDays = 14))
+ val testProduct = ApiStoreProduct(makeProduct(trialPeriodDays = 14))
Then("trialPeriodEndDate is not null and in the future") {
assertNotNull(testProduct.trialPeriodEndDate)
@@ -263,7 +263,7 @@ class TestStoreProductTest {
@Test
fun `trialPeriodEndDate is null when no trial`() {
Given("a product with no trial") {
- val testProduct = TestStoreProduct(makeProduct(trialPeriodDays = null))
+ val testProduct = ApiStoreProduct(makeProduct(trialPeriodDays = null))
Then("trialPeriodEndDate is null") {
assertNull(testProduct.trialPeriodEndDate)
@@ -274,7 +274,7 @@ class TestStoreProductTest {
@Test
fun `trialPeriodPrice is zero`() {
Given("a product with a trial") {
- val testProduct = TestStoreProduct(makeProduct(trialPeriodDays = 7))
+ val testProduct = ApiStoreProduct(makeProduct(trialPeriodDays = 7))
Then("trialPeriodPrice is zero") {
assertEquals(0, testProduct.trialPeriodPrice.compareTo(BigDecimal.ZERO))
@@ -289,7 +289,7 @@ class TestStoreProductTest {
@Test
fun `trialPeriodDays returns correct value`() {
Given("a product with a 30-day trial") {
- val testProduct = TestStoreProduct(makeProduct(trialPeriodDays = 30))
+ val testProduct = ApiStoreProduct(makeProduct(trialPeriodDays = 30))
Then("trialPeriodDays is 30") {
assertEquals(30, testProduct.trialPeriodDays)
@@ -301,7 +301,7 @@ class TestStoreProductTest {
@Test
fun `trialPeriodWeeks calculates from days`() {
Given("a product with a 14-day trial") {
- val testProduct = TestStoreProduct(makeProduct(trialPeriodDays = 14))
+ val testProduct = ApiStoreProduct(makeProduct(trialPeriodDays = 14))
Then("trialPeriodWeeks is 2") {
assertEquals(2, testProduct.trialPeriodWeeks)
@@ -313,7 +313,7 @@ class TestStoreProductTest {
@Test
fun `trial period values are zero when no trial`() {
Given("a product with no trial") {
- val testProduct = TestStoreProduct(makeProduct(trialPeriodDays = null))
+ val testProduct = ApiStoreProduct(makeProduct(trialPeriodDays = null))
Then("all trial period values are zero") {
assertEquals(0, testProduct.trialPeriodDays)
@@ -331,7 +331,7 @@ class TestStoreProductTest {
@Test
fun `product identifiers are correct`() {
Given("a product with identifier 'com.test.premium'") {
- val testProduct = TestStoreProduct(makeProduct(identifier = "com.test.premium"))
+ val testProduct = ApiStoreProduct(makeProduct(identifier = "com.test.premium"))
Then("fullIdentifier and productIdentifier are set") {
assertEquals("com.test.premium", testProduct.fullIdentifier)
@@ -347,7 +347,7 @@ class TestStoreProductTest {
@Test
fun `productType is subs for subscription products`() {
Given("a subscription product") {
- val testProduct = TestStoreProduct(makeProduct())
+ val testProduct = ApiStoreProduct(makeProduct())
Then("productType is 'subs'") {
assertEquals("subs", testProduct.productType)
@@ -358,7 +358,7 @@ class TestStoreProductTest {
@Test
fun `productType is inapp for non-subscription products`() {
Given("a non-subscription product") {
- val testProduct = TestStoreProduct(makeNonSubscriptionProduct())
+ val testProduct = ApiStoreProduct(makeNonSubscriptionProduct())
Then("productType is 'inapp'") {
assertEquals("inapp", testProduct.productType)
@@ -373,7 +373,7 @@ class TestStoreProductTest {
@Test
fun `period string formats correctly for monthly`() {
Given("a monthly subscription product") {
- val testProduct = TestStoreProduct(makeProduct(period = SuperwallSubscriptionPeriod.MONTH, periodCount = 1))
+ val testProduct = ApiStoreProduct(makeProduct(period = SuperwallSubscriptionPeriod.MONTH, periodCount = 1))
Then("period is 'month'") {
assertEquals("month", testProduct.period)
@@ -384,7 +384,7 @@ class TestStoreProductTest {
@Test
fun `period string formats correctly for yearly`() {
Given("a yearly subscription product") {
- val testProduct = TestStoreProduct(makeProduct(period = SuperwallSubscriptionPeriod.YEAR, periodCount = 1))
+ val testProduct = ApiStoreProduct(makeProduct(period = SuperwallSubscriptionPeriod.YEAR, periodCount = 1))
Then("period is 'year'") {
assertEquals("year", testProduct.period)
@@ -395,7 +395,7 @@ class TestStoreProductTest {
@Test
fun `period string formats correctly for weekly`() {
Given("a weekly subscription product") {
- val testProduct = TestStoreProduct(makeProduct(period = SuperwallSubscriptionPeriod.WEEK, periodCount = 1))
+ val testProduct = ApiStoreProduct(makeProduct(period = SuperwallSubscriptionPeriod.WEEK, periodCount = 1))
Then("period is 'week'") {
assertEquals("week", testProduct.period)
@@ -406,7 +406,7 @@ class TestStoreProductTest {
@Test
fun `period string for quarterly shows quarter`() {
Given("a 3-month subscription product") {
- val testProduct = TestStoreProduct(makeProduct(period = SuperwallSubscriptionPeriod.MONTH, periodCount = 3))
+ val testProduct = ApiStoreProduct(makeProduct(period = SuperwallSubscriptionPeriod.MONTH, periodCount = 3))
Then("period is 'quarter'") {
assertEquals("quarter", testProduct.period)
@@ -417,7 +417,7 @@ class TestStoreProductTest {
@Test
fun `period string for semi-annual shows 6 months`() {
Given("a 6-month subscription product") {
- val testProduct = TestStoreProduct(makeProduct(period = SuperwallSubscriptionPeriod.MONTH, periodCount = 6))
+ val testProduct = ApiStoreProduct(makeProduct(period = SuperwallSubscriptionPeriod.MONTH, periodCount = 6))
Then("period is '6 months'") {
assertEquals("6 months", testProduct.period)
@@ -428,7 +428,7 @@ class TestStoreProductTest {
@Test
fun `period string for bi-monthly shows 2 months`() {
Given("a 2-month subscription product") {
- val testProduct = TestStoreProduct(makeProduct(period = SuperwallSubscriptionPeriod.MONTH, periodCount = 2))
+ val testProduct = ApiStoreProduct(makeProduct(period = SuperwallSubscriptionPeriod.MONTH, periodCount = 2))
Then("period is '2 months'") {
assertEquals("2 months", testProduct.period)
@@ -439,7 +439,7 @@ class TestStoreProductTest {
@Test
fun `period string for 7-day product shows week`() {
Given("a 7-day subscription product") {
- val testProduct = TestStoreProduct(makeProduct(period = SuperwallSubscriptionPeriod.DAY, periodCount = 7))
+ val testProduct = ApiStoreProduct(makeProduct(period = SuperwallSubscriptionPeriod.DAY, periodCount = 7))
Then("period is 'week'") {
assertEquals("week", testProduct.period)
@@ -450,7 +450,7 @@ class TestStoreProductTest {
@Test
fun `period is empty for non-subscription product`() {
Given("a non-subscription product") {
- val testProduct = TestStoreProduct(makeNonSubscriptionProduct())
+ val testProduct = ApiStoreProduct(makeNonSubscriptionProduct())
Then("period is empty") {
assertEquals("", testProduct.period)
@@ -465,7 +465,7 @@ class TestStoreProductTest {
@Test
fun `periodly formats monthly correctly`() {
Given("a monthly product") {
- val testProduct = TestStoreProduct(makeProduct(period = SuperwallSubscriptionPeriod.MONTH, periodCount = 1))
+ val testProduct = ApiStoreProduct(makeProduct(period = SuperwallSubscriptionPeriod.MONTH, periodCount = 1))
Then("periodly is 'monthly'") {
assertEquals("monthly", testProduct.periodly)
@@ -476,7 +476,7 @@ class TestStoreProductTest {
@Test
fun `periodly formats yearly correctly`() {
Given("a yearly product") {
- val testProduct = TestStoreProduct(makeProduct(period = SuperwallSubscriptionPeriod.YEAR, periodCount = 1))
+ val testProduct = ApiStoreProduct(makeProduct(period = SuperwallSubscriptionPeriod.YEAR, periodCount = 1))
Then("periodly is 'yearly'") {
assertEquals("yearly", testProduct.periodly)
@@ -487,7 +487,7 @@ class TestStoreProductTest {
@Test
fun `periodly formats quarterly as every quarter`() {
Given("a quarterly product") {
- val testProduct = TestStoreProduct(makeProduct(period = SuperwallSubscriptionPeriod.MONTH, periodCount = 3))
+ val testProduct = ApiStoreProduct(makeProduct(period = SuperwallSubscriptionPeriod.MONTH, periodCount = 3))
Then("periodly is 'quarterly'") {
assertEquals("quarterly", testProduct.periodly)
@@ -498,7 +498,7 @@ class TestStoreProductTest {
@Test
fun `periodly formats semi-annual as every 6 months`() {
Given("a 6-month product") {
- val testProduct = TestStoreProduct(makeProduct(period = SuperwallSubscriptionPeriod.MONTH, periodCount = 6))
+ val testProduct = ApiStoreProduct(makeProduct(period = SuperwallSubscriptionPeriod.MONTH, periodCount = 6))
Then("periodly is 'every 6 months'") {
assertEquals("every 6 months", testProduct.periodly)
@@ -509,7 +509,7 @@ class TestStoreProductTest {
@Test
fun `periodly formats bi-monthly as every 2 months`() {
Given("a 2-month product") {
- val testProduct = TestStoreProduct(makeProduct(period = SuperwallSubscriptionPeriod.MONTH, periodCount = 2))
+ val testProduct = ApiStoreProduct(makeProduct(period = SuperwallSubscriptionPeriod.MONTH, periodCount = 2))
Then("periodly is 'every 2 months'") {
assertEquals("every 2 months", testProduct.periodly)
@@ -524,7 +524,7 @@ class TestStoreProductTest {
@Test
fun `periodDays calculates correctly for yearly`() {
Given("a yearly subscription product") {
- val testProduct = TestStoreProduct(makeProduct(period = SuperwallSubscriptionPeriod.YEAR, periodCount = 1))
+ val testProduct = ApiStoreProduct(makeProduct(period = SuperwallSubscriptionPeriod.YEAR, periodCount = 1))
Then("periodDays is 365") {
assertEquals(365, testProduct.periodDays)
@@ -536,7 +536,7 @@ class TestStoreProductTest {
@Test
fun `periodDays calculates correctly for monthly`() {
Given("a monthly subscription product") {
- val testProduct = TestStoreProduct(makeProduct(period = SuperwallSubscriptionPeriod.MONTH, periodCount = 1))
+ val testProduct = ApiStoreProduct(makeProduct(period = SuperwallSubscriptionPeriod.MONTH, periodCount = 1))
Then("periodDays is 30") {
assertEquals(30, testProduct.periodDays)
@@ -547,7 +547,7 @@ class TestStoreProductTest {
@Test
fun `periodDays calculates correctly for weekly`() {
Given("a weekly subscription product") {
- val testProduct = TestStoreProduct(makeProduct(period = SuperwallSubscriptionPeriod.WEEK, periodCount = 1))
+ val testProduct = ApiStoreProduct(makeProduct(period = SuperwallSubscriptionPeriod.WEEK, periodCount = 1))
Then("periodDays is 7") {
assertEquals(7, testProduct.periodDays)
@@ -558,7 +558,7 @@ class TestStoreProductTest {
@Test
fun `periodWeeks calculates correctly for yearly`() {
Given("a yearly subscription product") {
- val testProduct = TestStoreProduct(makeProduct(period = SuperwallSubscriptionPeriod.YEAR, periodCount = 1))
+ val testProduct = ApiStoreProduct(makeProduct(period = SuperwallSubscriptionPeriod.YEAR, periodCount = 1))
Then("periodWeeks is 52") {
assertEquals(52, testProduct.periodWeeks)
@@ -570,7 +570,7 @@ class TestStoreProductTest {
@Test
fun `periodMonths calculates correctly for yearly`() {
Given("a yearly subscription product") {
- val testProduct = TestStoreProduct(makeProduct(period = SuperwallSubscriptionPeriod.YEAR, periodCount = 1))
+ val testProduct = ApiStoreProduct(makeProduct(period = SuperwallSubscriptionPeriod.YEAR, periodCount = 1))
Then("periodMonths is 12") {
assertEquals(12, testProduct.periodMonths)
@@ -582,7 +582,7 @@ class TestStoreProductTest {
@Test
fun `periodYears is 1 for yearly product`() {
Given("a yearly subscription product") {
- val testProduct = TestStoreProduct(makeProduct(period = SuperwallSubscriptionPeriod.YEAR, periodCount = 1))
+ val testProduct = ApiStoreProduct(makeProduct(period = SuperwallSubscriptionPeriod.YEAR, periodCount = 1))
Then("periodYears is 1") {
assertEquals(1, testProduct.periodYears)
@@ -594,7 +594,7 @@ class TestStoreProductTest {
@Test
fun `period calculations are zero for non-subscription product`() {
Given("a non-subscription product") {
- val testProduct = TestStoreProduct(makeNonSubscriptionProduct())
+ val testProduct = ApiStoreProduct(makeNonSubscriptionProduct())
Then("all period values are 0") {
assertEquals(0, testProduct.periodDays)
@@ -612,7 +612,7 @@ class TestStoreProductTest {
@Test
fun `localizedSubscriptionPeriod for monthly`() {
Given("a monthly product") {
- val testProduct = TestStoreProduct(makeProduct(period = SuperwallSubscriptionPeriod.MONTH, periodCount = 1))
+ val testProduct = ApiStoreProduct(makeProduct(period = SuperwallSubscriptionPeriod.MONTH, periodCount = 1))
Then("localizedSubscriptionPeriod is '1 month'") {
assertEquals("1 month", testProduct.localizedSubscriptionPeriod)
@@ -623,7 +623,7 @@ class TestStoreProductTest {
@Test
fun `localizedSubscriptionPeriod for plural months`() {
Given("a 3-month product") {
- val testProduct = TestStoreProduct(makeProduct(period = SuperwallSubscriptionPeriod.MONTH, periodCount = 3))
+ val testProduct = ApiStoreProduct(makeProduct(period = SuperwallSubscriptionPeriod.MONTH, periodCount = 3))
Then("localizedSubscriptionPeriod is '3 months'") {
assertEquals("3 months", testProduct.localizedSubscriptionPeriod)
@@ -634,7 +634,7 @@ class TestStoreProductTest {
@Test
fun `localizedSubscriptionPeriod for yearly`() {
Given("a yearly product") {
- val testProduct = TestStoreProduct(makeProduct(period = SuperwallSubscriptionPeriod.YEAR, periodCount = 1))
+ val testProduct = ApiStoreProduct(makeProduct(period = SuperwallSubscriptionPeriod.YEAR, periodCount = 1))
Then("localizedSubscriptionPeriod is '1 year'") {
assertEquals("1 year", testProduct.localizedSubscriptionPeriod)
@@ -645,7 +645,7 @@ class TestStoreProductTest {
@Test
fun `localizedSubscriptionPeriod is empty for non-subscription`() {
Given("a non-subscription product") {
- val testProduct = TestStoreProduct(makeNonSubscriptionProduct())
+ val testProduct = ApiStoreProduct(makeNonSubscriptionProduct())
Then("localizedSubscriptionPeriod is empty") {
assertEquals("", testProduct.localizedSubscriptionPeriod)
@@ -660,7 +660,7 @@ class TestStoreProductTest {
@Test
fun `currencyCode is set from product`() {
Given("a product with EUR currency") {
- val testProduct = TestStoreProduct(makeProduct(currency = "EUR"))
+ val testProduct = ApiStoreProduct(makeProduct(currency = "EUR"))
Then("currencyCode is EUR") {
assertEquals("EUR", testProduct.currencyCode)
@@ -671,7 +671,7 @@ class TestStoreProductTest {
@Test
fun `currencySymbol resolves for USD`() {
Given("a product with USD currency") {
- val testProduct = TestStoreProduct(makeProduct(currency = "USD"))
+ val testProduct = ApiStoreProduct(makeProduct(currency = "USD"))
Then("currencySymbol is not null and contains dollar sign") {
assertNotNull(testProduct.currencySymbol)
@@ -683,7 +683,7 @@ class TestStoreProductTest {
@Test
fun `currencySymbol resolves for EUR`() {
Given("a product with EUR currency") {
- val testProduct = TestStoreProduct(makeProduct(currency = "EUR"))
+ val testProduct = ApiStoreProduct(makeProduct(currency = "EUR"))
Then("currencySymbol is euro sign") {
// The euro sign can vary by locale, just verify it's not null
@@ -699,7 +699,7 @@ class TestStoreProductTest {
@Test
fun `attributes map contains all expected keys`() {
Given("a subscription product with trial") {
- val testProduct = TestStoreProduct(makeProduct(trialPeriodDays = 7))
+ val testProduct = ApiStoreProduct(makeProduct(trialPeriodDays = 7))
When("attributes is accessed") {
val attrs = testProduct.attributes
@@ -727,7 +727,7 @@ class TestStoreProductTest {
@Test
fun `attributes identifier matches product`() {
Given("a product with identifier 'com.test.pro'") {
- val testProduct = TestStoreProduct(makeProduct(identifier = "com.test.pro"))
+ val testProduct = ApiStoreProduct(makeProduct(identifier = "com.test.pro"))
Then("attributes identifier is correct") {
assertEquals("com.test.pro", testProduct.attributes["identifier"])
diff --git a/superwall/src/test/java/com/superwall/sdk/store/abstractions/transactions/CustomStoreTransactionTest.kt b/superwall/src/test/java/com/superwall/sdk/store/abstractions/transactions/CustomStoreTransactionTest.kt
new file mode 100644
index 000000000..5f29198a3
--- /dev/null
+++ b/superwall/src/test/java/com/superwall/sdk/store/abstractions/transactions/CustomStoreTransactionTest.kt
@@ -0,0 +1,69 @@
+@file:Suppress("ktlint:standard:function-naming")
+
+package com.superwall.sdk.store.abstractions.transactions
+
+import com.superwall.sdk.Given
+import com.superwall.sdk.Then
+import com.superwall.sdk.When
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertNull
+import org.junit.Test
+import java.util.Date
+
+class CustomStoreTransactionTest {
+ @Test
+ fun `custom-transaction constructor sets ids and skips SK2 fields`() {
+ Given("a pre-generated transaction id, product id and purchase date") {
+ val txnId = "ABC-123-CUSTOM"
+ val productId = "stripe_pro_monthly"
+ val purchaseDate = Date(1_700_000_000_000L)
+
+ When("a StoreTransaction is built via the custom constructor") {
+ val tx =
+ StoreTransaction(
+ customTransactionId = txnId,
+ productIdentifier = productId,
+ purchaseDate = purchaseDate,
+ configRequestId = "req-1",
+ appSessionId = "sess-1",
+ )
+
+ Then("originalTransactionIdentifier and storeTransactionId equal the custom id") {
+ assertEquals(txnId, tx.originalTransactionIdentifier)
+ assertEquals(txnId, tx.storeTransactionId)
+ }
+
+ Then("transaction date and original date both equal the purchase date") {
+ assertEquals(purchaseDate, tx.transactionDate)
+ assertEquals(purchaseDate, tx.originalTransactionDate)
+ }
+
+ Then("state is Purchased") {
+ assertEquals(StoreTransactionState.Purchased, tx.state)
+ }
+
+ Then("payment carries the product identifier") {
+ assertEquals(productId, tx.payment?.productIdentifier)
+ }
+
+ Then("SK2 / Play-specific fields are nullable empties") {
+ assertNull(tx.webOrderLineItemID)
+ assertNull(tx.appBundleId)
+ assertNull(tx.subscriptionGroupId)
+ assertNull(tx.isUpgraded)
+ assertNull(tx.expirationDate)
+ assertNull(tx.offerId)
+ assertNull(tx.revocationDate)
+ assertNull(tx.appAccountToken)
+ assertEquals("", tx.purchaseToken)
+ assertNull(tx.signature)
+ }
+
+ Then("config and session ids are preserved") {
+ assertEquals("req-1", tx.configRequestId)
+ assertEquals("sess-1", tx.appSessionId)
+ }
+ }
+ }
+ }
+}
diff --git a/superwall/src/test/java/com/superwall/sdk/store/testmode/models/SuperwallProductSerializationTest.kt b/superwall/src/test/java/com/superwall/sdk/store/testmode/models/SuperwallProductSerializationTest.kt
index 20d5107fd..809f4040f 100644
--- a/superwall/src/test/java/com/superwall/sdk/store/testmode/models/SuperwallProductSerializationTest.kt
+++ b/superwall/src/test/java/com/superwall/sdk/store/testmode/models/SuperwallProductSerializationTest.kt
@@ -58,6 +58,32 @@ class SuperwallProductSerializationTest {
}
}
+ @Test
+ fun `deserializes product with platform custom`() {
+ Given("a JSON product with platform custom") {
+ val jsonString =
+ """
+ {
+ "identifier": "stripe_pro_monthly",
+ "platform": "custom",
+ "price": { "amount": 1499, "currency": "USD" },
+ "subscription": { "period": "month", "period_count": 1, "trial_period_days": 14 }
+ }
+ """.trimIndent()
+
+ When("the product is deserialized") {
+ val product = json.decodeFromString(jsonString)
+
+ Then("platform is CUSTOM and fields are preserved") {
+ assertEquals("stripe_pro_monthly", product.identifier)
+ assertEquals(SuperwallProductPlatform.CUSTOM, product.platform)
+ assertEquals(1499, product.price!!.amount)
+ assertEquals(14, product.subscription!!.trialPeriodDays)
+ }
+ }
+ }
+ }
+
@Test
fun `deserializes product without subscription`() {
Given("a JSON product without subscription field") {
diff --git a/superwall/src/test/java/com/superwall/sdk/store/transactions/TransactionManagerTest.kt b/superwall/src/test/java/com/superwall/sdk/store/transactions/TransactionManagerTest.kt
index 5f57ed892..c3a2888c1 100644
--- a/superwall/src/test/java/com/superwall/sdk/store/transactions/TransactionManagerTest.kt
+++ b/superwall/src/test/java/com/superwall/sdk/store/transactions/TransactionManagerTest.kt
@@ -494,7 +494,7 @@ class TransactionManagerTest {
val product = mockStoreProduct(productId, rawProduct)
every { storeManager.getProductFromCache(productId) } returns product
coEvery {
- storeManager.purchaseController.purchase(any(), any(), any(), any())
+ storeManager.purchaseController.purchase(any(), any(), any(), any())
} returns PurchaseResult.Purchased()
every { factory.makeHasExternalPurchaseController() } returns true
every { factory.makeTransactionVerifier() } returns
diff --git a/version.env b/version.env
index 7e3df4474..adafaa0b1 100644
--- a/version.env
+++ b/version.env
@@ -1 +1 @@
-SUPERWALL_VERSION=2.7.23
+SUPERWALL_VERSION=2.8.0