Skip to content

Commit

Permalink
[BC5] Add product category support in getProductInfo() and `purchas…
Browse files Browse the repository at this point in the history
…eProduct()` (#387)

## Motivation

Currently, the `purchaseProduct(identifier, type)` method requires the
developer to manually pass the type (either "subs" or "inapp") of the
product they are purchasing. Even if they are basing this off of a
fetched `StoreProduct`, the developer would need to determine what type
of product it is because its currently not exposed in any of the hybrid
SDKs.

New hybrids versions will have a new `productCategory` property on
`StoreProduct`. This value has always been sent from the Hybrid Common
layer but was just never exposed on the models. It has values of
"SUBSCRIPTION" and "NON_SUBSCRIPTION".

These values should be able to be passed into the type of
`purchaseProduct()` without any developer modification to make a
purchase

Example: 

```dart
Purchases.purchaseProduct(storeProduct.identifier, storeProduct.productCategory)
``` 

Goal of this is to **not** be a breaking API change but for Hybrid
Common to handle these different values of legacy values ("subs" and
"inapp") and the product categories of "SUBSCRIPTION" and
"NON_SUBSCRIPTION" going forward.

## Description

Mostly taken from #384 (which was reverted in the great unmigration of
product type)

- Moved hardcoded strings into `MappedProductCategory` enum with values
of:
  - `SUBSCRIPTION`
  - `NON_SUBSCRIPTION`
  - `UNKNOWN`
- New `mapStringToProductType()` helper method for converting from
`String` to `ProductType`
  - First checks if string is a `MappedProductCategory`
  - Then sees if legacy value of "subs" or "inapp"
