From 6dec6cef27966b90150b58c6befe96cd23b3b2f1 Mon Sep 17 00:00:00 2001 From: Alex Hart Date: Fri, 20 May 2022 13:05:33 -0300 Subject: [PATCH] Add decline code messages into expiration sheet. --- .../ExpiredBadgeBottomSheetDialogFragment.kt | 68 +++++--- .../self/overview/BadgesOverviewFragment.kt | 2 +- .../app/internal/InternalSettingsFragment.kt | 9 ++ .../donor/DonorErrorConfigurationFragment.kt | 66 ++++++++ .../donor/DonorErrorConfigurationState.kt | 12 ++ .../donor/DonorErrorConfigurationViewModel.kt | 152 ++++++++++++++++++ .../app/subscription/errors/DonationErrors.kt | 52 ++++++ .../UnexpectedSubscriptionCancellation.kt | 3 + .../manage/ManageDonationsFragment.kt | 2 +- .../ConversationListFragment.java | 6 +- ...SubscriptionReceiptRequestResponseJob.java | 37 ++++- app/src/main/res/navigation/app_settings.xml | 11 +- app/src/main/res/navigation/manage_badges.xml | 5 + app/src/main/res/values/strings.xml | 10 ++ 14 files changed, 405 insertions(+), 30 deletions(-) create mode 100644 app/src/main/java/org/thoughtcrime/securesms/components/settings/app/internal/donor/DonorErrorConfigurationFragment.kt create mode 100644 app/src/main/java/org/thoughtcrime/securesms/components/settings/app/internal/donor/DonorErrorConfigurationState.kt create mode 100644 app/src/main/java/org/thoughtcrime/securesms/components/settings/app/internal/donor/DonorErrorConfigurationViewModel.kt create mode 100644 app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/errors/DonationErrors.kt diff --git a/app/src/main/java/org/thoughtcrime/securesms/badges/self/expired/ExpiredBadgeBottomSheetDialogFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/badges/self/expired/ExpiredBadgeBottomSheetDialogFragment.kt index 2ff5eeffc6..b29ba136a5 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/badges/self/expired/ExpiredBadgeBottomSheetDialogFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/badges/self/expired/ExpiredBadgeBottomSheetDialogFragment.kt @@ -2,6 +2,8 @@ package org.thoughtcrime.securesms.badges.self.expired import androidx.fragment.app.FragmentManager import org.signal.core.util.DimensionUnit +import org.signal.core.util.logging.Log +import org.signal.donations.StripeDeclineCode import org.thoughtcrime.securesms.R import org.thoughtcrime.securesms.badges.models.Badge import org.thoughtcrime.securesms.badges.models.ExpiredBadge @@ -11,9 +13,12 @@ import org.thoughtcrime.securesms.components.settings.DSLSettingsBottomSheetFrag import org.thoughtcrime.securesms.components.settings.DSLSettingsText import org.thoughtcrime.securesms.components.settings.app.AppSettingsActivity import org.thoughtcrime.securesms.components.settings.app.subscription.errors.UnexpectedSubscriptionCancellation +import org.thoughtcrime.securesms.components.settings.app.subscription.errors.mapToErrorStringResource +import org.thoughtcrime.securesms.components.settings.app.subscription.errors.shouldRouteToGooglePay import org.thoughtcrime.securesms.components.settings.configure import org.thoughtcrime.securesms.keyvalue.SignalStore import org.thoughtcrime.securesms.util.BottomSheetUtil +import org.thoughtcrime.securesms.util.CommunicationActions import org.whispersystems.signalservice.api.subscriptions.ActiveSubscription /** @@ -32,11 +37,12 @@ class ExpiredBadgeBottomSheetDialogFragment : DSLSettingsBottomSheetFragment( val args = ExpiredBadgeBottomSheetDialogFragmentArgs.fromBundle(requireArguments()) val badge: Badge = args.badge val cancellationReason = UnexpectedSubscriptionCancellation.fromStatus(args.cancelationReason) - val chargeFailure: ActiveSubscription.ChargeFailure? = SignalStore.donationsValues().getUnexpectedSubscriptionCancelationChargeFailure() + val declineCode: StripeDeclineCode? = args.chargeFailure?.let { StripeDeclineCode.getFromCode(it) } val isLikelyASustainer = SignalStore.donationsValues().isLikelyASustainer() - val inactive = cancellationReason == UnexpectedSubscriptionCancellation.INACTIVE + Log.d(TAG, "Displaying Expired Badge Fragment with bundle: ${requireArguments()}", true) + return configure { customPref(ExpiredBadge.Model(badge)) @@ -57,6 +63,12 @@ class ExpiredBadgeBottomSheetDialogFragment : DSLSettingsBottomSheetFragment( DSLSettingsText.from( if (badge.isBoost()) { getString(R.string.ExpiredBadgeBottomSheetDialogFragment__your_boost_badge_has_expired_and) + } else if (declineCode != null) { + getString( + R.string.ExpiredBadgeBottomSheetDialogFragment__your_recurring_monthly_donation_was_canceled_s, + getString(declineCode.mapToErrorStringResource()), + badge.name + ) } else if (inactive) { getString(R.string.ExpiredBadgeBottomSheetDialogFragment__your_recurring_monthly_donation_was_automatically, badge.name) } else { @@ -68,22 +80,33 @@ class ExpiredBadgeBottomSheetDialogFragment : DSLSettingsBottomSheetFragment( space(DimensionUnit.DP.toPixels(16f).toInt()) - noPadTextPref( - DSLSettingsText.from( - if (badge.isBoost()) { - if (isLikelyASustainer) { - R.string.ExpiredBadgeBottomSheetDialogFragment__you_can_reactivate - } else { - R.string.ExpiredBadgeBottomSheetDialogFragment__you_can_keep - } - } else { - R.string.ExpiredBadgeBottomSheetDialogFragment__you_can - }, - DSLSettingsText.CenterModifier - ) - ) + if (badge.isSubscription() && declineCode?.shouldRouteToGooglePay() == true) { + space(DimensionUnit.DP.toPixels(68f).toInt()) - space(DimensionUnit.DP.toPixels(92f).toInt()) + secondaryButtonNoOutline( + text = DSLSettingsText.from(R.string.ExpiredBadgeBottomSheetDialogFragment__go_to_google_pay), + onClick = { + CommunicationActions.openBrowserLink(requireContext(), getString(R.string.google_pay_url)) + } + ) + } else { + noPadTextPref( + DSLSettingsText.from( + if (badge.isBoost()) { + if (isLikelyASustainer) { + R.string.ExpiredBadgeBottomSheetDialogFragment__you_can_reactivate + } else { + R.string.ExpiredBadgeBottomSheetDialogFragment__you_can_keep + } + } else { + R.string.ExpiredBadgeBottomSheetDialogFragment__you_can + }, + DSLSettingsText.CenterModifier + ) + ) + + space(DimensionUnit.DP.toPixels(92f).toInt()) + } primaryButton( text = DSLSettingsText.from( @@ -117,9 +140,16 @@ class ExpiredBadgeBottomSheetDialogFragment : DSLSettingsBottomSheetFragment( } companion object { + private val TAG = Log.tag(ExpiredBadgeBottomSheetDialogFragment::class.java) + @JvmStatic - fun show(badge: Badge, cancellationReason: UnexpectedSubscriptionCancellation?, fragmentManager: FragmentManager) { - val args = ExpiredBadgeBottomSheetDialogFragmentArgs.Builder(badge, cancellationReason?.status).build() + fun show( + badge: Badge, + cancellationReason: UnexpectedSubscriptionCancellation?, + chargeFailure: ActiveSubscription.ChargeFailure?, + fragmentManager: FragmentManager + ) { + val args = ExpiredBadgeBottomSheetDialogFragmentArgs.Builder(badge, cancellationReason?.status, chargeFailure?.code).build() val fragment = ExpiredBadgeBottomSheetDialogFragment() fragment.arguments = args.toBundle() diff --git a/app/src/main/java/org/thoughtcrime/securesms/badges/self/overview/BadgesOverviewFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/badges/self/overview/BadgesOverviewFragment.kt index 9fd26fca55..81592a672d 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/badges/self/overview/BadgesOverviewFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/badges/self/overview/BadgesOverviewFragment.kt @@ -38,7 +38,7 @@ class BadgesOverviewFragment : DSLSettingsFragment( override fun bindAdapter(adapter: DSLSettingsAdapter) { Badge.register(adapter) { badge, _, isFaded -> if (badge.isExpired() || isFaded) { - findNavController().safeNavigate(BadgesOverviewFragmentDirections.actionBadgeManageFragmentToExpiredBadgeDialog(badge, null)) + findNavController().safeNavigate(BadgesOverviewFragmentDirections.actionBadgeManageFragmentToExpiredBadgeDialog(badge, null, null)) } else { ViewBadgeBottomSheetDialogFragment.show(parentFragmentManager, Recipient.self().id, badge) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/internal/InternalSettingsFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/internal/InternalSettingsFragment.kt index e1797baa96..e02478e08e 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/internal/InternalSettingsFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/internal/InternalSettingsFragment.kt @@ -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() diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/internal/donor/DonorErrorConfigurationFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/internal/donor/DonorErrorConfigurationFragment.kt new file mode 100644 index 0000000000..905cecc3f1 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/internal/donor/DonorErrorConfigurationFragment.kt @@ -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() + } + ) + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/internal/donor/DonorErrorConfigurationState.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/internal/donor/DonorErrorConfigurationState.kt new file mode 100644 index 0000000000..82b72bc9a6 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/internal/donor/DonorErrorConfigurationState.kt @@ -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 = emptyList(), + val selectedBadge: Badge? = null, + val selectedUnexpectedSubscriptionCancellation: UnexpectedSubscriptionCancellation? = null, + val selectedStripeDeclineCode: StripeDeclineCode.Code? = null +) diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/internal/donor/DonorErrorConfigurationViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/internal/donor/DonorErrorConfigurationViewModel.kt new file mode 100644 index 0000000000..9e40f62036 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/internal/donor/DonorErrorConfigurationViewModel.kt @@ -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 = store.stateFlowable + + init { + val giftBadges: Single> = ApplicationDependencies.getDonationsService() + .getGiftBadges(Locale.getDefault()) + .flatMap { it.flattenResult() } + .map { results -> results.values.map { Badges.fromServiceBadge(it) } } + .subscribeOn(Schedulers.io()) + + val boostBadges: Single> = ApplicationDependencies.getDonationsService() + .getBoostBadge(Locale.getDefault()) + .flatMap { it.flattenResult() } + .map { listOf(Badges.fromServiceBadge(it)) } + .subscribeOn(Schedulers.io()) + + val subscriptionBadges: Single> = 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" + ) + } + ) + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/errors/DonationErrors.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/errors/DonationErrors.kt new file mode 100644 index 0000000000..a57526d773 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/errors/DonationErrors.kt @@ -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 + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/errors/UnexpectedSubscriptionCancellation.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/errors/UnexpectedSubscriptionCancellation.kt index 46954154db..9727d546f2 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/errors/UnexpectedSubscriptionCancellation.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/errors/UnexpectedSubscriptionCancellation.kt @@ -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"), diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/manage/ManageDonationsFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/manage/ManageDonationsFragment.kt index 6689e57ad1..93386a4387 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/manage/ManageDonationsFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/manage/ManageDonationsFragment.kt @@ -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) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversationlist/ConversationListFragment.java b/app/src/main/java/org/thoughtcrime/securesms/conversationlist/ConversationListFragment.java index 18516d86ca..c12e87576f 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversationlist/ConversationListFragment.java +++ b/app/src/main/java/org/thoughtcrime/securesms/conversationlist/ConversationListFragment.java @@ -396,7 +396,11 @@ public class ConversationListFragment extends MainFragment implements ActionMode if (expiredBadge.isBoost() || !SignalStore.donationsValues().isUserManuallyCancelled()) { Log.w(TAG, "Displaying bottom sheet for an expired badge", true); - ExpiredBadgeBottomSheetDialogFragment.show(expiredBadge, unexpectedSubscriptionCancellation, getParentFragmentManager()); + ExpiredBadgeBottomSheetDialogFragment.show( + expiredBadge, + unexpectedSubscriptionCancellation, + SignalStore.donationsValues().getUnexpectedSubscriptionCancelationChargeFailure(), + getParentFragmentManager()); } } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/SubscriptionReceiptRequestResponseJob.java b/app/src/main/java/org/thoughtcrime/securesms/jobs/SubscriptionReceiptRequestResponseJob.java index 9a9048c17c..40833deacd 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/jobs/SubscriptionReceiptRequestResponseJob.java +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/SubscriptionReceiptRequestResponseJob.java @@ -5,6 +5,7 @@ import androidx.annotation.Nullable; import androidx.annotation.VisibleForTesting; import org.signal.core.util.logging.Log; +import org.signal.donations.StripeDeclineCode; import org.signal.libsignal.zkgroup.InvalidInputException; import org.signal.libsignal.zkgroup.VerificationFailedException; import org.signal.libsignal.zkgroup.receipts.ClientZkReceiptOperations; @@ -152,9 +153,15 @@ public class SubscriptionReceiptRequestResponseJob extends BaseJob { Log.w(TAG, "Subscription payment charge failure code: " + chargeFailure.getCode() + ", message: " + chargeFailure.getMessage(), true); } - Log.w(TAG, "Subscription payment failure in active subscription response (status = " + subscription.getStatus() + ").", true); - onPaymentFailure(subscription.getStatus(), chargeFailure, subscription.getEndOfCurrentPeriod()); - throw new Exception("Subscription has a payment failure: " + subscription.getStatus()); + if (isForKeepAlive) { + Log.w(TAG, "Subscription payment failure in active subscription response (status = " + subscription.getStatus() + ").", true); + onPaymentFailure(subscription.getStatus(), chargeFailure, subscription.getEndOfCurrentPeriod(), true); + throw new Exception("Active subscription hit a payment failure: " + subscription.getStatus()); + } else { + Log.w(TAG, "New subscription has hit a payment failure. (status = " + subscription.getStatus() + ").", true); + onPaymentFailure(subscription.getStatus(), chargeFailure, subscription.getEndOfCurrentPeriod(), false); + throw new Exception("New subscription has hit a payment failure: " + subscription.getStatus()); + } } else if (!subscription.isActive()) { ActiveSubscription.ChargeFailure chargeFailure = activeSubscription.getChargeFailure(); if (chargeFailure != null) { @@ -269,15 +276,31 @@ public class SubscriptionReceiptRequestResponseJob extends BaseJob { } } - private void onPaymentFailure(@Nullable String status, @Nullable ActiveSubscription.ChargeFailure chargeFailure, long timestamp) { + /** + * Handles state updates and error routing for a payment failure. + * + * There are two ways this could go, depending on whether the job was created for a keep-alive chain. + * + * 1. In the case of a normal chain (new subscription) We simply route the error out to the user. The payment failure would have occurred while trying to + * charge for the first month of their subscription, and are likely still on the "Subscribe" screen, so we can just display a dialog. + * 1. In the case of a keep-alive event, we want to book-keep the error to show the user on a subsequent launch, and we want to sync our failure state to + * linked devices. + */ + private void onPaymentFailure(@NonNull String status, @Nullable ActiveSubscription.ChargeFailure chargeFailure, long timestamp, boolean isForKeepAlive) { SignalStore.donationsValues().setShouldCancelSubscriptionBeforeNextSubscribeAttempt(true); - if (status == null) { - DonationError.routeDonationError(context, DonationError.genericPaymentFailure(getErrorSource())); - } else { + if (isForKeepAlive){ + Log.d(TAG, "Is for a keep-alive and we have a status. Setting UnexpectedSubscriptionCancelation state...", true); SignalStore.donationsValues().setUnexpectedSubscriptionCancelationChargeFailure(chargeFailure); SignalStore.donationsValues().setUnexpectedSubscriptionCancelationReason(status); SignalStore.donationsValues().setUnexpectedSubscriptionCancelationTimestamp(timestamp); MultiDeviceSubscriptionSyncRequestJob.enqueue(); + } else { + Log.d(TAG, "Not for a keep-alive and we have a status. Routing a payment setup error...", true); + DonationError.routeDonationError(context, new DonationError.PaymentSetupError.DeclinedError( + getErrorSource(), + new Exception("Got a failure status from the subscription object."), + StripeDeclineCode.Companion.getFromCode(status) + )); } } diff --git a/app/src/main/res/navigation/app_settings.xml b/app/src/main/res/navigation/app_settings.xml index 6d3eb39bc6..b265a5388c 100644 --- a/app/src/main/res/navigation/app_settings.xml +++ b/app/src/main/res/navigation/app_settings.xml @@ -542,7 +542,16 @@ + android:label="internal_settings_fragment" > + + + + diff --git a/app/src/main/res/navigation/manage_badges.xml b/app/src/main/res/navigation/manage_badges.xml index ef180c6b7a..5bdf43ece1 100644 --- a/app/src/main/res/navigation/manage_badges.xml +++ b/app/src/main/res/navigation/manage_badges.xml @@ -41,5 +41,10 @@ app:argType="string" app:nullable="true" /> + + \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 972e95b46c..4d33207623 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -2739,6 +2739,7 @@ Badges Enqueue redemption. Enqueue keep-alive. + Set error state. Release channel Fetch release channel Set last version seen back 10 versions @@ -2751,6 +2752,11 @@ Clears all known service IDs (except your own). Do not use on your personal device! Clear all profile keys Clears all known profile keys (except your own). Do not use on your personal device! + Expired Badge + Charge Failure + Cancelation Reason + Save and Finish + Clear @@ -4226,8 +4232,12 @@ Your recurring monthly donation was automatically cancelled because you were inactive for too long. Your %1$s badge is no longer visible on your profile. Your recurring monthly donation was cancelled because we couldn\'t process your payment. Your badge is no longer visible on your profile. + + Your recurring monthly donation was cancelled. %1$s Your %2$s badge is no longer visible on your profile. You can keep using Signal but to support the app and reactivate your badge, renew now. Renew subscription + + Go to Google Pay Can\'t process subscription payment We\'re having trouble collecting your Signal Sustainer payment. Make sure your payment method is up to date. If it isn\'t, update it in Google Pay. Signal will try to process the payment again in a few days.