diff --git a/payments/src/main/java/com/anytypeio/anytype/payments/mapping/MembershipExt.kt b/payments/src/main/java/com/anytypeio/anytype/payments/mapping/MembershipExt.kt index dac78ff5c5..31ff5c36a2 100644 --- a/payments/src/main/java/com/anytypeio/anytype/payments/mapping/MembershipExt.kt +++ b/payments/src/main/java/com/anytypeio/anytype/payments/mapping/MembershipExt.kt @@ -104,8 +104,7 @@ fun MembershipTierData.toView( conditionInfo = getConditionInfo( isActive = isActive, billingClientState = billingClientState, - membershipStatus = membershipStatus, - billingPurchaseState = billingPurchaseState + membershipStatus = membershipStatus ), isActive = isActive, features = features, @@ -138,8 +137,7 @@ fun MembershipTierData.toPreviewView( conditionInfo = getConditionInfo( isActive = isActive, billingClientState = billingClientState, - membershipStatus = membershipStatus, - billingPurchaseState = billingPurchaseState + membershipStatus = membershipStatus ), isActive = isActive, color = colorStr @@ -242,6 +240,13 @@ private fun MembershipTierData.mapInactiveTierButtonAndNameStates( ): Pair { val androidProductId = this.androidProductId val androidInfoUrl = this.androidManageUrl + if (billingClientState is BillingClientState.NotAvailable) { + return if (androidInfoUrl == null) { + TierButton.Info.Disabled to TierAnyName.Hidden + } else { + TierButton.Info.Enabled(androidInfoUrl) to TierAnyName.Hidden + } + } if (androidProductId == null) { return if (androidInfoUrl == null) { TierButton.Info.Disabled to TierAnyName.Hidden @@ -269,7 +274,8 @@ private fun MembershipTierData.mapInactiveTierButtonAndNameStates( handleNoPurchasesState( billingClientState = billingClientState, membershipStatus = membershipStatus, - androidProductId = androidProductId + androidProductId = androidProductId, + androidInfoUrl = androidInfoUrl ) } } @@ -278,7 +284,8 @@ private fun MembershipTierData.mapInactiveTierButtonAndNameStates( private fun handleNoPurchasesState( billingClientState: BillingClientState, membershipStatus: MembershipStatus, - androidProductId: String + androidProductId: String, + androidInfoUrl: String? ): Pair { if (billingClientState is BillingClientState.Connected) { val product = billingClientState.productDetails.find { it.productId == androidProductId } @@ -289,7 +296,13 @@ private fun handleNoPurchasesState( else -> TierButton.Pay.Enabled to TierAnyName.Visible.Purchased(membershipStatus.anyName) } } - + if (billingClientState is BillingClientState.NotAvailable ) { + return if (androidInfoUrl == null) { + TierButton.Info.Disabled to TierAnyName.Hidden + } else { + TierButton.Info.Enabled(androidInfoUrl) to TierAnyName.Hidden + } + } return TierButton.Pay.Disabled to TierAnyName.Visible.Disabled } @@ -319,8 +332,7 @@ private fun getButtonStateAccordingToPurchaseState( private fun MembershipTierData.getConditionInfo( isActive: Boolean, billingClientState: BillingClientState, - membershipStatus: MembershipStatus, - billingPurchaseState: BillingPurchaseState + membershipStatus: MembershipStatus ): TierConditionInfo { return if (isActive) { createConditionInfoForCurrentTier( @@ -333,8 +345,7 @@ private fun MembershipTierData.getConditionInfo( } else { createConditionInfoForBillingTier( billingClientState = billingClientState, - membershipStatus = membershipStatus, - billingPurchaseState = billingPurchaseState + membershipStatus = membershipStatus ) } } @@ -375,8 +386,7 @@ private fun formatPriceInCents(priceInCents: Int): String { private fun MembershipTierData.createConditionInfoForBillingTier( billingClientState: BillingClientState, - membershipStatus: MembershipStatus, - billingPurchaseState: BillingPurchaseState + membershipStatus: MembershipStatus ): TierConditionInfo { if ( membershipStatus.status == Membership.Status.STATUS_PENDING || @@ -384,9 +394,6 @@ private fun MembershipTierData.createConditionInfoForBillingTier( ) { return TierConditionInfo.Visible.Pending } - if (billingPurchaseState is BillingPurchaseState.Loading) { - return TierConditionInfo.Visible.Pending - } return when (billingClientState) { BillingClientState.Loading -> { TierConditionInfo.Visible.LoadingBillingClient @@ -410,6 +417,11 @@ private fun MembershipTierData.createConditionInfoForBillingTier( } } } + BillingClientState.NotAvailable -> { + TierConditionInfo.Visible.Price( + price = formatPriceInCents(priceStripeUsdCents), + period = convertToTierViewPeriod(this)) + } } } diff --git a/payments/src/main/java/com/anytypeio/anytype/payments/playbilling/BillingClientLifecycle.kt b/payments/src/main/java/com/anytypeio/anytype/payments/playbilling/BillingClientLifecycle.kt index c4892fc44a..9292c0bf24 100644 --- a/payments/src/main/java/com/anytypeio/anytype/payments/playbilling/BillingClientLifecycle.kt +++ b/payments/src/main/java/com/anytypeio/anytype/payments/playbilling/BillingClientLifecycle.kt @@ -101,15 +101,22 @@ class BillingClientLifecycle( val responseCode = billingResult.responseCode val debugMessage = billingResult.debugMessage Timber.d("onBillingSetupFinished: $responseCode $debugMessage") - if (responseCode == BillingClient.BillingResponseCode.OK) { - // The billing client is ready. - // You can query product details and purchases here. - querySubscriptionProductDetails() - querySubscriptionPurchases() - } else { - Timber.e("onBillingSetupFinished: BillingResponse $responseCode") - _builderSubProductWithProductDetails.value = - BillingClientState.Error("BillingResponse $responseCode") + when (responseCode) { + BillingClient.BillingResponseCode.OK -> { + // The billing client is ready. + // You can query product details and purchases here. + querySubscriptionProductDetails() + querySubscriptionPurchases() + } + BillingClient.BillingResponseCode.BILLING_UNAVAILABLE -> { + Timber.e("onBillingSetupFinished: BILLING_UNAVAILABLE") + _builderSubProductWithProductDetails.value = BillingClientState.NotAvailable + } + else -> { + Timber.e("onBillingSetupFinished: BillingResponse $responseCode") + _builderSubProductWithProductDetails.value = + BillingClientState.Error("BillingResponse $responseCode") + } } } @@ -389,6 +396,7 @@ sealed class BillingClientState { data class Error(val message: String) : BillingClientState() //Connected state is suppose that we have non empty list of product details data class Connected(val productDetails: List) : BillingClientState() + data object NotAvailable : BillingClientState() } sealed class BillingPurchaseState { diff --git a/payments/src/test/java/com/anytypeio/anytype/payments/TierAndroidBillingUnavailableTest.kt b/payments/src/test/java/com/anytypeio/anytype/payments/TierAndroidBillingUnavailableTest.kt new file mode 100644 index 0000000000..8158e456db --- /dev/null +++ b/payments/src/test/java/com/anytypeio/anytype/payments/TierAndroidBillingUnavailableTest.kt @@ -0,0 +1,185 @@ +package com.anytypeio.anytype.payments + +import app.cash.turbine.turbineScope +import com.anytypeio.anytype.core_models.membership.Membership +import com.anytypeio.anytype.core_models.membership.MembershipPaymentMethod +import com.anytypeio.anytype.core_models.membership.MembershipPeriodType +import com.anytypeio.anytype.core_models.membership.MembershipTierData +import com.anytypeio.anytype.payments.constants.MembershipConstants +import com.anytypeio.anytype.payments.models.MembershipPurchase +import com.anytypeio.anytype.payments.models.TierAnyName +import com.anytypeio.anytype.payments.models.TierButton +import com.anytypeio.anytype.payments.models.TierConditionInfo +import com.anytypeio.anytype.payments.models.TierEmail +import com.anytypeio.anytype.payments.models.TierPeriod +import com.anytypeio.anytype.payments.models.TierPreview +import com.anytypeio.anytype.payments.playbilling.BillingClientState +import com.anytypeio.anytype.payments.playbilling.BillingPurchaseState +import com.anytypeio.anytype.payments.viewmodel.MembershipMainState +import com.anytypeio.anytype.payments.viewmodel.MembershipTierState +import com.anytypeio.anytype.presentation.membership.models.MembershipStatus +import com.anytypeio.anytype.presentation.membership.models.TierId +import junit.framework.TestCase +import kotlin.test.assertIs +import kotlinx.coroutines.test.runTest +import net.bytebuddy.utility.RandomString +import org.junit.Test + +class TierAndroidBillingUnavailableTest : MembershipTestsSetup() { + + private fun commonTestSetup(): Pair, List> { + val features = listOf("feature-${RandomString.make()}", "feature-${RandomString.make()}") + val tiers = setupTierData(features) + return Pair(features, tiers) + } + + private fun setupTierData(features: List): List { + return listOf( + StubMembershipTierData( + id = MembershipConstants.EXPLORER_ID, + androidProductId = null, + features = features, + periodType = MembershipPeriodType.PERIOD_TYPE_UNLIMITED, + priceStripeUsdCents = 0 + ), + StubMembershipTierData( + id = MembershipConstants.BUILDER_ID, + androidProductId = androidProductId, + features = features, + periodValue = 1, + periodType = MembershipPeriodType.PERIOD_TYPE_YEARS, + priceStripeUsdCents = 9901, + androidManageUrl = "https://anytype.io/pricing" + ), + StubMembershipTierData( + id = MembershipConstants.CO_CREATOR_ID, + androidProductId = null, + features = features, + periodValue = 3, + periodType = MembershipPeriodType.PERIOD_TYPE_YEARS, + priceStripeUsdCents = 29900 + ) + ) + } + + private fun setupMembershipStatus(tiers: List): MembershipStatus { + return MembershipStatus( + activeTier = TierId(MembershipConstants.EXPLORER_ID), + status = Membership.Status.STATUS_ACTIVE, + dateEnds = 0, + paymentMethod = MembershipPaymentMethod.METHOD_NONE, + anyName = "", + tiers = tiers, + formattedDateEnds = "" + ) + } + + /** + * Tier - not active and non free | with androidId (Builder) | billing library unavailable + * TierPreview = [Title|Subtitle|ConditionInfo.Price] + * Tier = [Title|Subtitle|Features|ConditionInfo.Price|ButtonInfo] + */ + @Test + fun `test billing unavailable`() = runTest { + turbineScope { + val (features, tiers) = commonTestSetup() + + stubMembershipProvider(setupMembershipStatus(tiers)) + stubPurchaseState(purchaseState = BillingPurchaseState.Loading) + stubBilling(billingClientState = BillingClientState.NotAvailable) + + val viewModel = buildViewModel() + val viewStateFlow = viewModel.viewState.testIn(backgroundScope) + val tierStateFlow = viewModel.tierState.testIn(backgroundScope) + + assertIs(viewStateFlow.awaitItem()) + assertIs(tierStateFlow.awaitItem()) + + val conditionInfo = TierConditionInfo.Visible.Price( + price = "$99.01", + period = TierPeriod.Year(1) + ) + + viewStateFlow.awaitItem().let { result -> + assertIs(result) + val tier: TierPreview = result.tiersPreview.find { it.id.value == MembershipConstants.BUILDER_ID }!! + TestCase.assertEquals(MembershipConstants.BUILDER_ID, tier.id.value) + TestCase.assertEquals(false, tier.isActive) + TestCase.assertEquals(conditionInfo, tier.conditionInfo) + } + + viewModel.onTierClicked(TierId(MembershipConstants.BUILDER_ID)) + + //STATE : BUILDER, NOT ACTIVE, BILLING UNAVAILABLE + tierStateFlow.awaitItem().let { result -> + assertIs(result) + validateTierView( + tier = result.tier, + expectedFeatures = features, + expectedConditionInfo = conditionInfo, + expectedAnyName = TierAnyName.Hidden, + expectedButtonState = TierButton.Info.Enabled("https://anytype.io/pricing"), + expectedId = MembershipConstants.BUILDER_ID, + expectedActive = false, + expectedEmailState = TierEmail.Hidden + ) + } + } + } + + /** + * Tier - not active and non free | with androidId (Builder) | billing library unavailable | purchase is exist + * TierPreview = [Title|Subtitle|ConditionInfo.Price] + * Tier = [Title|Subtitle|Features|ConditionInfo.Price|ButtonInfo] + */ + @Test + fun `test billing unavailable, but purchase is exist`() = runTest { + turbineScope { + val (features, tiers) = commonTestSetup() + + val purchase = MembershipPurchase(accountId, listOf(androidProductId), + MembershipPurchase.PurchaseState.PURCHASED) + stubPurchaseState(BillingPurchaseState.HasPurchases(listOf(purchase), false)) + + stubMembershipProvider(setupMembershipStatus(tiers)) + stubBilling(billingClientState = BillingClientState.NotAvailable) + + val viewModel = buildViewModel() + val viewStateFlow = viewModel.viewState.testIn(backgroundScope) + val tierStateFlow = viewModel.tierState.testIn(backgroundScope) + + assertIs(viewStateFlow.awaitItem()) + assertIs(tierStateFlow.awaitItem()) + + val conditionInfo = TierConditionInfo.Visible.Price( + price = "$99.01", + period = TierPeriod.Year(1) + ) + + viewStateFlow.awaitItem().let { result -> + assertIs(result) + val tier: TierPreview = result.tiersPreview.find { it.id.value == MembershipConstants.BUILDER_ID }!! + TestCase.assertEquals(MembershipConstants.BUILDER_ID, tier.id.value) + TestCase.assertEquals(false, tier.isActive) + TestCase.assertEquals(conditionInfo, tier.conditionInfo) + } + + viewModel.onTierClicked(TierId(MembershipConstants.BUILDER_ID)) + + //STATE : BUILDER, NOT ACTIVE, BILLING UNAVAILABLE + tierStateFlow.awaitItem().let { result -> + assertIs(result) + validateTierView( + tier = result.tier, + expectedFeatures = features, + expectedConditionInfo = conditionInfo, + expectedAnyName = TierAnyName.Hidden, + expectedButtonState = TierButton.Info.Enabled("https://anytype.io/pricing"), + expectedId = MembershipConstants.BUILDER_ID, + expectedActive = false, + expectedEmailState = TierEmail.Hidden + ) + } + } + } +} \ No newline at end of file