- `mapStringToProductType()` is used in `getProductInfo()` and
`purchaseProduct()
  • Loading branch information
joshdholtz authored Apr 14, 2023
1 parent 2f3853d commit 98ea582
Show file tree
Hide file tree
Showing 3 changed files with 128 additions and 10 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import com.revenuecat.purchases.getCustomerInfoWith
import com.revenuecat.purchases.getOfferingsWith
import com.revenuecat.purchases.getProductsWith
import com.revenuecat.purchases.hybridcommon.mappers.LogHandlerWithMapping
import com.revenuecat.purchases.hybridcommon.mappers.MappedProductCategory
import com.revenuecat.purchases.hybridcommon.mappers.map
import com.revenuecat.purchases.logInWith
import com.revenuecat.purchases.logOutWith
Expand Down Expand Up @@ -58,7 +59,7 @@ fun getProductInfo(
val onError: (PurchasesError) -> Unit = { onResult.onError(it.map()) }
val onReceived: (List<StoreProduct>) -> Unit = { onResult.onReceived(it.map()) }

if (type.equals("subs", ignoreCase = true)) {
if (mapStringToProductType(type) == ProductType.SUBS) {
Purchases.sharedInstance.getProductsWith(productIDs, ProductType.SUBS, onError, onReceived)
} else {
Purchases.sharedInstance.getProductsWith(productIDs, ProductType.INAPP, onError, onReceived)
Expand Down Expand Up @@ -87,18 +88,20 @@ fun purchaseProduct(
return
}

val productType = mapStringToProductType(type)

if (activity != null) {
val onReceiveStoreProducts: (List<StoreProduct>) -> Unit = { storeProducts ->
val productToBuy = storeProducts.firstOrNull {
// Comparison for when productIdentifier is "subId:basePlanId"
val foundByProductIdContainingBasePlan =
(it.id == productIdentifier && it.type.name.equals(type, ignoreCase = true))
(it.id == productIdentifier && it.type == productType)

// Comparison for when productIdentifier is "subId" and googleBasePlanId is "basePlanId"
val foundByProductIdAndGoogleBasePlanId = (
it.purchasingData.productId == productIdentifier
&& it.googleProduct?.basePlanId == googleBasePlanId
&& it.type.name.equals(type, ignoreCase = true)
&& it.type == productType
)

// Finding the matching StoreProduct two different ways:
Expand Down Expand Up @@ -139,7 +142,7 @@ fun purchaseProduct(
}

}
if (type.equals("subs", ignoreCase = true)) {
if (productType == ProductType.SUBS) {
// The "productIdentifier"
val productIdWithoutBasePlanId = productIdentifier.split(":").first()

Expand Down Expand Up @@ -493,6 +496,25 @@ fun getPromotionalOffer() : ErrorContainer {

// region private functions

internal fun mapStringToProductType(type: String) : ProductType {
MappedProductCategory.values()
.firstOrNull { it.value.equals(type, ignoreCase = true) }
?.let {
return it.toProductType
}

// Maps strings used in deprecated hybrid methods to native ProductType enum
// "subs" and "inapp" are legacy purchase types used in v4 and below
return when(type.lowercase()) {
"subs" -> ProductType.SUBS
"inapp" -> ProductType.INAPP
else -> {
warnLog("Unrecognized product type: $type... Defaulting to INAPP")
ProductType.INAPP
}
}
}

internal class InvalidProrationModeException(): Exception()

@Throws(InvalidProrationModeException::class)
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
package com.revenuecat.purchases.hybridcommon.mappers

import com.revenuecat.purchases.ProductType
import com.revenuecat.purchases.models.OfferPaymentMode
import com.revenuecat.purchases.models.Period
import com.revenuecat.purchases.models.Price
import com.revenuecat.purchases.models.PricingPhase
Expand Down Expand Up @@ -41,7 +40,7 @@ fun StoreProduct.map(): Map<String, Any?> =
"currencyCode" to priceCurrencyCode,
"introPrice" to mapIntroPrice(),
"discounts" to null,
"productCategory" to mapProductCategory(),
"productCategory" to mapProductCategory().value,
"productType" to mapProductType(),
"subscriptionPeriod" to period?.iso8601,
"defaultOption" to defaultOption?.mapSubscriptionOption(this),
Expand All @@ -51,11 +50,24 @@ fun StoreProduct.map(): Map<String, Any?> =

fun List<StoreProduct>.map(): List<Map<String, Any?>> = this.map { it.map() }

internal fun StoreProduct.mapProductCategory(): String {
internal enum class MappedProductCategory(val value: String) {
SUBSCRIPTION("SUBSCRIPTION"),
NON_SUBSCRIPTION("NON_SUBSCRIPTION"),
UNKNOWN("UNKNOWN");

val toProductType: ProductType
get() = when(this) {
NON_SUBSCRIPTION -> ProductType.INAPP
SUBSCRIPTION -> ProductType.SUBS
UNKNOWN -> ProductType.UNKNOWN
}
}

internal fun StoreProduct.mapProductCategory(): MappedProductCategory {
return when (type) {
ProductType.INAPP -> "NON_SUBSCRIPTION"
ProductType.SUBS -> "SUBSCRIPTION"
ProductType.UNKNOWN -> "UNKNOWN"
ProductType.INAPP -> MappedProductCategory.NON_SUBSCRIPTION
ProductType.SUBS -> MappedProductCategory.SUBSCRIPTION
ProductType.UNKNOWN -> MappedProductCategory.UNKNOWN
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import com.revenuecat.purchases.PurchasesError
import com.revenuecat.purchases.PurchasesErrorCode
import com.revenuecat.purchases.Store
import com.revenuecat.purchases.common.PlatformInfo
import com.revenuecat.purchases.hybridcommon.mappers.MappedProductCategory
import com.revenuecat.purchases.hybridcommon.mappers.map
import com.revenuecat.purchases.interfaces.Callback
import com.revenuecat.purchases.interfaces.GetStoreProductsCallback
Expand Down Expand Up @@ -427,6 +428,65 @@ internal class CommonKtTests {
capturedPurchaseCallback.captured.onCompleted(mockPurchase, mockk(relaxed = true))
}

purchaseProduct(
mockActivity,
productIdentifier = expectedProductIdentifier,
type = MappedProductCategory.SUBSCRIPTION.value,
googleBasePlanId = null,
googleOldProductId = null,
googleProrationMode = null,
googleIsPersonalizedPrice = null,
presentedOfferingIdentifier = null,
onResult = object : OnResult {
override fun onReceived(map: MutableMap<String, *>) {
receivedResponse = map
}

override fun onError(errorContainer: ErrorContainer) {
fail("Should be success")
}
}
)

assertNotNull(receivedResponse)
assertEquals(expectedProductIdentifier, receivedResponse?.get("productIdentifier"))
}

@Test
fun `purchaseProduct passes correct productIdentifier and legacy product type after a successful purchase`() {
configure(
context = mockContext,
apiKey = "api_key",
appUserID = "appUserID",
observerMode = true,
platformInfo = PlatformInfo("flavor", "version")
)
val expectedProductIdentifier = "product"
var receivedResponse: MutableMap<String, *>? = null

val capturedGetStoreProductsCallback = slot<GetStoreProductsCallback>()
val mockStoreProduct = stubStoreProduct(expectedProductIdentifier)
val mockPurchase = mockk<StoreTransaction>()
every {
mockPurchase.productIds
} returns ArrayList(listOf(expectedProductIdentifier, "other"))

every {
mockPurchases.getProducts(listOf(expectedProductIdentifier), ProductType.SUBS, capture(capturedGetStoreProductsCallback))
} answers {
capturedGetStoreProductsCallback.captured.onReceived(listOf(mockStoreProduct))
}

val capturedPurchaseCallback = slot<PurchaseCallback>()
every {
mockPurchases.purchase(any<PurchaseParams>(), capture(capturedPurchaseCallback))
} answers {
val params = it.invocation.args.first() as PurchaseParams
assertEquals(false, params.isPersonalizedPrice)

capturedPurchaseCallback.captured.onCompleted(mockPurchase, mockk(relaxed = true))
}

purchaseProduct(
mockActivity,
productIdentifier = expectedProductIdentifier,
Expand Down Expand Up @@ -1112,6 +1172,30 @@ internal class CommonKtTests {
assertTrue(catchWasCalled)
}

@Test
fun `mapStringToProductType returns ProductType SUBS for subs`() {
val productType = mapStringToProductType("subs")
assertEquals(ProductType.SUBS, productType)
}

@Test
fun `mapStringToProductType returns ProductType SUBS for SUBSCRIPTION`() {
val productType = mapStringToProductType("SUBSCRIPTION")
assertEquals(ProductType.SUBS, productType)
}

@Test
fun `mapStringToProductType returns ProductType INAPP for inapp`() {
val productType = mapStringToProductType("inapp")
assertEquals(ProductType.INAPP, productType)
}

@Test
fun `mapStringToProductType returns ProductType INAPP for NON_SUBSCRIPTION`() {
val productType = mapStringToProductType("NON_SUBSCRIPTION")
assertEquals(ProductType.INAPP, productType)
}

private fun getOfferings(mockStoreProduct: StoreProduct): Triple<String, Package, Offerings> {
val offeringIdentifier = "offering"
val packageToPurchase = Package(
Expand Down

0 comments on commit 98ea582

Please sign in to comment.