Add decline code messages into expiration sheet.

This commit is contained in:
Alex Hart
2022-05-20 13:05:33 -03:00
parent 4d8faffb75
commit 6dec6cef27
14 changed files with 405 additions and 30 deletions

View File

@@ -6,6 +6,7 @@ import android.content.Context
import android.content.DialogInterface
import android.widget.Toast
import androidx.lifecycle.ViewModelProvider
import androidx.navigation.fragment.findNavController
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import org.signal.core.util.AppUtil
import org.signal.core.util.concurrent.SignalExecutors
@@ -38,6 +39,7 @@ import org.thoughtcrime.securesms.payments.DataExportUtil
import org.thoughtcrime.securesms.storage.StorageSyncHelper
import org.thoughtcrime.securesms.util.ConversationUtil
import org.thoughtcrime.securesms.util.FeatureFlags
import org.thoughtcrime.securesms.util.navigation.safeNavigate
import java.util.Optional
import java.util.concurrent.TimeUnit
import kotlin.math.max
@@ -409,6 +411,13 @@ class InternalSettingsFragment : DSLSettingsFragment(R.string.preferences__inter
enqueueSubscriptionKeepAlive()
}
)
clickPref(
title = DSLSettingsText.from(R.string.preferences__internal_badges_set_error_state),
onClick = {
findNavController().safeNavigate(InternalSettingsFragmentDirections.actionInternalSettingsFragmentToDonorErrorConfigurationFragment())
}
)
}
dividerPref()

View File

@@ -0,0 +1,66 @@
package org.thoughtcrime.securesms.components.settings.app.internal.donor
import androidx.fragment.app.viewModels
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
import org.signal.donations.StripeDeclineCode
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.components.settings.DSLConfiguration
import org.thoughtcrime.securesms.components.settings.DSLSettingsAdapter
import org.thoughtcrime.securesms.components.settings.DSLSettingsFragment
import org.thoughtcrime.securesms.components.settings.DSLSettingsText
import org.thoughtcrime.securesms.components.settings.app.subscription.errors.UnexpectedSubscriptionCancellation
import org.thoughtcrime.securesms.components.settings.configure
import org.thoughtcrime.securesms.util.LifecycleDisposable
class DonorErrorConfigurationFragment : DSLSettingsFragment() {
private val viewModel: DonorErrorConfigurationViewModel by viewModels()
private val lifecycleDisposable = LifecycleDisposable()
override fun bindAdapter(adapter: DSLSettingsAdapter) {
lifecycleDisposable += viewModel.state.observeOn(AndroidSchedulers.mainThread()).subscribe { state ->
adapter.submitList(getConfiguration(state).toMappingModelList())
}
}
private fun getConfiguration(state: DonorErrorConfigurationState): DSLConfiguration {
return configure {
radioListPref(
title = DSLSettingsText.from(R.string.preferences__internal_donor_error_expired_badge),
selected = state.badges.indexOf(state.selectedBadge),
listItems = state.badges.map { it.name }.toTypedArray(),
onSelected = { viewModel.setSelectedBadge(it) }
)
radioListPref(
title = DSLSettingsText.from(R.string.preferences__internal_donor_error_cancelation_reason),
selected = UnexpectedSubscriptionCancellation.values().indexOf(state.selectedUnexpectedSubscriptionCancellation),
listItems = UnexpectedSubscriptionCancellation.values().map { it.status }.toTypedArray(),
onSelected = { viewModel.setSelectedUnexpectedSubscriptionCancellation(it) },
isEnabled = state.selectedBadge == null || state.selectedBadge.isSubscription()
)
radioListPref(
title = DSLSettingsText.from(R.string.preferences__internal_donor_error_charge_failure),
selected = StripeDeclineCode.Code.values().indexOf(state.selectedStripeDeclineCode),
listItems = StripeDeclineCode.Code.values().map { it.code }.toTypedArray(),
onSelected = { viewModel.setStripeDeclineCode(it) },
isEnabled = state.selectedBadge == null || state.selectedBadge.isSubscription()
)
primaryButton(
text = DSLSettingsText.from(R.string.preferences__internal_donor_error_save_and_finish),
onClick = {
lifecycleDisposable += viewModel.save().subscribe { requireActivity().finish() }
}
)
secondaryButtonNoOutline(
text = DSLSettingsText.from(R.string.preferences__internal_donor_error_clear),
onClick = {
lifecycleDisposable += viewModel.clear().subscribe()
}
)
}
}
}

