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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/badges/branches.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
2 changes: 1 addition & 1 deletion .github/badges/jacoco.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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.",
)
}
Original file line number Diff line number Diff line change
Expand Up @@ -314,7 +314,7 @@ class DependencyContainer(
InternalPurchaseController(
kotlinPurchaseController =
purchaseController
?: AutomaticPurchaseController(context, ioScope, entitlements),
?: AutomaticPurchaseController(context, ioScope, { entitlements }),
javaPurchaseController = null,
context,
)
Expand All @@ -330,6 +330,7 @@ class DependencyContainer(
customerInfoManager = { customerInfoManager },
)
},
getSuperwallProducts = { network.getSuperwallProducts() },
testMode = testMode,
)

Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -1073,6 +1077,19 @@ class DependencyContainer(
appSessionId = appSessionManager.appSession.id,
)

override suspend fun makeStoreTransaction(
customTransactionId: String,
productIdentifier: String,
purchaseDate: Date,
): StoreTransaction =
StoreTransaction(
customTransactionId = customTransactionId,
productIdentifier = productIdentifier,
purchaseDate = purchaseDate,
configRequestId = configManager.config?.requestId ?: "",
appSessionId = appSessionManager.appSession.id,
)

override suspend fun activeProductIds(): List<String> =
storeManager.receiptManager.purchases.toList()

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -238,6 +248,17 @@ interface ConfigManagerFactory {
interface StoreTransactionFactory {
suspend fun makeStoreTransaction(transaction: Purchase): StoreTransaction

/**
* Builds a StoreTransaction for a custom-product purchase (no Google Play receipt).
* [customTransactionId] is the pre-generated UUID used as both original and
* store transaction identifier.
*/
suspend fun makeStoreTransaction(
customTransactionId: String,
productIdentifier: String,
purchaseDate: Date,
): StoreTransaction

suspend fun activeProductIds(): List<String>
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -168,6 +168,9 @@ data class Paywall(
val paddleProducts: List<CrossplatformProduct>
get() = _productItemsV3.filter { it.storeProduct is CrossplatformProduct.StoreProduct.Paddle }

val customProducts: List<CrossplatformProduct>
get() = _productItemsV3.filter { it.storeProduct is CrossplatformProduct.StoreProduct.Custom }

// Public getter for productItems
var productItems: List<ProductItem>
get() = (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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 -> ""
}
}
Expand Down Expand Up @@ -331,6 +344,38 @@ object PaddleSerializer : KSerializer<CrossplatformProduct.StoreProduct.Paddle>
}
}

object CustomSerializer : KSerializer<CrossplatformProduct.StoreProduct.Custom> {
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<CrossplatformProduct.StoreProduct.Other> {
override val descriptor: SerialDescriptor = buildClassSerialDescriptor("Other")

Expand Down Expand Up @@ -407,6 +452,7 @@ object CrossplatformProductSerializer : KSerializer<CrossplatformProduct> {
"APP_STORE" -> decoder.json.decodeFromJsonElement<CrossplatformProduct.StoreProduct.AppStore>(storeProductJsonObject)
"STRIPE" -> decoder.json.decodeFromJsonElement<CrossplatformProduct.StoreProduct.Stripe>(storeProductJsonObject)
"PADDLE" -> decoder.json.decodeFromJsonElement<CrossplatformProduct.StoreProduct.Paddle>(storeProductJsonObject)
"CUSTOM" -> decoder.json.decodeFromJsonElement<CrossplatformProduct.StoreProduct.Custom>(storeProductJsonObject)
"OTHER" -> decoder.json.decodeFromJsonElement<CrossplatformProduct.StoreProduct.Other>(storeProductJsonObject)
else ->
CrossplatformProduct.StoreProduct.Other(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,9 @@ enum class Store {
@SerialName("SUPERWALL")
SUPERWALL,

@SerialName("CUSTOM")
CUSTOM,

@SerialName("OTHER")
OTHER,

Expand All @@ -57,6 +60,7 @@ enum class Store {
"STRIPE" -> STRIPE
"PADDLE" -> PADDLE
"SUPERWALL" -> SUPERWALL
"CUSTOM" -> CUSTOM
else -> OTHER
}
}
Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -269,6 +284,9 @@ object StoreProductSerializer : KSerializer<ProductItem.StoreProductType> {
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(),
Expand Down Expand Up @@ -319,6 +337,11 @@ object StoreProductSerializer : KSerializer<ProductItem.StoreProductType> {
ProductItem.StoreProductType.Paddle(product)
}

Store.CUSTOM -> {
val product = json.decodeFromJsonElement(CustomStoreProduct.serializer(), jsonObject)
ProductItem.StoreProductType.Custom(product)
}

Store.SUPERWALL,
Store.OTHER,
-> {
Expand Down Expand Up @@ -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,
Expand All @@ -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
}

Expand Down Expand Up @@ -447,6 +476,7 @@ object ProductItemSerializer : KSerializer<ProductItem> {
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
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -161,9 +161,47 @@ object PaywallLogic {
customerInfo = customerInfo,
introOfferEligibility = introOfferEligibility,
)

is ProductItem.StoreProductType.Custom -> {
// Custom product trial info lives on the cached StoreProduct's
// subscription metadata (fetched from /products). Use the same
// entitlement-history check as web products.
val trialDays = productsByFullId[productItem.fullProductId]?.trialPeriodDays ?: 0
isWebTrialAvailable(
name = productItem.name,
trialDays = trialDays,
entitlements = productItem.entitlements,
customerInfo = customerInfo,
introOfferEligibility = introOfferEligibility,
)
}
}
}

/**
* Entitlement-history-based trial eligibility for a custom (store == CUSTOM) product,
* mirroring the check used for web products in [computeHasFreeTrial]. Used at purchase
* time so a repeat purchaser who already consumed their trial does not generate a
* spurious `freeTrialStart` event — the external payment system charges them immediately.
*
* Returns false when the product has no trial metadata, when eligibility can't be
* verified (e.g. no entitlements or customer info not yet loaded), or when the customer
* has ever held one of the product's entitlements.
*/
fun isFreeTrialEligibleForCustomProduct(
product: StoreProduct,
entitlements: Set<Entitlement>,
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?,
Expand Down
Loading