diff --git a/localization/src/main/res/values/strings.xml b/localization/src/main/res/values/strings.xml
index 771e6ec033..8881fa4655 100644
--- a/localization/src/main/res/values/strings.xml
+++ b/localization/src/main/res/values/strings.xml
@@ -1629,6 +1629,9 @@ Please provide specific details of your needs here.
Terms of Use
and
Privacy Policy
+ You’ve already acquired a Membership plan using another Anytype account.
+ Found a subscription with a different id
+ Found more than one subscription
- year
diff --git a/payments/build.gradle b/payments/build.gradle
index bffdcc683f..09897f2894 100644
--- a/payments/build.gradle
+++ b/payments/build.gradle
@@ -1,6 +1,7 @@
plugins {
id "com.android.library"
id "kotlin-android"
+ id "kotlinx-serialization"
}
android {
@@ -47,6 +48,8 @@ dependencies {
implementation libs.timber
+ implementation libs.kotlinxSerializationJson
+
testImplementation libs.junit
testImplementation libs.kotlinTest
testImplementation libs.mockitoKotlin
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 697a5340db..44cf5ff87c 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
@@ -1,6 +1,9 @@
package com.anytypeio.anytype.payments.mapping
+import kotlinx.serialization.Serializable
+import kotlinx.serialization.json.Json
import com.android.billingclient.api.ProductDetails
+import com.android.billingclient.api.Purchase
import com.anytypeio.anytype.core_models.membership.Membership
import com.anytypeio.anytype.core_models.membership.MembershipPaymentMethod
import com.anytypeio.anytype.core_models.membership.MembershipPeriodType
@@ -26,10 +29,10 @@ import com.anytypeio.anytype.payments.viewmodel.MembershipMainState
import com.anytypeio.anytype.presentation.membership.models.MembershipStatus
import com.anytypeio.anytype.presentation.membership.models.TierId
-
fun MembershipStatus.toMainView(
billingClientState: BillingClientState,
- billingPurchaseState: BillingPurchaseState
+ billingPurchaseState: BillingPurchaseState,
+ accountId: String
): MembershipMainState {
val (showBanner, subtitle) = if (activeTier.value in ACTIVE_TIERS_WITH_BANNERS) {
true to R.string.payments_subheader
@@ -45,7 +48,7 @@ fun MembershipStatus.toMainView(
billingClientState = billingClientState,
billingPurchaseState = billingPurchaseState
)
- },
+ }.sortedByDescending { it.isActive },
membershipLevelDetails = MEMBERSHIP_LEVEL_DETAILS,
privacyPolicy = PRIVACY_POLICY,
termsOfService = TERMS_OF_SERVICE,
@@ -55,7 +58,8 @@ fun MembershipStatus.toMainView(
it.toView(
membershipStatus = this,
billingClientState = billingClientState,
- billingPurchaseState = billingPurchaseState
+ billingPurchaseState = billingPurchaseState,
+ accountId = accountId,
)
}
)
@@ -79,7 +83,8 @@ private fun MembershipTierData.isActiveTierPurchasedOnAndroid(activePaymentMetho
fun MembershipTierData.toView(
membershipStatus: MembershipStatus,
billingClientState: BillingClientState,
- billingPurchaseState: BillingPurchaseState
+ billingPurchaseState: BillingPurchaseState,
+ accountId: String
): Tier {
val tierId = TierId(id)
val isActive = membershipStatus.isTierActive(id)
@@ -107,7 +112,8 @@ fun MembershipTierData.toView(
buttonState = toButtonView(
isActive = isActive,
billingPurchaseState = billingPurchaseState,
- membershipStatus = membershipStatus
+ membershipStatus = membershipStatus,
+ accountId = accountId
),
email = emailState,
color = colorStr,
@@ -148,7 +154,8 @@ fun MembershipTierData.toPreviewView(
private fun MembershipTierData.toButtonView(
isActive: Boolean,
billingPurchaseState: BillingPurchaseState,
- membershipStatus: MembershipStatus
+ membershipStatus: MembershipStatus,
+ accountId: String
): TierButton {
val androidProductId = this.androidProductId
val androidInfoUrl = this.androidManageUrl
@@ -206,14 +213,15 @@ private fun MembershipTierData.toButtonView(
} else {
when (billingPurchaseState) {
is BillingPurchaseState.HasPurchases -> {
- //Tier has purchase, but it's not active yet, still waiting for a event from the mw
- TierButton.Hidden
+ getButtonStateAccordingToPurchaseState(
+ androidProductId = androidProductId,
+ accountId = accountId,
+ purchases = billingPurchaseState.purchases
+ )
}
-
BillingPurchaseState.Loading -> {
TierButton.Hidden
}
-
BillingPurchaseState.NoPurchases -> {
if (membershipStatus.anyName.isBlank()) {
TierButton.Pay.Disabled
@@ -226,6 +234,32 @@ private fun MembershipTierData.toButtonView(
}
}
+private fun getButtonStateAccordingToPurchaseState(
+ androidProductId: String?,
+ accountId: String,
+ purchases: List
+): TierButton {
+ when (purchases.size) {
+ 0 -> {
+ return TierButton.Hidden
+ }
+ 1 -> {
+ val purchase = purchases[0]
+ val purchaseModel = Json.decodeFromString(purchase.originalJson)
+ if (purchaseModel.obfuscatedAccountId != accountId) {
+ return TierButton.HiddenWithText.DifferentPurchaseAccountId
+ }
+ if (purchaseModel.productId != androidProductId) {
+ return TierButton.HiddenWithText.DifferentPurchaseProductId
+ }
+ return TierButton.Hidden
+ }
+ else -> {
+ return TierButton.HiddenWithText.MoreThenOnePurchase
+ }
+ }
+}
+
private fun MembershipTierData.getAnyName(
isActive: Boolean,
billingClientState: BillingClientState,
@@ -289,9 +323,9 @@ private fun MembershipTierData.getConditionInfo(
createConditionInfoForNonBillingTier()
} else {
createConditionInfoForBillingTier(
- billingClientState,
- membershipStatus,
- billingPurchaseState
+ billingClientState = billingClientState,
+ membershipStatus = membershipStatus,
+ billingPurchaseState = billingPurchaseState
)
}
}
@@ -341,10 +375,7 @@ private fun MembershipTierData.createConditionInfoForBillingTier(
) {
return TierConditionInfo.Visible.Pending
}
- if (
- billingPurchaseState is BillingPurchaseState.Loading
- || billingPurchaseState is BillingPurchaseState.HasPurchases
- ) {
+ if (billingPurchaseState is BillingPurchaseState.Loading) {
return TierConditionInfo.Visible.Pending
}
return when (billingClientState) {
@@ -406,4 +437,10 @@ private fun MembershipTierData.getTierEmail(isActive: Boolean, membershipEmail:
}
}
return TierEmail.Hidden
-}
\ No newline at end of file
+}
+
+@Serializable
+data class PurchaseModel(
+ val obfuscatedAccountId: String,
+ val productId: String
+)
\ No newline at end of file
diff --git a/payments/src/main/java/com/anytypeio/anytype/payments/models/MembershipModels.kt b/payments/src/main/java/com/anytypeio/anytype/payments/models/MembershipModels.kt
index 0f6ac3bacb..ff7734757a 100644
--- a/payments/src/main/java/com/anytypeio/anytype/payments/models/MembershipModels.kt
+++ b/payments/src/main/java/com/anytypeio/anytype/payments/models/MembershipModels.kt
@@ -58,6 +58,11 @@ sealed class TierPeriod {
sealed class TierButton {
data object Hidden : TierButton()
+ sealed class HiddenWithText : TierButton() {
+ data object DifferentPurchaseAccountId : HiddenWithText()
+ data object DifferentPurchaseProductId : HiddenWithText()
+ data object MoreThenOnePurchase : HiddenWithText()
+ }
sealed class Submit : TierButton() {
data object Enabled : Submit()
data object Disabled : Submit()
diff --git a/payments/src/main/java/com/anytypeio/anytype/payments/screens/TierScreen.kt b/payments/src/main/java/com/anytypeio/anytype/payments/screens/TierScreen.kt
index 7d78be6385..788c2d4b15 100644
--- a/payments/src/main/java/com/anytypeio/anytype/payments/screens/TierScreen.kt
+++ b/payments/src/main/java/com/anytypeio/anytype/payments/screens/TierScreen.kt
@@ -41,6 +41,7 @@ import com.anytypeio.anytype.core_ui.views.ButtonPrimary
import com.anytypeio.anytype.core_ui.views.ButtonSecondary
import com.anytypeio.anytype.core_ui.views.ButtonSize
import com.anytypeio.anytype.core_ui.views.HeadlineTitle
+import com.anytypeio.anytype.core_ui.views.Relations2
import com.anytypeio.anytype.payments.R
import com.anytypeio.anytype.payments.constants.MembershipConstants.EXPLORER_ID
import com.anytypeio.anytype.payments.constants.MembershipConstants.PRIVACY_POLICY
@@ -247,27 +248,42 @@ private fun MainButton(
buttonState: TierButton,
actionTier: (TierAction) -> Unit
) {
- if (buttonState !is TierButton.Hidden) {
- val (stringRes, enabled) = getButtonText(buttonState)
- ButtonPrimary(
- enabled = enabled,
- text = stringResource(id = stringRes),
- onClick = {
- when (buttonState) {
- is TierButton.Pay.Enabled -> actionTier(TierAction.PayClicked(tier.id))
- is TierButton.Info.Enabled -> actionTier(TierAction.OpenUrl(tier.urlInfo))
- TierButton.Submit.Enabled -> actionTier(TierAction.SubmitClicked)
- else -> {
- Timber.d("MainButton: skipped action: $buttonState")
- }
- }
+ when (buttonState) {
+ TierButton.Hidden -> {}
+ TierButton.HiddenWithText.DifferentPurchaseAccountId -> {
+ val text = stringResource(id = R.string.membership_support_already_acquired)
+ SupportText(text = text)
+ }
+ TierButton.HiddenWithText.DifferentPurchaseProductId -> {
+ val text = stringResource(id = R.string.membership_support_different_subscription)
+ SupportText(text = text)
+ }
+ TierButton.HiddenWithText.MoreThenOnePurchase -> {
+ val text = stringResource(id = R.string.membership_support_more_then_one_subscription)
+ SupportText(text = text)
+ }
+ else -> {
+ val (stringRes, enabled) = getButtonText(buttonState)
+ ButtonPrimary(
+ enabled = enabled,
+ text = stringResource(id = stringRes),
+ onClick = {
+ when (buttonState) {
+ is TierButton.Pay.Enabled -> actionTier(TierAction.PayClicked(tier.id))
+ is TierButton.Info.Enabled -> actionTier(TierAction.OpenUrl(tier.urlInfo))
+ TierButton.Submit.Enabled -> actionTier(TierAction.SubmitClicked)
+ else -> {
+ Timber.d("MainButton: skipped action: $buttonState")
+ }
+ }
- },
- size = ButtonSize.Large,
- modifier = Modifier
- .fillMaxWidth()
- .padding(horizontal = 20.dp)
- )
+ },
+ size = ButtonSize.Large,
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 20.dp)
+ )
+ }
}
}
@@ -366,9 +382,25 @@ private fun getButtonText(buttonState: TierButton): Pair {
TierButton.Pay.Disabled -> Pair(R.string.payments_button_pay, false)
TierButton.Pay.Enabled -> Pair(R.string.payments_button_pay, true)
TierButton.ChangeEmail -> Pair(R.string.payments_button_change_email, true)
+ TierButton.HiddenWithText.DifferentPurchaseAccountId -> Pair(0, false)
+ TierButton.HiddenWithText.DifferentPurchaseProductId -> Pair(0, false)
+ TierButton.HiddenWithText.MoreThenOnePurchase -> Pair(0, false)
}
}
+@Composable
+private fun SupportText(text: String) {
+ Text(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(start = 20.dp, end = 20.dp, top = 2.dp, bottom = 10.dp),
+ text = text,
+ textAlign = TextAlign.Center,
+ style = Relations2,
+ color = colorResource(id = R.color.text_secondary)
+ )
+}
+
@OptIn(ExperimentalFoundationApi::class)
@Preview
@Composable
diff --git a/payments/src/main/java/com/anytypeio/anytype/payments/viewmodel/MembershipViewModel.kt b/payments/src/main/java/com/anytypeio/anytype/payments/viewmodel/MembershipViewModel.kt
index b074d48f13..abad6c5fad 100644
--- a/payments/src/main/java/com/anytypeio/anytype/payments/viewmodel/MembershipViewModel.kt
+++ b/payments/src/main/java/com/anytypeio/anytype/payments/viewmodel/MembershipViewModel.kt
@@ -90,6 +90,8 @@ class MembershipViewModel(
init {
viewModelScope.launch {
+ val account = getAccount.async(Unit)
+ val accountId = account.getOrNull()?.id.orEmpty()
combine(
membershipProvider.status()
.onEach { setupBillingClient(it) },
@@ -104,7 +106,8 @@ class MembershipViewModel(
}.collect { (membershipStatus, billingClientState, purchases) ->
val newState = membershipStatus.toMainView(
billingClientState = billingClientState,
- billingPurchaseState = purchases
+ billingPurchaseState = purchases,
+ accountId = accountId
)
proceedWithUpdatingVisibleTier(newState)
viewState.value = newState
diff --git a/payments/src/test/java/com/anytypeio/anytype/payments/MembershipTestsSetup.kt b/payments/src/test/java/com/anytypeio/anytype/payments/MembershipTestsSetup.kt
index 6b2587db35..043accd454 100644
--- a/payments/src/test/java/com/anytypeio/anytype/payments/MembershipTestsSetup.kt
+++ b/payments/src/test/java/com/anytypeio/anytype/payments/MembershipTestsSetup.kt
@@ -2,11 +2,13 @@ package com.anytypeio.anytype.payments
import com.android.billingclient.api.ProductDetails
import com.anytypeio.anytype.analytics.base.Analytics
+import com.anytypeio.anytype.core_models.Account
import com.anytypeio.anytype.core_models.membership.Membership
import com.anytypeio.anytype.core_models.membership.MembershipPaymentMethod
import com.anytypeio.anytype.core_models.membership.MembershipTierData
import com.anytypeio.anytype.domain.auth.interactor.GetAccount
import com.anytypeio.anytype.domain.base.AppCoroutineDispatchers
+import com.anytypeio.anytype.domain.base.Resultat
import com.anytypeio.anytype.domain.block.repo.BlockRepository
import com.anytypeio.anytype.domain.payments.GetMembershipEmailStatus
import com.anytypeio.anytype.domain.payments.GetMembershipPaymentUrl
@@ -26,6 +28,7 @@ import com.anytypeio.anytype.payments.viewmodel.MembershipViewModel
import com.anytypeio.anytype.presentation.membership.models.MembershipStatus
import com.anytypeio.anytype.presentation.membership.models.TierId
import com.anytypeio.anytype.presentation.membership.provider.MembershipProvider
+import com.anytypeio.anytype.test_utils.MockDataFactory
import junit.framework.TestCase.assertEquals
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
@@ -41,6 +44,7 @@ import org.mockito.kotlin.stub
import org.mockito.Mock
import org.mockito.Mockito
import org.mockito.MockitoAnnotations
+import org.mockito.kotlin.doReturn
open class MembershipTestsSetup {
@@ -83,6 +87,7 @@ open class MembershipTestsSetup {
@Mock
lateinit var getMembershipPaymentUrl: GetMembershipPaymentUrl
protected val androidProductId = "id_android_builder"
+ protected val accountId = "accountId-${RandomString.make()}"
fun membershipStatus(tiers: List) = MembershipStatus(
activeTier = TierId(MembershipConstants.EXPLORER_ID),
@@ -97,6 +102,7 @@ open class MembershipTestsSetup {
@Before
open fun setUp() {
MockitoAnnotations.openMocks(this)
+ stubAccount()
}
protected fun validateTierView(
@@ -164,6 +170,19 @@ open class MembershipTestsSetup {
isMembershipNameValid = isMembershipNameValid,
setMembershipEmail = setMembershipEmail,
verifyMembershipEmailCode = verifyMembershipEmailCode,
- getMembershipEmailStatus = getMembershipEmailStatus,
+ getMembershipEmailStatus = getMembershipEmailStatus
)
+
+ protected fun stubAccount() {
+ getAccount.stub {
+ onBlocking { async(Unit) } doReturn Resultat.success(
+ Account(
+ id = accountId,
+ name = MockDataFactory.randomString(),
+ avatar = null,
+ color = null
+ )
+ )
+ }
+ }
}
\ No newline at end of file
diff --git a/payments/src/test/java/com/anytypeio/anytype/payments/TierActiveWithDifferentSubIdTest.kt b/payments/src/test/java/com/anytypeio/anytype/payments/TierActiveWithDifferentSubIdTest.kt
new file mode 100644
index 0000000000..c075fd8635
--- /dev/null
+++ b/payments/src/test/java/com/anytypeio/anytype/payments/TierActiveWithDifferentSubIdTest.kt
@@ -0,0 +1,510 @@
+package com.anytypeio.anytype.payments
+
+import app.cash.turbine.turbineScope
+import com.android.billingclient.api.ProductDetails
+import com.android.billingclient.api.Purchase
+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.BillingPriceInfo
+import com.anytypeio.anytype.payments.models.PeriodDescription
+import com.anytypeio.anytype.payments.models.PeriodUnit
+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.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 kotlin.test.assertIs
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.flow.flow
+import kotlinx.coroutines.test.advanceUntilIdle
+import kotlinx.coroutines.test.runTest
+import net.bytebuddy.utility.RandomString
+import org.junit.Test
+import org.mockito.Mockito
+import org.mockito.kotlin.doReturn
+import org.mockito.kotlin.stub
+
+class TierActiveWithDifferentSubIdTest : MembershipTestsSetup() {
+
+ // Randomly generated account ID for testing
+ protected val accountIdDifferent = "accountIdDifferent-${RandomString.make()}"
+
+ // Common test setup function to generate features and tiers
+ private fun commonTestSetup(): Pair, List> {
+ val features = listOf("feature-${RandomString.make()}", "feature-${RandomString.make()}")
+ val tiers = setupTierData(features)
+ return Pair(features, tiers)
+ }
+
+ // Setup tier data with predefined features
+ 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 = 9900
+ )
+ )
+ }
+
+ /**
+ * Test Case: Display of Builder Tier when Subscription with a Different ID is Purchased
+ *
+ * Objective: Verify that the Builder Tier is NOT available for purchase when a subscription with a different ID has been purchased.
+ *
+ * Preconditions:
+ * • The user has an active subscription with an AccountId that is different from the Session AccountId.
+ */
+ @Test
+ fun `when subscription with different AccountId is active`() = runTest {
+ turbineScope {
+ val (features, tiers) = commonTestSetup()
+
+ // Setup for two subscriptions with different IDs from Google Play
+
+ // First production subscription, this ID is used in the Tier model
+ val product1 = Mockito.mock(ProductDetails::class.java)
+ val subscriptionOfferDetails1 =
+ listOf(Mockito.mock(ProductDetails.SubscriptionOfferDetails::class.java))
+ val pricingPhases1 = Mockito.mock(ProductDetails.PricingPhases::class.java)
+ val pricingPhaseList1 = listOf(Mockito.mock(ProductDetails.PricingPhase::class.java))
+
+ Mockito.`when`(product1.productId).thenReturn(androidProductId)
+ Mockito.`when`(product1.subscriptionOfferDetails).thenReturn(subscriptionOfferDetails1)
+ Mockito.`when`(subscriptionOfferDetails1[0].pricingPhases).thenReturn(pricingPhases1)
+ Mockito.`when`(pricingPhases1.pricingPhaseList).thenReturn(pricingPhaseList1)
+ val formattedPrice1 = "$299"
+ Mockito.`when`(pricingPhaseList1[0].formattedPrice).thenReturn(formattedPrice1)
+ Mockito.`when`(pricingPhaseList1[0].billingPeriod).thenReturn("P1Y")
+ stubBilling(billingClientState = BillingClientState.Connected(listOf(product1)))
+
+ // Mocking purchase
+ val purchase = Mockito.mock(Purchase::class.java)
+ Mockito.`when`(purchase.products).thenReturn(listOf(androidProductId))
+ Mockito.`when`(purchase.isAcknowledged).thenReturn(true)
+ val purchaseJson =
+ "{\"obfuscatedAccountId\":\"$accountIdDifferent\", \"productId\":\"$androidProductId\"}"
+ Mockito.`when`(purchase.originalJson).thenReturn(purchaseJson)
+ stubPurchaseState(BillingPurchaseState.HasPurchases(listOf(purchase), false))
+
+ // Mocking the flow of membership status
+ val flow = flow {
+ emit(
+ MembershipStatus(
+ activeTier = TierId(MembershipConstants.EXPLORER_ID),
+ status = Membership.Status.STATUS_ACTIVE,
+ dateEnds = 0L,
+ paymentMethod = MembershipPaymentMethod.METHOD_NONE,
+ anyName = "",
+ tiers = tiers,
+ formattedDateEnds = "formattedDateEnds-${RandomString.make()}"
+ )
+ )
+ }
+ membershipProvider.stub {
+ onBlocking { status() } doReturn flow
+ }
+
+ val validPeriod = TierPeriod.Year(1)
+
+ val viewModel = buildViewModel()
+
+ // Testing initial state and tier visibility
+ val mainStateFlow = viewModel.viewState.testIn(backgroundScope)
+ val tierStateFlow = viewModel.tierState.testIn(backgroundScope)
+
+ val firstMainItem = mainStateFlow.awaitItem()
+ assertIs(firstMainItem)
+ val firstTierItem = tierStateFlow.awaitItem()
+ assertIs(firstTierItem)
+
+ val secondMainItem = mainStateFlow.awaitItem()
+ assertIs(secondMainItem)
+
+ // Simulate user clicking on the Builder Tier
+ delay(200)
+ viewModel.onTierClicked(TierId(MembershipConstants.BUILDER_ID))
+
+ advanceUntilIdle()
+
+ // Verify that the Builder Tier is shown as available for purchase with the correct price and billing period
+ val expectedConditionInfo = TierConditionInfo.Visible.PriceBilling(
+ BillingPriceInfo(
+ formattedPrice = formattedPrice1,
+ period = PeriodDescription(
+ amount = 1,
+ unit = PeriodUnit.YEARS
+ )
+ )
+ )
+ val secondTierItem = tierStateFlow.awaitItem()
+ secondTierItem.let {
+ assertIs(secondTierItem)
+ validateTierView(
+ expectedId = MembershipConstants.BUILDER_ID,
+ expectedActive = false,
+ expectedFeatures = features,
+ expectedConditionInfo = expectedConditionInfo,
+ expectedAnyName = TierAnyName.Hidden,
+ expectedButtonState = TierButton.HiddenWithText.DifferentPurchaseAccountId,
+ tier = secondTierItem.tier,
+ expectedEmailState = TierEmail.Hidden
+ )
+ }
+ }
+ }
+
+ @Test
+ fun `when subscription with different ProductId is active`() =
+ runTest {
+ turbineScope {
+ val (features, tiers) = commonTestSetup()
+
+ val product1 = Mockito.mock(ProductDetails::class.java)
+ val subscriptionOfferDetails1 =
+ listOf(Mockito.mock(ProductDetails.SubscriptionOfferDetails::class.java))
+ val pricingPhases1 = Mockito.mock(ProductDetails.PricingPhases::class.java)
+ val pricingPhaseList1 =
+ listOf(Mockito.mock(ProductDetails.PricingPhase::class.java))
+
+ Mockito.`when`(product1.productId).thenReturn(androidProductId)
+ Mockito.`when`(product1?.subscriptionOfferDetails)
+ .thenReturn(subscriptionOfferDetails1)
+ Mockito.`when`(subscriptionOfferDetails1[0].pricingPhases)
+ .thenReturn(pricingPhases1)
+ Mockito.`when`(pricingPhases1?.pricingPhaseList).thenReturn(pricingPhaseList1)
+ val formattedPrice1 = "$999"
+ Mockito.`when`(pricingPhaseList1[0]?.formattedPrice).thenReturn(formattedPrice1)
+ Mockito.`when`(pricingPhaseList1[0]?.billingPeriod).thenReturn("P1Y")
+
+ //вторая купленная на другой аккаунт подписка, этот id НЕ приходит в модели Тира
+ val product2 = Mockito.mock(ProductDetails::class.java)
+ val subscriptionOfferDetails2 =
+ listOf(Mockito.mock(ProductDetails.SubscriptionOfferDetails::class.java))
+ val pricingPhases2 = Mockito.mock(ProductDetails.PricingPhases::class.java)
+ val pricingPhaseList2 =
+ listOf(Mockito.mock(ProductDetails.PricingPhase::class.java))
+
+ // Mock active subscription with an invalid ID
+ val invalidProductId = "invalidProductId-${RandomString.make()}"
+ Mockito.`when`(product2.productId).thenReturn(invalidProductId)
+ Mockito.`when`(product2?.subscriptionOfferDetails)
+ .thenReturn(subscriptionOfferDetails2)
+ Mockito.`when`(subscriptionOfferDetails2[0].pricingPhases)
+ .thenReturn(pricingPhases2)
+ Mockito.`when`(pricingPhases2?.pricingPhaseList).thenReturn(pricingPhaseList2)
+ val formattedPrice2 = "$111"
+ Mockito.`when`(pricingPhaseList2[0]?.formattedPrice).thenReturn(formattedPrice2)
+ Mockito.`when`(pricingPhaseList2[0]?.billingPeriod).thenReturn("P3Y")
+ stubBilling(
+ billingClientState = BillingClientState.Connected(
+ listOf(
+ product2,
+ product1
+ )
+ )
+ )
+
+ val purchase = Mockito.mock(Purchase::class.java)
+ Mockito.`when`(purchase.products).thenReturn(listOf(invalidProductId))
+ Mockito.`when`(purchase.isAcknowledged).thenReturn(true)
+ val purchaseJson =
+ "{\"obfuscatedAccountId\":\"$accountId\", \"productId\":\"$invalidProductId\"}"
+ Mockito.`when`(purchase.originalJson).thenReturn(purchaseJson)
+ stubPurchaseState(BillingPurchaseState.HasPurchases(listOf(purchase), false))
+
+ val flow = flow {
+ emit(
+ MembershipStatus(
+ activeTier = TierId(MembershipConstants.EXPLORER_ID),
+ status = Membership.Status.STATUS_ACTIVE,
+ dateEnds = 0L,
+ paymentMethod = MembershipPaymentMethod.METHOD_NONE,
+ anyName = "",
+ tiers = tiers,
+ formattedDateEnds = "formattedDateEnds-${RandomString.make()}"
+ )
+ )
+ }
+ membershipProvider.stub {
+ onBlocking { status() } doReturn flow
+ }
+
+ val viewModel = buildViewModel()
+
+ val mainStateFlow = viewModel.viewState.testIn(backgroundScope)
+ val tierStateFlow = viewModel.tierState.testIn(backgroundScope)
+
+ val firstMainItem = mainStateFlow.awaitItem()
+ assertIs(firstMainItem)
+ val firstTierItem = tierStateFlow.awaitItem()
+ assertIs(firstTierItem)
+
+ val secondMainItem = mainStateFlow.awaitItem()
+ assertIs(secondMainItem)
+
+ delay(200)
+ viewModel.onTierClicked(TierId(MembershipConstants.BUILDER_ID))
+
+ advanceUntilIdle()
+
+ val expectedConditionInfo = TierConditionInfo.Visible.PriceBilling(
+ BillingPriceInfo(
+ formattedPrice = formattedPrice1,
+ period = PeriodDescription(
+ amount = 1,
+ unit = PeriodUnit.YEARS
+ )
+ )
+ )
+ val secondTierItem = tierStateFlow.awaitItem()
+ secondTierItem.let {
+ assertIs(secondTierItem)
+ validateTierView(
+ expectedId = MembershipConstants.BUILDER_ID,
+ expectedActive = false,
+ expectedFeatures = features,
+ expectedConditionInfo = expectedConditionInfo,
+ expectedAnyName = TierAnyName.Hidden,
+ expectedButtonState = TierButton.HiddenWithText.DifferentPurchaseProductId,
+ tier = secondTierItem.tier,
+ expectedEmailState = TierEmail.Hidden
+ )
+ }
+ }
+ }
+
+ /**
+ * Verify that the Builder Tier is available for purchase when there is no active subscription.
+ */
+
+ @Test
+ fun `when no active subscription`() = runTest {
+ turbineScope {
+ val (features, tiers) = commonTestSetup()
+
+ val product1 = Mockito.mock(ProductDetails::class.java)
+ val subscriptionOfferDetails1 =
+ listOf(Mockito.mock(ProductDetails.SubscriptionOfferDetails::class.java))
+ val pricingPhases1 = Mockito.mock(ProductDetails.PricingPhases::class.java)
+ val pricingPhaseList1 = listOf(Mockito.mock(ProductDetails.PricingPhase::class.java))
+
+ Mockito.`when`(product1.productId).thenReturn(androidProductId)
+ Mockito.`when`(product1.subscriptionOfferDetails).thenReturn(subscriptionOfferDetails1)
+ Mockito.`when`(subscriptionOfferDetails1[0].pricingPhases).thenReturn(pricingPhases1)
+ Mockito.`when`(pricingPhases1.pricingPhaseList).thenReturn(pricingPhaseList1)
+ val formattedPrice1 = "$299"
+ Mockito.`when`(pricingPhaseList1[0].formattedPrice).thenReturn(formattedPrice1)
+ Mockito.`when`(pricingPhaseList1[0].billingPeriod).thenReturn("P1Y")
+ stubBilling(billingClientState = BillingClientState.Connected(listOf(product1)))
+
+ // Mock no active subscription
+ stubPurchaseState(BillingPurchaseState.NoPurchases)
+
+ val flow = flow {
+ emit(
+ MembershipStatus(
+ activeTier = TierId(MembershipConstants.EXPLORER_ID),
+ status = Membership.Status.STATUS_ACTIVE,
+ dateEnds = 0L,
+ paymentMethod = MembershipPaymentMethod.METHOD_NONE,
+ anyName = "",
+ tiers = tiers,
+ formattedDateEnds = "formattedDateEnds-${RandomString.make()}"
+ )
+ )
+ }
+ membershipProvider.stub {
+ onBlocking { status() } doReturn flow
+ }
+
+ val viewModel = buildViewModel()
+
+ val mainStateFlow = viewModel.viewState.testIn(backgroundScope)
+ val tierStateFlow = viewModel.tierState.testIn(backgroundScope)
+
+ val firstMainItem = mainStateFlow.awaitItem()
+ assertIs(firstMainItem)
+ val firstTierItem = tierStateFlow.awaitItem()
+ assertIs(firstTierItem)
+
+ val secondMainItem = mainStateFlow.awaitItem()
+ assertIs(secondMainItem)
+
+ delay(200)
+ viewModel.onTierClicked(TierId(MembershipConstants.BUILDER_ID))
+
+ advanceUntilIdle()
+
+ val expectedConditionInfo = TierConditionInfo.Visible.PriceBilling(
+ BillingPriceInfo(
+ formattedPrice = formattedPrice1,
+ period = PeriodDescription(
+ amount = 1,
+ unit = PeriodUnit.YEARS
+ )
+ )
+ )
+ val secondTierItem = tierStateFlow.awaitItem()
+ secondTierItem.let {
+ assertIs(secondTierItem)
+ validateTierView(
+ expectedId = MembershipConstants.BUILDER_ID,
+ expectedActive = false,
+ expectedFeatures = features,
+ expectedConditionInfo = expectedConditionInfo,
+ expectedAnyName = TierAnyName.Visible.Enter,
+ expectedButtonState = TierButton.Pay.Disabled,
+ tier = secondTierItem.tier,
+ expectedEmailState = TierEmail.Hidden
+ )
+ }
+ }
+ }
+
+ @Test
+ fun `when there are more then one purchase`() = runTest {
+ turbineScope {
+ val (features, tiers) = commonTestSetup()
+
+ val product1 = Mockito.mock(ProductDetails::class.java)
+ val subscriptionOfferDetails1 =
+ listOf(Mockito.mock(ProductDetails.SubscriptionOfferDetails::class.java))
+ val pricingPhases1 = Mockito.mock(ProductDetails.PricingPhases::class.java)
+ val pricingPhaseList1 =
+ listOf(Mockito.mock(ProductDetails.PricingPhase::class.java))
+
+ Mockito.`when`(product1.productId).thenReturn(androidProductId)
+ Mockito.`when`(product1?.subscriptionOfferDetails)
+ .thenReturn(subscriptionOfferDetails1)
+ Mockito.`when`(subscriptionOfferDetails1[0].pricingPhases)
+ .thenReturn(pricingPhases1)
+ Mockito.`when`(pricingPhases1?.pricingPhaseList).thenReturn(pricingPhaseList1)
+ val formattedPrice1 = "$999"
+ Mockito.`when`(pricingPhaseList1[0]?.formattedPrice).thenReturn(formattedPrice1)
+ Mockito.`when`(pricingPhaseList1[0]?.billingPeriod).thenReturn("P1Y")
+
+ //вторая купленная на другой аккаунт подписка, этот id НЕ приходит в модели Тира
+ val product2 = Mockito.mock(ProductDetails::class.java)
+ val subscriptionOfferDetails2 =
+ listOf(Mockito.mock(ProductDetails.SubscriptionOfferDetails::class.java))
+ val pricingPhases2 = Mockito.mock(ProductDetails.PricingPhases::class.java)
+ val pricingPhaseList2 =
+ listOf(Mockito.mock(ProductDetails.PricingPhase::class.java))
+
+ // Mock active subscription with an invalid ID
+ val invalidProductId = "invalidProductId-${RandomString.make()}"
+ Mockito.`when`(product2.productId).thenReturn(invalidProductId)
+ Mockito.`when`(product2?.subscriptionOfferDetails)
+ .thenReturn(subscriptionOfferDetails2)
+ Mockito.`when`(subscriptionOfferDetails2[0].pricingPhases)
+ .thenReturn(pricingPhases2)
+ Mockito.`when`(pricingPhases2?.pricingPhaseList).thenReturn(pricingPhaseList2)
+ val formattedPrice2 = "$111"
+ Mockito.`when`(pricingPhaseList2[0]?.formattedPrice).thenReturn(formattedPrice2)
+ Mockito.`when`(pricingPhaseList2[0]?.billingPeriod).thenReturn("P3Y")
+ stubBilling(
+ billingClientState = BillingClientState.Connected(
+ listOf(
+ product2,
+ product1
+ )
+ )
+ )
+
+ val purchase1 = Mockito.mock(Purchase::class.java)
+ Mockito.`when`(purchase1.products).thenReturn(listOf(invalidProductId))
+ Mockito.`when`(purchase1.isAcknowledged).thenReturn(true)
+ val purchaseJson1 =
+ "{\"obfuscatedAccountId\":\"$accountId\", \"productId\":\"$invalidProductId\"}"
+ Mockito.`when`(purchase1.originalJson).thenReturn(purchaseJson1)
+
+ val purchase2 = Mockito.mock(Purchase::class.java)
+ Mockito.`when`(purchase2.products).thenReturn(listOf(invalidProductId))
+ Mockito.`when`(purchase2.isAcknowledged).thenReturn(true)
+ val purchaseJson2 =
+ "{\"obfuscatedAccountId\":\"$accountId\", \"productId\":\"$androidProductId\"}"
+ Mockito.`when`(purchase2.originalJson).thenReturn(purchaseJson2)
+
+ stubPurchaseState(BillingPurchaseState.HasPurchases(listOf(purchase1, purchase2), false))
+
+ val flow = flow {
+ emit(
+ MembershipStatus(
+ activeTier = TierId(MembershipConstants.EXPLORER_ID),
+ status = Membership.Status.STATUS_ACTIVE,
+ dateEnds = 0L,
+ paymentMethod = MembershipPaymentMethod.METHOD_NONE,
+ anyName = "",
+ tiers = tiers,
+ formattedDateEnds = "formattedDateEnds-${RandomString.make()}"
+ )
+ )
+ }
+ membershipProvider.stub {
+ onBlocking { status() } doReturn flow
+ }
+
+ val viewModel = buildViewModel()
+
+ val mainStateFlow = viewModel.viewState.testIn(backgroundScope)
+ val tierStateFlow = viewModel.tierState.testIn(backgroundScope)
+
+ val firstMainItem = mainStateFlow.awaitItem()
+ assertIs(firstMainItem)
+ val firstTierItem = tierStateFlow.awaitItem()
+ assertIs(firstTierItem)
+
+ val secondMainItem = mainStateFlow.awaitItem()
+ assertIs(secondMainItem)
+
+ delay(200)
+ viewModel.onTierClicked(TierId(MembershipConstants.BUILDER_ID))
+
+ advanceUntilIdle()
+
+ val expectedConditionInfo = TierConditionInfo.Visible.PriceBilling(
+ BillingPriceInfo(
+ formattedPrice = formattedPrice1,
+ period = PeriodDescription(
+ amount = 1,
+ unit = PeriodUnit.YEARS
+ )
+ )
+ )
+ val secondTierItem = tierStateFlow.awaitItem()
+ secondTierItem.let {
+ assertIs(secondTierItem)
+ validateTierView(
+ expectedId = MembershipConstants.BUILDER_ID,
+ expectedActive = false,
+ expectedFeatures = features,
+ expectedConditionInfo = expectedConditionInfo,
+ expectedAnyName = TierAnyName.Hidden,
+ expectedButtonState = TierButton.HiddenWithText.MoreThenOnePurchase,
+ tier = secondTierItem.tier,
+ expectedEmailState = TierEmail.Hidden
+ )
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/payments/src/test/java/com/anytypeio/anytype/payments/TierBuilderFallbackOnExplorerTest.kt b/payments/src/test/java/com/anytypeio/anytype/payments/TierBuilderFallbackOnExplorerTest.kt
index 876fc4b211..06324c30f6 100644
--- a/payments/src/test/java/com/anytypeio/anytype/payments/TierBuilderFallbackOnExplorerTest.kt
+++ b/payments/src/test/java/com/anytypeio/anytype/payments/TierBuilderFallbackOnExplorerTest.kt
@@ -9,6 +9,9 @@ import com.anytypeio.anytype.core_models.membership.MembershipPeriodType
import com.anytypeio.anytype.core_models.membership.MembershipTierData
import com.anytypeio.anytype.payments.constants.MembershipConstants.BUILDER_ID
import com.anytypeio.anytype.payments.constants.MembershipConstants.EXPLORER_ID
+import com.anytypeio.anytype.payments.models.BillingPriceInfo
+import com.anytypeio.anytype.payments.models.PeriodDescription
+import com.anytypeio.anytype.payments.models.PeriodUnit
import com.anytypeio.anytype.payments.models.TierAnyName
import com.anytypeio.anytype.payments.models.TierButton
import com.anytypeio.anytype.payments.models.TierConditionInfo
@@ -84,6 +87,9 @@ class TierBuilderFallbackOnExplorerTest : MembershipTestsSetup() {
val purchase = Mockito.mock(Purchase::class.java)
Mockito.`when`(purchase.products).thenReturn(listOf(androidProductId))
Mockito.`when`(purchase.isAcknowledged).thenReturn(true)
+ val purchaseJson =
+ "{\"obfuscatedAccountId\":\"$accountId\", \"productId\":\"$androidProductId\"}"
+ Mockito.`when`(purchase.originalJson).thenReturn(purchaseJson)
stubPurchaseState(BillingPurchaseState.HasPurchases(listOf(purchase), false))
val flow = flow {
emit(
@@ -167,7 +173,15 @@ class TierBuilderFallbackOnExplorerTest : MembershipTestsSetup() {
)
}
- val expectedConditionInfo = TierConditionInfo.Visible.Pending
+ val expectedConditionInfo = TierConditionInfo.Visible.PriceBilling(
+ BillingPriceInfo(
+ formattedPrice = formattedPrice,
+ period = PeriodDescription(
+ amount = 1,
+ unit = PeriodUnit.YEARS
+ )
+ )
+ )
val thirdTierItem = tierStateFlow.awaitItem()
thirdTierItem.let {