View File

@@ -0,0 +1,12 @@
package org.thoughtcrime.securesms.components.settings.app.internal.donor
import org.signal.donations.StripeDeclineCode
import org.thoughtcrime.securesms.badges.models.Badge
import org.thoughtcrime.securesms.components.settings.app.subscription.errors.UnexpectedSubscriptionCancellation
data class DonorErrorConfigurationState(
val badges: List<Badge> = emptyList(),
val selectedBadge: Badge? = null,
val selectedUnexpectedSubscriptionCancellation: UnexpectedSubscriptionCancellation? = null,
val selectedStripeDeclineCode: StripeDeclineCode.Code? = null
)

View File

@@ -0,0 +1,152 @@
package org.thoughtcrime.securesms.components.settings.app.internal.donor
import androidx.lifecycle.ViewModel
import io.reactivex.rxjava3.core.Completable
import io.reactivex.rxjava3.core.Flowable
import io.reactivex.rxjava3.core.Single
import io.reactivex.rxjava3.disposables.CompositeDisposable
import io.reactivex.rxjava3.kotlin.plusAssign
import io.reactivex.rxjava3.schedulers.Schedulers
import org.signal.donations.StripeDeclineCode
import org.thoughtcrime.securesms.badges.Badges
import org.thoughtcrime.securesms.badges.models.Badge
import org.thoughtcrime.securesms.components.settings.app.subscription.errors.UnexpectedSubscriptionCancellation
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
import org.thoughtcrime.securesms.jobs.SubscriptionReceiptRequestResponseJob
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.util.rx.RxStore
import org.whispersystems.signalservice.api.subscriptions.ActiveSubscription
import java.util.Locale
class DonorErrorConfigurationViewModel : ViewModel() {
private val store = RxStore(DonorErrorConfigurationState())
private val disposables = CompositeDisposable()
val state: Flowable<DonorErrorConfigurationState> = store.stateFlowable
init {
val giftBadges: Single<List<Badge>> = ApplicationDependencies.getDonationsService()
.getGiftBadges(Locale.getDefault())
.flatMap { it.flattenResult() }
.map { results -> results.values.map { Badges.fromServiceBadge(it) } }
.subscribeOn(Schedulers.io())
val boostBadges: Single<List<Badge>> = ApplicationDependencies.getDonationsService()
.getBoostBadge(Locale.getDefault())
.flatMap { it.flattenResult() }
.map { listOf(Badges.fromServiceBadge(it)) }
.subscribeOn(Schedulers.io())
val subscriptionBadges: Single<List<Badge>> = ApplicationDependencies.getDonationsService()
.getSubscriptionLevels(Locale.getDefault())
.flatMap { it.flattenResult() }
.map { levels -> levels.levels.values.map { Badges.fromServiceBadge(it.badge) } }
.subscribeOn(Schedulers.io())
disposables += Single.zip(giftBadges, boostBadges, subscriptionBadges) { g, b, s ->
g + b + s
}.subscribe { badges ->
store.update { it.copy(badges = badges) }
}
}
override fun onCleared() {
disposables.clear()
}
fun setSelectedBadge(badgeIndex: Int) {
store.update {
it.copy(selectedBadge = if (badgeIndex in it.badges.indices) it.badges[badgeIndex] else null)
}
}
fun setSelectedUnexpectedSubscriptionCancellation(unexpectedSubscriptionCancellationIndex: Int) {
store.update {
it.copy(
selectedUnexpectedSubscriptionCancellation = if (unexpectedSubscriptionCancellationIndex in UnexpectedSubscriptionCancellation.values().indices) {
UnexpectedSubscriptionCancellation.values()[unexpectedSubscriptionCancellationIndex]
} else {
null
}
)
}
}
fun setStripeDeclineCode(stripeDeclineCodeIndex: Int) {
store.update {
it.copy(
selectedStripeDeclineCode = if (stripeDeclineCodeIndex in StripeDeclineCode.Code.values().indices) {
StripeDeclineCode.Code.values()[stripeDeclineCodeIndex]
} else {
null
}
)
}
}
fun save(): Completable {
val snapshot = store.state
val saveState = Completable.fromAction {
synchronized(SubscriptionReceiptRequestResponseJob.MUTEX) {
when {
snapshot.selectedBadge?.isGift() == true -> handleGiftExpiration(snapshot)
snapshot.selectedBadge?.isBoost() == true -> handleBoostExpiration(snapshot)
snapshot.selectedBadge?.isSubscription() == true -> handleSubscriptionExpiration(snapshot)
else -> handleSubscriptionPaymentFailure(snapshot)
}
}
}.subscribeOn(Schedulers.io())
return clear().andThen(saveState)
}
fun clear(): Completable {
return Completable.fromAction {
synchronized(SubscriptionReceiptRequestResponseJob.MUTEX) {
SignalStore.donationsValues().setExpiredBadge(null)
SignalStore.donationsValues().setExpiredGiftBadge(null)
SignalStore.donationsValues().unexpectedSubscriptionCancelationReason = null
SignalStore.donationsValues().unexpectedSubscriptionCancelationTimestamp = 0L
SignalStore.donationsValues().setUnexpectedSubscriptionCancelationChargeFailure(null)
}
store.update {
it.copy(
selectedStripeDeclineCode = null,
selectedUnexpectedSubscriptionCancellation = null,
selectedBadge = null
)
}
}
}
private fun handleBoostExpiration(state: DonorErrorConfigurationState) {
SignalStore.donationsValues().setExpiredBadge(state.selectedBadge)
}
private fun handleGiftExpiration(state: DonorErrorConfigurationState) {
SignalStore.donationsValues().setExpiredGiftBadge(state.selectedBadge)
}
private fun handleSubscriptionExpiration(state: DonorErrorConfigurationState) {
SignalStore.donationsValues().setExpiredBadge(state.selectedBadge)
handleSubscriptionPaymentFailure(state)
}
private fun handleSubscriptionPaymentFailure(state: DonorErrorConfigurationState) {
SignalStore.donationsValues().unexpectedSubscriptionCancelationReason = state.selectedUnexpectedSubscriptionCancellation?.status
SignalStore.donationsValues().unexpectedSubscriptionCancelationTimestamp = System.currentTimeMillis()
SignalStore.donationsValues().setUnexpectedSubscriptionCancelationChargeFailure(
state.selectedStripeDeclineCode?.let {
ActiveSubscription.ChargeFailure(
it.code,
"Test Charge Failure",
"Test Network Status",
"Test Network Reason",
"Test"
)
}
)
}
}

