From 58c6733c6a3878cf7f7a1aced988d2969ec9fa08 Mon Sep 17 00:00:00 2001 From: Ian Rumac Date: Mon, 11 May 2026 15:18:00 +0200 Subject: [PATCH 1/8] Add custom store products, new purchase controller method --- .../PurchaseController.kt | 22 ++++ .../sdk/dependencies/DependencyContainer.kt | 13 ++ .../sdk/dependencies/FactoryProtocols.kt | 11 ++ .../superwall/sdk/models/paywall/Paywall.kt | 3 + .../models/product/CrossplatformProduct.kt | 46 +++++++ .../sdk/models/product/ProductItem.kt | 30 +++++ .../sdk/paywall/request/PaywallLogic.kt | 14 +++ .../paywall/request/PaywallRequestManager.kt | 53 ++++++++ .../sdk/store/InternalPurchaseController.kt | 11 ++ .../product/ApiStoreProduct.kt} | 12 +- .../abstractions/product/StoreProduct.kt | 18 +++ .../transactions/StoreTransaction.kt | 37 ++++++ .../superwall/sdk/store/testmode/TestMode.kt | 3 +- .../store/testmode/models/SuperwallProduct.kt | 3 + .../store/transactions/TransactionManager.kt | 117 ++++++++++++++++++ .../models/product/CustomStoreProductTest.kt | 88 +++++++++++++ .../sdk/paywall/request/PaywallLogicTest.kt | 92 ++++++++++++++ .../product/ApiStoreProductTest.kt} | 110 ++++++++-------- .../CustomStoreTransactionTest.kt | 69 +++++++++++ .../SuperwallProductSerializationTest.kt | 26 ++++ 20 files changed, 717 insertions(+), 61 deletions(-) rename superwall/src/main/java/com/superwall/sdk/store/{testmode/TestStoreProduct.kt => abstractions/product/ApiStoreProduct.kt} (97%) create mode 100644 superwall/src/test/java/com/superwall/sdk/models/product/CustomStoreProductTest.kt rename superwall/src/test/java/com/superwall/sdk/store/{testmode/TestStoreProductTest.kt => abstractions/product/ApiStoreProductTest.kt} (78%) create mode 100644 superwall/src/test/java/com/superwall/sdk/store/abstractions/transactions/CustomStoreTransactionTest.kt 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..86a2a5ad9 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. @@ -51,4 +52,25 @@ interface PurchaseController { */ @MainThread suspend fun restorePurchases(): RestorationResult + + /** + * Called when the user initiates purchasing of a **custom** product — a product whose + * `store` is `CUSTOM` and whose metadata is sourced from the Superwall API rather than + * Google Play. Implement this to route the purchase through your own payment system + * (e.g. Stripe, web checkout). + * + * The default implementation returns `PurchaseResult.Failed` to signal that the + * controller does not handle custom products. Override it if you've configured any + * custom products in the Superwall dashboard. + * + * @param customProduct The Superwall [StoreProduct] (with `isCustomProduct == true`) the + * user would like to purchase. Its [StoreProduct.customTransactionId] is pre-generated + * by the SDK and used as the original transaction identifier in analytics. + */ + @MainThread + suspend fun purchase(customProduct: StoreProduct): PurchaseResult = + PurchaseResult.Failed( + "This PurchaseController does not implement purchase(customProduct:). " + + "Override it to handle custom (store == CUSTOM) products.", + ) } 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..0ebb74139 100644 --- a/superwall/src/main/java/com/superwall/sdk/dependencies/DependencyContainer.kt +++ b/superwall/src/main/java/com/superwall/sdk/dependencies/DependencyContainer.kt @@ -1073,6 +1073,19 @@ class DependencyContainer( appSessionId = appSessionManager.appSession.id, ) + override suspend fun makeStoreTransaction( + customTransactionId: String, + productIdentifier: String, + purchaseDate: java.util.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..0b2744e88 100644 --- a/superwall/src/main/java/com/superwall/sdk/dependencies/FactoryProtocols.kt +++ b/superwall/src/main/java/com/superwall/sdk/dependencies/FactoryProtocols.kt @@ -238,6 +238,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: java.util.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..8edbd1ec4 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,6 +452,7 @@ object CrossplatformProductSerializer : KSerializer { "APP_STORE" -> decoder.json.decodeFromJsonElement(storeProductJsonObject) "STRIPE" -> decoder.json.decodeFromJsonElement(storeProductJsonObject) "PADDLE" -> decoder.json.decodeFromJsonElement(storeProductJsonObject) + "CUSTOM" -> decoder.json.decodeFromJsonElement(storeProductJsonObject) "OTHER" -> decoder.json.decodeFromJsonElement(storeProductJsonObject) else -> CrossplatformProduct.StoreProduct.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..48a8164bc 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 @@ -44,6 +44,9 @@ enum class Store { @SerialName("SUPERWALL") SUPERWALL, + @SerialName("CUSTOM") + CUSTOM, + @SerialName("OTHER") OTHER, @@ -57,6 +60,7 @@ enum class Store { "STRIPE" -> STRIPE "PADDLE" -> PADDLE "SUPERWALL" -> SUPERWALL + "CUSTOM" -> CUSTOM else -> OTHER } } @@ -147,6 +151,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 +284,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,6 +337,11 @@ object StoreProductSerializer : KSerializer { ProductItem.StoreProductType.Paddle(product) } + Store.CUSTOM -> { + val product = json.decodeFromJsonElement(CustomStoreProduct.serializer(), jsonObject) + ProductItem.StoreProductType.Custom(product) + } + Store.SUPERWALL, Store.OTHER, -> { @@ -366,6 +389,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 +407,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 +476,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..032b09d44 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,6 +161,20 @@ 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, + ) + } } } 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..18d5e6cf6 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 @@ -14,14 +14,17 @@ import com.superwall.sdk.misc.IOScope import com.superwall.sdk.misc.map import com.superwall.sdk.misc.mapError import com.superwall.sdk.misc.onError +import com.superwall.sdk.misc.fold 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 import com.superwall.sdk.store.StoreManager +import com.superwall.sdk.store.abstractions.product.ApiStoreProduct import com.superwall.sdk.store.abstractions.product.StoreProduct import com.superwall.sdk.utilities.withErrorTracking import kotlinx.coroutines.CompletableDeferred @@ -297,6 +300,9 @@ class PaywallRequestManager( var paywall = paywall paywall = trackProductsLoadStart(paywall, request) + // Fetch and cache custom products (store == CUSTOM) before Google Play product fetch. + // These are sourced from the Superwall /products endpoint, not from Play Billing. + fetchAndCacheCustomProducts(paywall) paywall = try { getProducts(paywall, request) @@ -308,6 +314,53 @@ class PaywallRequestManager( 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. + */ + private suspend fun fetchAndCacheCustomProducts(paywall: Paywall) { + val customIds = + paywall.productItems + .filter { it.type is ProductItem.StoreProductType.Custom } + .map { it.fullProductId } + .filterNot { it.isEmpty() } + .toSet() + if (customIds.isEmpty()) return + + val idsNeedingRefresh = customIds.filterNot { storeManager.hasCached(it) } + if (idsNeedingRefresh.isEmpty()) return + + network.getSuperwallProducts().fold( + onSuccess = { response -> + val matches = response.data.filter { it.identifier in idsNeedingRefresh } + val seenIds = mutableSetOf() + for (superwallProduct in matches) { + if (!seenIds.add(superwallProduct.identifier)) { + Logger.debug( + LogLevel.warn, + LogScope.productsManager, + "Duplicate custom product id from /products: ${superwallProduct.identifier}", + ) + continue + } + val apiProduct = ApiStoreProduct(superwallProduct) + val storeProduct = StoreProduct.custom(apiProduct) + storeManager.cacheProduct(superwallProduct.identifier, storeProduct) + } + }, + onFailure = { error -> + Logger.debug( + LogLevel.error, + LogScope.productsManager, + "Failed to fetch custom products: ${error.message}", + ) + }, + ) + } + private suspend fun getProducts( paywall: Paywall, request: PaywallRequest, 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..e1484a790 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 @@ -61,6 +62,16 @@ class InternalPurchaseController( // return PurchaseResult.Cancelled() } + override suspend fun purchase(customProduct: StoreProduct): PurchaseResult { + if (kotlinPurchaseController != null) { + return kotlinPurchaseController.purchase(customProduct) + } + // No PurchaseControllerJava overload for custom products yet — callers should + // be guarded by TransactionManager against this path when no external controller + // is configured. + return PurchaseResult.Failed("No PurchaseController configured to handle custom product purchase.") + } + override suspend fun restorePurchases(): RestorationResult { if (kotlinPurchaseController != null) { return kotlinPurchaseController.restorePurchases() 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 97% 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..2bd51b1ca 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,8 +1,5 @@ -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.SuperwallSubscriptionPeriod import com.superwall.sdk.utilities.DateUtils @@ -13,7 +10,12 @@ 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 { private val priceFormatterProvider = PriceFormatterProvider() 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..f86cdb689 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 @@ -78,8 +78,26 @@ sealed class OfferType { class StoreProduct private constructor( val rawStoreProduct: RawStoreProduct?, private val backingProduct: StoreProductType, + val isCustomProduct: Boolean = false, ) : StoreProductType by backingProduct { constructor(rawStoreProduct: RawStoreProduct) : this(rawStoreProduct, rawStoreProduct) constructor(storeProductType: StoreProductType) : this(null, storeProductType) + + /** + * Pre-generated transaction identifier used as the original transaction ID when + * a custom product (store == CUSTOM) is purchased through an external + * PurchaseController. Regenerated by TransactionManager on each purchase attempt. + */ + var customTransactionId: String? = null + + companion object { + /** Builds a StoreProduct flagged as a custom product, backed by an ApiStoreProduct. */ + fun custom(apiStoreProduct: ApiStoreProduct): StoreProduct = + StoreProduct( + rawStoreProduct = null, + backingProduct = apiStoreProduct, + isCustomProduct = true, + ) + } } 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..b0a1b9d3c 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 @@ -354,6 +354,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}") @@ -435,6 +441,117 @@ 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(customProduct:)], 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 products require an external PurchaseController. " + + "Configure Superwall with a PurchaseController that overrides " + + "purchase(customProduct:) to handle ${product.fullIdentifier}." + log(message = message, error = Error(message)) + trackFailure(message, product, purchaseSource) + if (purchaseSource is PurchaseSource.Internal) { + updateState( + purchaseSource.paywallInfo.cacheKey, + PaywallViewState.Updates.ToggleSpinner(hidden = true), + ) + } + return PurchaseResult.Failed(message) + } + + // Regenerate the transaction id on every attempt so cancel-and-retry + // doesn't reuse the same identifier in analytics. + product.customTransactionId = java.util.UUID.randomUUID().toString() + + prepareToPurchase(product, purchaseSource) + + val result = storeManager.purchaseController.purchase(customProduct = product) + + // If we only have an external PurchaseController, the dev's flow handles the + // rest of the transaction lifecycle (mirrors the existing ExternalPurchase + // early return below for Play products). + if (purchaseSource is PurchaseSource.ExternalPurchase && + factory.makeHasExternalPurchaseController() && + !factory.makeHasInternalPurchaseController() + ) { + if (result is PurchaseResult.Purchased) { + // Still build + track a transaction so analytics include the custom txn id. + val transaction = + factory.makeStoreTransaction( + customTransactionId = product.customTransactionId ?: java.util.UUID.randomUUID().toString(), + productIdentifier = product.fullIdentifier, + purchaseDate = java.util.Date(), + ) + trackTransactionDidSucceed(transaction, product, purchaseSource, product.hasFreeTrial) + } + return result + } + + when (result) { + is PurchaseResult.Purchased -> { + val transaction = + factory.makeStoreTransaction( + customTransactionId = product.customTransactionId ?: java.util.UUID.randomUUID().toString(), + productIdentifier = product.fullIdentifier, + purchaseDate = java.util.Date(), + ) + // Skip storeManager.loadPurchasedProducts — no Play receipt for custom products. + trackTransactionDidSucceed(transaction, product, purchaseSource, product.hasFreeTrial) + 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/models/product/CustomStoreProductTest.kt b/superwall/src/test/java/com/superwall/sdk/models/product/CustomStoreProductTest.kt new file mode 100644 index 000000000..f124bd5d7 --- /dev/null +++ b/superwall/src/test/java/com/superwall/sdk/models/product/CustomStoreProductTest.kt @@ -0,0 +1,88 @@ +@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 `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..c6b7f4d8e 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 = 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") { From 9816ab67f2144872cb11430c747b06a1cb951573 Mon Sep 17 00:00:00 2001 From: Ian Rumac Date: Fri, 24 Jul 2026 15:11:53 +0200 Subject: [PATCH 2/8] Add CustomPurchaseController, fix referenecs to external controllers --- .../sdk/dependencies/DependencyContainer.kt | 8 +- .../sdk/dependencies/FactoryProtocols.kt | 12 +- .../sdk/paywall/request/PaywallLogic.kt | 24 ++++ .../paywall/request/PaywallRequestManager.kt | 62 +++----- .../sdk/store/AutomaticPurchaseController.kt | 29 +++- .../store/CustomProductPurchaseController.kt | 24 ++++ .../sdk/store/InternalPurchaseController.kt | 14 +- .../com/superwall/sdk/store/StoreManager.kt | 91 +++++++++++- .../abstractions/product/StoreProduct.kt | 7 +- .../store/transactions/TransactionManager.kt | 93 ++++++++---- .../sdk/paywall/request/PaywallLogicTest.kt | 79 +++++++++++ .../store/InternalPurchaseControllerTest.kt | 66 +++++++++ .../superwall/sdk/store/StoreManagerTest.kt | 134 ++++++++++++++++++ 13 files changed, 561 insertions(+), 82 deletions(-) create mode 100644 superwall/src/main/java/com/superwall/sdk/store/CustomProductPurchaseController.kt 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 0ebb74139..20a27a866 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, ) @@ -943,6 +944,9 @@ class DependencyContainer( override fun makeHasExternalPurchaseController(): Boolean = storeManager.purchaseController.hasExternalPurchaseController + override fun makeHasCustomProductPurchaseController(): Boolean = + storeManager.purchaseController.hasCustomProductPurchaseController + override fun makeHasInternalPurchaseController(): Boolean = storeManager.purchaseController.hasInternalPurchaseController @@ -1076,7 +1080,7 @@ class DependencyContainer( override suspend fun makeStoreTransaction( customTransactionId: String, productIdentifier: String, - purchaseDate: java.util.Date, + purchaseDate: Date, ): StoreTransaction = StoreTransaction( customTransactionId = customTransactionId, 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 0b2744e88..8a12f0300 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: @@ -189,6 +190,15 @@ interface UserAttributesEventFactory { interface HasExternalPurchaseControllerFactory { fun makeHasExternalPurchaseController(): Boolean + + /** + * Whether the configured controller can fulfill custom (store == CUSTOM) product purchases. + * Defaults to [makeHasExternalPurchaseController] since a fully external controller may + * override purchase(customProduct:); a CustomProductPurchaseController reports true here + * while reporting false for makeHasExternalPurchaseController (Superwall still manages the + * standard Play lifecycle for it). + */ + fun makeHasCustomProductPurchaseController(): Boolean = makeHasExternalPurchaseController() } interface HasInternalPurchaseControllerFactory { @@ -246,7 +256,7 @@ interface StoreTransactionFactory { suspend fun makeStoreTransaction( customTransactionId: String, productIdentifier: String, - purchaseDate: java.util.Date, + purchaseDate: Date, ): StoreTransaction suspend fun activeProductIds(): List 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 032b09d44..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 @@ -178,6 +178,30 @@ object PaywallLogic { } } + /** + * 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 18d5e6cf6..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 @@ -14,7 +14,6 @@ import com.superwall.sdk.misc.IOScope import com.superwall.sdk.misc.map import com.superwall.sdk.misc.mapError import com.superwall.sdk.misc.onError -import com.superwall.sdk.misc.fold import com.superwall.sdk.misc.then import com.superwall.sdk.models.customer.CustomerInfo import com.superwall.sdk.models.events.EventData @@ -24,7 +23,6 @@ import com.superwall.sdk.network.Network import com.superwall.sdk.paywall.presentation.PaywallInfo import com.superwall.sdk.paywall.presentation.internal.request.ProductOverride import com.superwall.sdk.store.StoreManager -import com.superwall.sdk.store.abstractions.product.ApiStoreProduct import com.superwall.sdk.store.abstractions.product.StoreProduct import com.superwall.sdk.utilities.withErrorTracking import kotlinx.coroutines.CompletableDeferred @@ -300,15 +298,22 @@ class PaywallRequestManager( var paywall = paywall paywall = trackProductsLoadStart(paywall, request) - // Fetch and cache custom products (store == CUSTOM) before Google Play product fetch. - // These are sourced from the Superwall /products endpoint, not from Play Billing. - fetchAndCacheCustomProducts(paywall) - 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 @@ -319,46 +324,19 @@ class PaywallRequestManager( * /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. + * 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 } - .filterNot { it.isEmpty() } .toSet() if (customIds.isEmpty()) return - val idsNeedingRefresh = customIds.filterNot { storeManager.hasCached(it) } - if (idsNeedingRefresh.isEmpty()) return - - network.getSuperwallProducts().fold( - onSuccess = { response -> - val matches = response.data.filter { it.identifier in idsNeedingRefresh } - val seenIds = mutableSetOf() - for (superwallProduct in matches) { - if (!seenIds.add(superwallProduct.identifier)) { - Logger.debug( - LogLevel.warn, - LogScope.productsManager, - "Duplicate custom product id from /products: ${superwallProduct.identifier}", - ) - continue - } - val apiProduct = ApiStoreProduct(superwallProduct) - val storeProduct = StoreProduct.custom(apiProduct) - storeManager.cacheProduct(superwallProduct.identifier, storeProduct) - } - }, - onFailure = { error -> - Logger.debug( - LogLevel.error, - LogScope.productsManager, - "Failed to fetch custom products: ${error.message}", - ) - }, - ) + storeManager.fetchAndCacheCustomProducts(customIds, required = true) } private suspend fun getProducts( 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..83f08d3e9 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 { @@ -270,6 +271,26 @@ class AutomaticPurchaseController( return value } + /** + * The automatic controller only handles Google Play products. Custom (store == CUSTOM) + * products require a [CustomProductPurchaseController] (or a PurchaseController that + * overrides purchase(customProduct:)). TransactionManager guards against reaching this in + * the normal flow; this override logs and fails clearly as a safety net. + */ + override suspend fun purchase(customProduct: StoreProduct): PurchaseResult { + val message = + "AutomaticPurchaseController cannot purchase custom (store == CUSTOM) product " + + "'${customProduct.fullIdentifier}'. Configure Superwall with a " + + "CustomProductPurchaseController (or a PurchaseController that overrides " + + "purchase(customProduct:)) to handle custom products." + Logger.debug( + logLevel = LogLevel.error, + scope = LogScope.nativePurchaseController, + message = message, + ) + return PurchaseResult.Failed(message) + } + override suspend fun restorePurchases(): RestorationResult { syncSubscriptionStatusAndWait() return RestorationResult.Restored() @@ -349,7 +370,7 @@ class AutomaticPurchaseController( it.products }.toSet() .flatMap { - val res = entitlementsInfo.byProductId(it) + val res = entitlementsInfo().byProductId(it) res }.toSet() .let { entitlements -> @@ -359,7 +380,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/CustomProductPurchaseController.kt b/superwall/src/main/java/com/superwall/sdk/store/CustomProductPurchaseController.kt new file mode 100644 index 000000000..f416e3185 --- /dev/null +++ b/superwall/src/main/java/com/superwall/sdk/store/CustomProductPurchaseController.kt @@ -0,0 +1,24 @@ +package com.superwall.sdk.store + +import android.content.Context +import com.superwall.sdk.delegate.PurchaseResult +import com.superwall.sdk.delegate.subscription_controller.PurchaseController +import com.superwall.sdk.misc.IOScope +import com.superwall.sdk.store.abstractions.product.StoreProduct + +/** + * Use this controller as a [PurchaseController] in [com.superwall.sdk.Superwall.configure] if you: + * - Want to use custom store products + * - Also want to keep other purchases going through superwall + */ +class CustomProductPurchaseController( + val appContext: Context, + val onPurchase: (customProduct: StoreProduct)-> PurchaseResult +) : PurchaseController by AutomaticPurchaseController( + appContext, + IOScope() +){ + override suspend fun purchase(customProduct: StoreProduct): PurchaseResult { + return onPurchase(customProduct) + } +} 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 e1484a790..ec91ded8b 100644 --- a/superwall/src/main/java/com/superwall/sdk/store/InternalPurchaseController.kt +++ b/superwall/src/main/java/com/superwall/sdk/store/InternalPurchaseController.kt @@ -20,7 +20,19 @@ class InternalPurchaseController( get() = !hasInternalPurchaseController val hasInternalPurchaseController: Boolean - get() = kotlinPurchaseController is AutomaticPurchaseController + get() = + kotlinPurchaseController is AutomaticPurchaseController || + // Delegates standard purchases to the automatic controller, so Superwall still + // manages the Play lifecycle; only custom products route to the dev's lambda. + kotlinPurchaseController is CustomProductPurchaseController + + /** + * Whether the configured controller can fulfill custom (store == CUSTOM) product purchases: + * a dedicated [CustomProductPurchaseController], or any fully external controller (which may + * override purchase(customProduct:)). + */ + val hasCustomProductPurchaseController: Boolean + get() = kotlinPurchaseController is CustomProductPurchaseController || hasExternalPurchaseController override suspend fun purchase( activity: Activity, 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/abstractions/product/StoreProduct.kt b/superwall/src/main/java/com/superwall/sdk/store/abstractions/product/StoreProduct.kt index f86cdb689..b451bbf94 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 @@ -85,9 +85,10 @@ class StoreProduct private constructor( constructor(storeProductType: StoreProductType) : this(null, storeProductType) /** - * Pre-generated transaction identifier used as the original transaction ID when - * a custom product (store == CUSTOM) is purchased through an external - * PurchaseController. Regenerated by TransactionManager on each purchase attempt. + * Pre-generated transaction id for the current custom-product (store == CUSTOM) purchase, + * exposed so the developer can read it inside `purchase(customProduct:)`. 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 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 b0a1b9d3c..5393faa25 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, @@ -453,46 +460,82 @@ class TransactionManager( purchaseSource: PurchaseSource, shouldDismiss: Boolean, ): PurchaseResult { - if (!factory.makeHasExternalPurchaseController()) { + if (!factory.makeHasCustomProductPurchaseController()) { val message = - "Custom products require an external PurchaseController. " + - "Configure Superwall with a PurchaseController that overrides " + - "purchase(customProduct:) to handle ${product.fullIdentifier}." + "Custom products require a PurchaseController that handles them. Configure " + + "Superwall with a CustomProductPurchaseController (or a PurchaseController " + + "that overrides purchase(customProduct:)) to handle ${product.fullIdentifier}." log(message = message, error = Error(message)) trackFailure(message, product, purchaseSource) if (purchaseSource is PurchaseSource.Internal) { - updateState( - purchaseSource.paywallInfo.cacheKey, - PaywallViewState.Updates.ToggleSpinner(hidden = true), - ) + // 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) } - // Regenerate the transaction id on every attempt so cancel-and-retry - // doesn't reuse the same identifier in analytics. - product.customTransactionId = java.util.UUID.randomUUID().toString() + // 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 result = storeManager.purchaseController.purchase(customProduct = product) - // If we only have an external PurchaseController, the dev's flow handles the - // rest of the transaction lifecycle (mirrors the existing ExternalPurchase - // early return below for Play products). - if (purchaseSource is PurchaseSource.ExternalPurchase && - factory.makeHasExternalPurchaseController() && - !factory.makeHasInternalPurchaseController() - ) { + // 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) { - // Still build + track a transaction so analytics include the custom txn id. val transaction = factory.makeStoreTransaction( - customTransactionId = product.customTransactionId ?: java.util.UUID.randomUUID().toString(), + customTransactionId = txnId, productIdentifier = product.fullIdentifier, - purchaseDate = java.util.Date(), + purchaseDate = Date(), ) - trackTransactionDidSucceed(transaction, product, purchaseSource, product.hasFreeTrial) + trackTransactionDidSucceed(transaction, product, purchaseSource, didStartFreeTrial) } return result } @@ -501,12 +544,12 @@ class TransactionManager( is PurchaseResult.Purchased -> { val transaction = factory.makeStoreTransaction( - customTransactionId = product.customTransactionId ?: java.util.UUID.randomUUID().toString(), + customTransactionId = txnId, productIdentifier = product.fullIdentifier, - purchaseDate = java.util.Date(), + purchaseDate = Date(), ) // Skip storeManager.loadPurchasedProducts — no Play receipt for custom products. - trackTransactionDidSucceed(transaction, product, purchaseSource, product.hasFreeTrial) + trackTransactionDidSucceed(transaction, product, purchaseSource, didStartFreeTrial) if (shouldDismiss && purchaseSource is PurchaseSource.Internal && factory.makeSuperwallOptions().paywalls.automaticallyDismiss 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 c6b7f4d8e..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 @@ -703,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..64da611e0 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,72 @@ class InternalPurchaseControllerTest { } } + @Test + fun `test CustomProductPurchaseController is treated as internal, not external`() = + runTest { + Given("an InternalPurchaseController wrapping a CustomProductPurchaseController") { + val customProductController = mockk() + val controller = + InternalPurchaseController( + kotlinPurchaseController = customProductController, + javaPurchaseController = null, + context = mockContext, + ) + + When("checking the controller flags") { + Then("it is internal — Superwall manages the standard Play lifecycle") { + assertTrue(controller.hasInternalPurchaseController) + assertFalse(controller.hasExternalPurchaseController) + } + + And("it can still fulfill custom product purchases") { + assertTrue(controller.hasCustomProductPurchaseController) + } + } + } + } + + @Test + fun `test a fully external controller can fulfill custom products`() = + runTest { + Given("an InternalPurchaseController with a generic external controller") { + val externalController = mockk() + val controller = + InternalPurchaseController( + kotlinPurchaseController = externalController, + javaPurchaseController = null, + context = mockContext, + ) + + When("checking hasCustomProductPurchaseController") { + Then("it is true — the controller may override purchase(customProduct:)") { + assertTrue(controller.hasExternalPurchaseController) + assertTrue(controller.hasCustomProductPurchaseController) + } + } + } + } + + @Test + fun `test automatic controller cannot fulfill custom products`() = + runTest { + Given("an InternalPurchaseController with an AutomaticPurchaseController") { + val automaticController = mockk() + val controller = + InternalPurchaseController( + kotlinPurchaseController = automaticController, + javaPurchaseController = null, + context = mockContext, + ) + + When("checking hasCustomProductPurchaseController") { + Then("it is false — custom purchases are rejected") { + assertFalse(controller.hasCustomProductPurchaseController) + } + } + } + } + @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")) + } + } + } + } } From 8c69af081da06b325f60c3e94d4cd4b8c8dfc1aa Mon Sep 17 00:00:00 2001 From: Ian Rumac Date: Fri, 24 Jul 2026 15:42:48 +0200 Subject: [PATCH 3/8] Add storefront test --- .../test/java/com/superwall/sdk/billing/StorefrontTest.kt | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) 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, From 62e0de54ec97aad455ff64b3a452c748dd6173aa Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Fri, 24 Jul 2026 13:59:57 +0000 Subject: [PATCH 4/8] Update coverage badge [skip ci] --- .github/badges/branches.svg | 2 +- .github/badges/jacoco.svg | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/badges/branches.svg b/.github/badges/branches.svg index cf88d0552..95275ca45 100644 --- a/.github/badges/branches.svg +++ b/.github/badges/branches.svg @@ -1 +1 @@ -branches35.2% \ No newline at end of file +branches36.2% \ No newline at end of file diff --git a/.github/badges/jacoco.svg b/.github/badges/jacoco.svg index b9dcd7843..0ab08157f 100644 --- a/.github/badges/jacoco.svg +++ b/.github/badges/jacoco.svg @@ -1 +1 @@ -coverage44.7% \ No newline at end of file +coverage45.2% \ No newline at end of file From 87a13e4167c06704e942bf2daff0b29d385c7d28 Mon Sep 17 00:00:00 2001 From: Ian Rumac Date: Mon, 27 Jul 2026 17:49:52 +0200 Subject: [PATCH 5/8] Deprecate old purchase controller method --- .../purchase/RevenueCatPurchaseController.kt | 27 +++++- .../sdk/config/options/SuperwallOptions.kt | 2 +- .../PurchaseController.kt | 80 ++++++++++++----- .../sdk/dependencies/DependencyContainer.kt | 3 - .../sdk/dependencies/FactoryProtocols.kt | 9 -- .../models/product/CrossplatformProduct.kt | 11 ++- .../sdk/models/product/ProductItem.kt | 36 +++++++- .../sdk/store/AutomaticPurchaseController.kt | 27 ++---- .../store/CustomProductPurchaseController.kt | 24 ----- .../sdk/store/InternalPurchaseController.kt | 87 +++++++++---------- .../abstractions/product/ApiStoreProduct.kt | 5 ++ .../abstractions/product/StoreProduct.kt | 15 +++- .../store/transactions/TransactionManager.kt | 27 ++++-- .../models/product/CustomStoreProductTest.kt | 85 ++++++++++++++++++ .../store/InternalPurchaseControllerTest.kt | 63 ++++++-------- .../transactions/TransactionManagerTest.kt | 2 +- 16 files changed, 318 insertions(+), 185 deletions(-) delete mode 100644 superwall/src/main/java/com/superwall/sdk/store/CustomProductPurchaseController.kt 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/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 86a2a5ad9..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 @@ -27,50 +27,84 @@ 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 a restore. + * Called when the user initiates purchasing of a product. * - * Add your restore logic here, making sure that the user's subscription status is updated after restore, - * and return its result. + * 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)). * - * @return A `RestorationResult` that's `Restored` if the user's purchases were restored or `Failed(error)` if they weren't. - * **Note**: `Restored` does not imply the user has an active subscription, it just means the restore had no errors. + * 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 restorePurchases(): RestorationResult + 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 purchasing of a **custom** product — a product whose - * `store` is `CUSTOM` and whose metadata is sourced from the Superwall API rather than - * Google Play. Implement this to route the purchase through your own payment system - * (e.g. Stripe, web checkout). + * Called when the user initiates a restore. * - * The default implementation returns `PurchaseResult.Failed` to signal that the - * controller does not handle custom products. Override it if you've configured any - * custom products in the Superwall dashboard. + * Add your restore logic here, making sure that the user's subscription status is updated after restore, + * and return its result. * - * @param customProduct The Superwall [StoreProduct] (with `isCustomProduct == true`) the - * user would like to purchase. Its [StoreProduct.customTransactionId] is pre-generated - * by the SDK and used as the original transaction identifier in analytics. + * @return A `RestorationResult` that's `Restored` if the user's purchases were restored or `Failed(error)` if they weren't. + * **Note**: `Restored` does not imply the user has an active subscription, it just means the restore had no errors. */ @MainThread - suspend fun purchase(customProduct: StoreProduct): PurchaseResult = - PurchaseResult.Failed( - "This PurchaseController does not implement purchase(customProduct:). " + - "Override it to handle custom (store == CUSTOM) products.", - ) + suspend fun restorePurchases(): RestorationResult } 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 20a27a866..86d047bee 100644 --- a/superwall/src/main/java/com/superwall/sdk/dependencies/DependencyContainer.kt +++ b/superwall/src/main/java/com/superwall/sdk/dependencies/DependencyContainer.kt @@ -944,9 +944,6 @@ class DependencyContainer( override fun makeHasExternalPurchaseController(): Boolean = storeManager.purchaseController.hasExternalPurchaseController - override fun makeHasCustomProductPurchaseController(): Boolean = - storeManager.purchaseController.hasCustomProductPurchaseController - override fun makeHasInternalPurchaseController(): Boolean = storeManager.purchaseController.hasInternalPurchaseController 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 8a12f0300..8f597ca53 100644 --- a/superwall/src/main/java/com/superwall/sdk/dependencies/FactoryProtocols.kt +++ b/superwall/src/main/java/com/superwall/sdk/dependencies/FactoryProtocols.kt @@ -190,15 +190,6 @@ interface UserAttributesEventFactory { interface HasExternalPurchaseControllerFactory { fun makeHasExternalPurchaseController(): Boolean - - /** - * Whether the configured controller can fulfill custom (store == CUSTOM) product purchases. - * Defaults to [makeHasExternalPurchaseController] since a fully external controller may - * override purchase(customProduct:); a CustomProductPurchaseController reports true here - * while reporting false for makeHasExternalPurchaseController (Superwall still manages the - * standard Play lifecycle for it). - */ - fun makeHasCustomProductPurchaseController(): Boolean = makeHasExternalPurchaseController() } interface HasInternalPurchaseControllerFactory { 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 8edbd1ec4..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 @@ -453,7 +453,16 @@ object CrossplatformProductSerializer : KSerializer { "STRIPE" -> decoder.json.decodeFromJsonElement(storeProductJsonObject) "PADDLE" -> decoder.json.decodeFromJsonElement(storeProductJsonObject) "CUSTOM" -> decoder.json.decodeFromJsonElement(storeProductJsonObject) - "OTHER" -> 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 48a8164bc..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, @@ -66,6 +68,19 @@ enum class Store { } } +// 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( @@ -342,13 +357,26 @@ object StoreProductSerializer : KSerializer { ProductItem.StoreProductType.Custom(product) } - Store.SUPERWALL, - Store.OTHER, - -> { + 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) + } + } } } } 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 83f08d3e9..1c2f523c8 100644 --- a/superwall/src/main/java/com/superwall/sdk/store/AutomaticPurchaseController.kt +++ b/superwall/src/main/java/com/superwall/sdk/store/AutomaticPurchaseController.kt @@ -162,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, @@ -271,26 +278,6 @@ class AutomaticPurchaseController( return value } - /** - * The automatic controller only handles Google Play products. Custom (store == CUSTOM) - * products require a [CustomProductPurchaseController] (or a PurchaseController that - * overrides purchase(customProduct:)). TransactionManager guards against reaching this in - * the normal flow; this override logs and fails clearly as a safety net. - */ - override suspend fun purchase(customProduct: StoreProduct): PurchaseResult { - val message = - "AutomaticPurchaseController cannot purchase custom (store == CUSTOM) product " + - "'${customProduct.fullIdentifier}'. Configure Superwall with a " + - "CustomProductPurchaseController (or a PurchaseController that overrides " + - "purchase(customProduct:)) to handle custom products." - Logger.debug( - logLevel = LogLevel.error, - scope = LogScope.nativePurchaseController, - message = message, - ) - return PurchaseResult.Failed(message) - } - override suspend fun restorePurchases(): RestorationResult { syncSubscriptionStatusAndWait() return RestorationResult.Restored() diff --git a/superwall/src/main/java/com/superwall/sdk/store/CustomProductPurchaseController.kt b/superwall/src/main/java/com/superwall/sdk/store/CustomProductPurchaseController.kt deleted file mode 100644 index f416e3185..000000000 --- a/superwall/src/main/java/com/superwall/sdk/store/CustomProductPurchaseController.kt +++ /dev/null @@ -1,24 +0,0 @@ -package com.superwall.sdk.store - -import android.content.Context -import com.superwall.sdk.delegate.PurchaseResult -import com.superwall.sdk.delegate.subscription_controller.PurchaseController -import com.superwall.sdk.misc.IOScope -import com.superwall.sdk.store.abstractions.product.StoreProduct - -/** - * Use this controller as a [PurchaseController] in [com.superwall.sdk.Superwall.configure] if you: - * - Want to use custom store products - * - Also want to keep other purchases going through superwall - */ -class CustomProductPurchaseController( - val appContext: Context, - val onPurchase: (customProduct: StoreProduct)-> PurchaseResult -) : PurchaseController by AutomaticPurchaseController( - appContext, - IOScope() -){ - override suspend fun purchase(customProduct: StoreProduct): PurchaseResult { - return onPurchase(customProduct) - } -} 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 ec91ded8b..5a79661a7 100644 --- a/superwall/src/main/java/com/superwall/sdk/store/InternalPurchaseController.kt +++ b/superwall/src/main/java/com/superwall/sdk/store/InternalPurchaseController.kt @@ -20,68 +20,61 @@ class InternalPurchaseController( get() = !hasInternalPurchaseController val hasInternalPurchaseController: Boolean - get() = - kotlinPurchaseController is AutomaticPurchaseController || - // Delegates standard purchases to the automatic controller, so Superwall still - // manages the Play lifecycle; only custom products route to the dev's lambda. - kotlinPurchaseController is CustomProductPurchaseController - - /** - * Whether the configured controller can fulfill custom (store == CUSTOM) product purchases: - * a dedicated [CustomProductPurchaseController], or any fully external controller (which may - * override purchase(customProduct:)). - */ - val hasCustomProductPurchaseController: Boolean - get() = kotlinPurchaseController is CustomProductPurchaseController || hasExternalPurchaseController + 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() - } - - override suspend fun purchase(customProduct: StoreProduct): PurchaseResult { - if (kotlinPurchaseController != null) { - return kotlinPurchaseController.purchase(customProduct) } - // No PurchaseControllerJava overload for custom products yet — callers should - // be guarded by TransactionManager against this path when no external controller - // is configured. - return PurchaseResult.Failed("No PurchaseController configured to handle custom product purchase.") + // 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/abstractions/product/ApiStoreProduct.kt b/superwall/src/main/java/com/superwall/sdk/store/abstractions/product/ApiStoreProduct.kt index 2bd51b1ca..3a47afafb 100644 --- a/superwall/src/main/java/com/superwall/sdk/store/abstractions/product/ApiStoreProduct.kt +++ b/superwall/src/main/java/com/superwall/sdk/store/abstractions/product/ApiStoreProduct.kt @@ -1,6 +1,7 @@ package com.superwall.sdk.store.abstractions.product 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 @@ -18,6 +19,10 @@ import java.util.Locale 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 b451bbf94..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.* @@ -78,27 +79,33 @@ sealed class OfferType { class StoreProduct private constructor( val rawStoreProduct: RawStoreProduct?, private val backingProduct: StoreProductType, - val isCustomProduct: Boolean = false, ) : 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(customProduct:)`. Regenerated on + * 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 flagged as a custom product, backed by an ApiStoreProduct. */ + /** Builds a StoreProduct backed by an ApiStoreProduct (custom or test-mode product). */ fun custom(apiStoreProduct: ApiStoreProduct): StoreProduct = StoreProduct( rawStoreProduct = null, backingProduct = apiStoreProduct, - isCustomProduct = true, ) } } 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 5393faa25..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 @@ -374,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") @@ -383,7 +382,7 @@ class TransactionManager( val result = storeManager.purchaseController.purchase( activity = activity, - productDetails = productDetails, + product = product, offerId = rawStoreProduct.offerId, basePlanId = rawStoreProduct.basePlanId, ) @@ -452,19 +451,20 @@ class TransactionManager( * 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(customProduct:)], and constructs a - * StoreTransaction without touching Google Play Billing / receipts. + * [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.makeHasCustomProductPurchaseController()) { + if (!factory.makeHasExternalPurchaseController()) { val message = - "Custom products require a PurchaseController that handles them. Configure " + - "Superwall with a CustomProductPurchaseController (or a PurchaseController " + - "that overrides purchase(customProduct:)) to handle ${product.fullIdentifier}." + "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) { @@ -523,7 +523,16 @@ class TransactionManager( ) } - val result = storeManager.purchaseController.purchase(customProduct = product) + 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. 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 index f124bd5d7..ed6f36f1a 100644 --- a/superwall/src/test/java/com/superwall/sdk/models/product/CustomStoreProductTest.kt +++ b/superwall/src/test/java/com/superwall/sdk/models/product/CustomStoreProductTest.kt @@ -66,6 +66,91 @@ class CustomStoreProductTest { } } + @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") { 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 64da611e0..9c9388bc1 100644 --- a/superwall/src/test/java/com/superwall/sdk/store/InternalPurchaseControllerTest.kt +++ b/superwall/src/test/java/com/superwall/sdk/store/InternalPurchaseControllerTest.kt @@ -105,35 +105,17 @@ class InternalPurchaseControllerTest { } @Test - fun `test CustomProductPurchaseController is treated as internal, not external`() = + fun `test unified purchase delegates to Kotlin controller`() = runTest { - Given("an InternalPurchaseController wrapping a CustomProductPurchaseController") { - val customProductController = mockk() - val controller = - InternalPurchaseController( - kotlinPurchaseController = customProductController, - javaPurchaseController = null, - context = mockContext, - ) - - When("checking the controller flags") { - Then("it is internal — Superwall manages the standard Play lifecycle") { - assertTrue(controller.hasInternalPurchaseController) - assertFalse(controller.hasExternalPurchaseController) - } + Given("an InternalPurchaseController with an external Kotlin controller") { + val externalController = mockk() + val product = mockk() + val expectedResult = PurchaseResult.Purchased() - And("it can still fulfill custom product purchases") { - assertTrue(controller.hasCustomProductPurchaseController) - } - } - } - } + coEvery { + externalController.purchase(mockActivity, product, "base_plan", "offer") + } returns expectedResult - @Test - fun `test a fully external controller can fulfill custom products`() = - runTest { - Given("an InternalPurchaseController with a generic external controller") { - val externalController = mockk() val controller = InternalPurchaseController( kotlinPurchaseController = externalController, @@ -141,30 +123,37 @@ class InternalPurchaseControllerTest { context = mockContext, ) - When("checking hasCustomProductPurchaseController") { - Then("it is true — the controller may override purchase(customProduct:)") { - assertTrue(controller.hasExternalPurchaseController) - assertTrue(controller.hasCustomProductPurchaseController) + 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 automatic controller cannot fulfill custom products`() = + fun `test unified purchase of a custom product fails without a Kotlin controller`() = runTest { - Given("an InternalPurchaseController with an AutomaticPurchaseController") { - val automaticController = mockk() + Given("an InternalPurchaseController with no Kotlin controller") { + val product = mockk() + every { product.rawStoreProduct } returns null val controller = InternalPurchaseController( - kotlinPurchaseController = automaticController, + kotlinPurchaseController = null, javaPurchaseController = null, context = mockContext, ) - When("checking hasCustomProductPurchaseController") { - Then("it is false — custom purchases are rejected") { - assertFalse(controller.hasCustomProductPurchaseController) + 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) } } } 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 From 1e929274e21096b0073de65066453f5e1d5aa448 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Mon, 27 Jul 2026 17:03:40 +0000 Subject: [PATCH 6/8] Update coverage badge [skip ci] --- .github/badges/branches.svg | 2 +- .github/badges/jacoco.svg | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/badges/branches.svg b/.github/badges/branches.svg index f1db12a4c..95275ca45 100644 --- a/.github/badges/branches.svg +++ b/.github/badges/branches.svg @@ -1 +1 @@ -branches35.3% +branches36.2% \ No newline at end of file diff --git a/.github/badges/jacoco.svg b/.github/badges/jacoco.svg index d0c80d131..ec50def2a 100644 --- a/.github/badges/jacoco.svg +++ b/.github/badges/jacoco.svg @@ -1 +1 @@ -coverage44.8% +coverage45.4% \ No newline at end of file From 5f859742e9a41ec0da1102c56cf88ee9b8e29d30 Mon Sep 17 00:00:00 2001 From: Ian Rumac Date: Mon, 27 Jul 2026 19:11:28 +0200 Subject: [PATCH 7/8] Bump changelog --- CHANGELOG.md | 13 +++++++++++++ version.env | 2 +- 2 files changed, 14 insertions(+), 1 deletion(-) 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/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 From a5d3173218f900f8e34b47749645406edc756e54 Mon Sep 17 00:00:00 2001 From: Ian Rumac Date: Mon, 27 Jul 2026 19:47:30 +0200 Subject: [PATCH 8/8] Update tests --- .../analytics/internal/TrackingLogicTest.kt | 6 +++++ .../transactions/TransactionManagerTest.kt | 24 +++++++++---------- 2 files changed, 18 insertions(+), 12 deletions(-) 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(), )