View File

@@ -0,0 +1,52 @@
package org.thoughtcrime.securesms.components.settings.app.subscription.errors
import androidx.annotation.StringRes
import org.signal.donations.StripeDeclineCode
import org.thoughtcrime.securesms.R
@StringRes
fun StripeDeclineCode.mapToErrorStringResource(): Int {
return when (this) {
is StripeDeclineCode.Known -> when (this.code) {
StripeDeclineCode.Code.APPROVE_WITH_ID -> R.string.DeclineCode__verify_your_payment_method_is_up_to_date_in_google_pay_and_try_again
StripeDeclineCode.Code.CALL_ISSUER -> R.string.DeclineCode__verify_your_payment_method_is_up_to_date_in_google_pay_and_try_again_if_the_problem
StripeDeclineCode.Code.CARD_NOT_SUPPORTED -> R.string.DeclineCode__your_card_does_not_support_this_type_of_purchase
StripeDeclineCode.Code.EXPIRED_CARD -> R.string.DeclineCode__your_card_has_expired
StripeDeclineCode.Code.INCORRECT_NUMBER -> R.string.DeclineCode__your_card_number_is_incorrect
StripeDeclineCode.Code.INCORRECT_CVC -> R.string.DeclineCode__your_cards_cvc_number_is_incorrect
StripeDeclineCode.Code.INSUFFICIENT_FUNDS -> R.string.DeclineCode__your_card_does_not_have_sufficient_funds
StripeDeclineCode.Code.INVALID_CVC -> R.string.DeclineCode__your_cards_cvc_number_is_incorrect
StripeDeclineCode.Code.INVALID_EXPIRY_MONTH -> R.string.DeclineCode__the_expiration_month
StripeDeclineCode.Code.INVALID_EXPIRY_YEAR -> R.string.DeclineCode__the_expiration_year
StripeDeclineCode.Code.INVALID_NUMBER -> R.string.DeclineCode__your_card_number_is_incorrect
StripeDeclineCode.Code.ISSUER_NOT_AVAILABLE -> R.string.DeclineCode__try_completing_the_payment_again
StripeDeclineCode.Code.PROCESSING_ERROR -> R.string.DeclineCode__try_again
StripeDeclineCode.Code.REENTER_TRANSACTION -> R.string.DeclineCode__try_again
else -> R.string.DeclineCode__try_another_payment_method_or_contact_your_bank
}
else -> R.string.DeclineCode__try_another_payment_method_or_contact_your_bank
}
}
fun StripeDeclineCode.shouldRouteToGooglePay(): Boolean {
return when (this) {
is StripeDeclineCode.Known -> when (this.code) {
StripeDeclineCode.Code.APPROVE_WITH_ID -> true
StripeDeclineCode.Code.CALL_ISSUER -> true
StripeDeclineCode.Code.CARD_NOT_SUPPORTED -> false
StripeDeclineCode.Code.EXPIRED_CARD -> true
StripeDeclineCode.Code.INCORRECT_NUMBER -> true
StripeDeclineCode.Code.INCORRECT_CVC -> true
StripeDeclineCode.Code.INSUFFICIENT_FUNDS -> false
StripeDeclineCode.Code.INVALID_CVC -> true
StripeDeclineCode.Code.INVALID_EXPIRY_MONTH -> true
StripeDeclineCode.Code.INVALID_EXPIRY_YEAR -> true
StripeDeclineCode.Code.INVALID_NUMBER -> true
StripeDeclineCode.Code.ISSUER_NOT_AVAILABLE -> false
StripeDeclineCode.Code.PROCESSING_ERROR -> false
StripeDeclineCode.Code.REENTER_TRANSACTION -> false
else -> false
}
else -> false
}
}

View File

@@ -3,6 +3,9 @@ package org.thoughtcrime.securesms.components.settings.app.subscription.errors
/**
* Error states that can occur if we detect that a user's subscription has been cancelled and the manual
* cancellation flag is not set.
*
* This status is taken directly from the ActiveSubscription object, and is set in the Subscription's
* keep-alive and subscription receipt redemption jobs.
*/
enum class UnexpectedSubscriptionCancellation(val status: String) {
PAST_DUE("past_due"),

View File

@@ -68,7 +68,7 @@ class ManageDonationsFragment : DSLSettingsFragment(), ExpiredGiftSheet.Callback
val expiredGiftBadge = SignalStore.donationsValues().getExpiredGiftBadge()
if (expiredGiftBadge != null) {
SignalStore.donationsValues().setExpiredBadge(null)
SignalStore.donationsValues().setExpiredGiftBadge(null)
ExpiredGiftSheet.show(childFragmentManager, expiredGiftBadge)
}