From 10eec025d27ba12ac9be90ef275dd75deba7f46b Mon Sep 17 00:00:00 2001 From: Alex Hart Date: Mon, 23 Oct 2023 12:50:54 -0400 Subject: [PATCH] Implement pending one-time donation error handling. --- ...ingOneTimeDonationConfigurationFragment.kt | 320 ++++++++++++++++++ ...ngOneTimeDonationConfigurationViewModel.kt | 52 +++ .../app/internal/InternalSettingsFragment.kt | 16 + .../app/internal/InternalSettingsState.kt | 3 +- .../app/internal/InternalSettingsViewModel.kt | 12 +- .../DonationSerializationHelper.kt | 2 +- .../app/subscription/StripeRepository.kt | 1 + .../donate/DonateToSignalViewModel.kt | 3 +- .../app/subscription/errors/DonationErrors.kt | 2 +- .../manage/ManageDonationsFragment.kt | 14 + .../manage/OneTimeDonationPreference.kt | 28 +- .../jobs/BoostReceiptRequestResponseJob.java | 63 ++++ .../jobs/DonationReceiptRedemptionJob.java | 19 +- .../securesms/keyvalue/DonationsValues.kt | 29 +- .../securesms/util/FeatureFlags.java | 2 +- app/src/main/protowire/Database.proto | 22 +- app/src/main/res/navigation/app_settings.xml | 8 + .../internal/push/PushServiceSocket.java | 16 +- .../DonationReceiptCredentialError.kt | 25 ++ 19 files changed, 615 insertions(+), 22 deletions(-) create mode 100644 app/src/main/java/org/thoughtcrime/securesms/components/settings/app/internal/InternalPendingOneTimeDonationConfigurationFragment.kt create mode 100644 app/src/main/java/org/thoughtcrime/securesms/components/settings/app/internal/InternalPendingOneTimeDonationConfigurationViewModel.kt create mode 100644 libsignal-service/src/main/java/org/whispersystems/signalservice/internal/push/exceptions/DonationReceiptCredentialError.kt diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/internal/InternalPendingOneTimeDonationConfigurationFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/internal/InternalPendingOneTimeDonationConfigurationFragment.kt new file mode 100644 index 0000000000..7d0f69976d --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/internal/InternalPendingOneTimeDonationConfigurationFragment.kt @@ -0,0 +1,320 @@ +/* + * Copyright 2023 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.components.settings.app.internal + +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ExposedDropdownMenuBox +import androidx.compose.material3.ExposedDropdownMenuDefaults +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextField +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment.Companion.CenterHorizontally +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.fragment.app.viewModels +import androidx.navigation.fragment.findNavController +import org.signal.core.ui.Buttons +import org.signal.core.ui.Rows +import org.signal.core.ui.Scaffolds +import org.signal.core.ui.theme.SignalTheme +import org.signal.donations.StripeDeclineCode +import org.signal.donations.StripeFailureCode +import org.thoughtcrime.securesms.R +import org.thoughtcrime.securesms.components.settings.app.subscription.errors.PayPalDeclineCode +import org.thoughtcrime.securesms.compose.ComposeFragment +import org.thoughtcrime.securesms.database.model.databaseprotos.PendingOneTimeDonation +import org.thoughtcrime.securesms.keyvalue.SignalStore + +/** + * Allows configuration of a PendingOneTimeDonation object to display different + * states in the donation settings screen. + */ +class InternalPendingOneTimeDonationConfigurationFragment : ComposeFragment() { + + private val viewModel: InternalPendingOneTimeDonationConfigurationViewModel by viewModels() + + @Composable + override fun FragmentContent() { + val state by viewModel.state + Content( + state, + onNavigationClick = { + findNavController().popBackStack() + }, + onAddError = { + viewModel.state.value = viewModel.state.value.copy(error = PendingOneTimeDonation.Error()) + }, + onClearError = { + viewModel.state.value = viewModel.state.value.copy(error = null) + }, + onPaymentMethodTypeSelected = { + viewModel.state.value = viewModel.state.value.copy(paymentMethodType = it, error = null) + }, + onErrorTypeSelected = { + viewModel.state.value = viewModel.state.value.copy(error = viewModel.state.value.error!!.copy(type = it)) + }, + onErrorCodeChanged = { + viewModel.state.value = viewModel.state.value.copy(error = viewModel.state.value.error!!.copy(code = it)) + }, + onSave = { + SignalStore.donationsValues().setPendingOneTimeDonation(viewModel.state.value) + findNavController().popBackStack() + } + ) + } +} + +@Preview +@Composable +private fun ContentPreview() { + SignalTheme { + Surface { + Content( + state = PendingOneTimeDonation.Builder().error(PendingOneTimeDonation.Error()).build(), + onNavigationClick = {}, + onClearError = {}, + onAddError = {}, + onPaymentMethodTypeSelected = {}, + onErrorTypeSelected = {}, + onErrorCodeChanged = {}, + onSave = {} + ) + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun Content( + state: PendingOneTimeDonation, + onNavigationClick: () -> Unit, + onAddError: () -> Unit, + onClearError: () -> Unit, + onPaymentMethodTypeSelected: (PendingOneTimeDonation.PaymentMethodType) -> Unit, + onErrorTypeSelected: (PendingOneTimeDonation.Error.Type) -> Unit, + onErrorCodeChanged: (String) -> Unit, + onSave: () -> Unit +) { + Scaffolds.Settings( + title = "One-time donation state", + navigationIconPainter = painterResource(id = R.drawable.symbol_arrow_left_24), + navigationContentDescription = null, + onNavigationClick = onNavigationClick + ) { + val isCodedError = remember(state.error?.type) { + state.error?.type in setOf(PendingOneTimeDonation.Error.Type.PROCESSOR_CODE, PendingOneTimeDonation.Error.Type.DECLINE_CODE, PendingOneTimeDonation.Error.Type.FAILURE_CODE) + } + + LazyColumn( + horizontalAlignment = CenterHorizontally, + modifier = Modifier.padding(it) + ) { + item { + var expanded by remember { + mutableStateOf(false) + } + + ExposedDropdownMenuBox( + expanded = expanded, + onExpandedChange = { + expanded = !expanded + } + ) { + TextField( + value = state.paymentMethodType.name, + onValueChange = {}, + readOnly = true, + trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) }, + modifier = Modifier.menuAnchor() + ) + + ExposedDropdownMenu( + expanded = expanded, + onDismissRequest = { expanded = false } + ) { + PendingOneTimeDonation.PaymentMethodType.values().forEach { item -> + DropdownMenuItem( + text = { Text(text = item.name) }, + onClick = { + onPaymentMethodTypeSelected(item) + expanded = false + } + ) + } + } + } + } + + item { + Rows.ToggleRow( + checked = state.error != null, + text = "Enable error", + onCheckChanged = { + if (it) { + onAddError() + } else { + onClearError() + } + } + ) + } + + if (state.error != null) { + item { + var expanded by remember { + mutableStateOf(false) + } + + ExposedDropdownMenuBox( + expanded = expanded, + onExpandedChange = { + expanded = !expanded + } + ) { + TextField( + value = state.error.type.name, + onValueChange = {}, + readOnly = true, + trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) }, + modifier = Modifier.menuAnchor() + ) + + ExposedDropdownMenu( + expanded = expanded, + onDismissRequest = { expanded = false } + ) { + PendingOneTimeDonation.Error.Type.values().filterNot { + state.paymentMethodType == PendingOneTimeDonation.PaymentMethodType.PAYPAL && it == PendingOneTimeDonation.Error.Type.FAILURE_CODE + }.forEach { item -> + DropdownMenuItem( + text = { Text(text = item.name) }, + onClick = { + onErrorTypeSelected(item) + expanded = false + } + ) + } + } + } + } + + if (isCodedError) { + item { + var expanded by remember { + mutableStateOf(false) + } + + ExposedDropdownMenuBox( + expanded = expanded, + onExpandedChange = { + expanded = !expanded + } + ) { + TextField( + value = state.error.code, + onValueChange = {}, + readOnly = true, + trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) }, + modifier = Modifier.menuAnchor() + ) + + ExposedDropdownMenu( + expanded = expanded, + onDismissRequest = { expanded = false } + ) { + when (state.error.type) { + PendingOneTimeDonation.Error.Type.PROCESSOR_CODE -> { + ProcessorErrorsDropdown(state.paymentMethodType, onErrorCodeChanged) + } + + PendingOneTimeDonation.Error.Type.DECLINE_CODE -> { + DeclineCodeErrorsDropdown(state.paymentMethodType, onErrorCodeChanged) + } + + PendingOneTimeDonation.Error.Type.FAILURE_CODE -> { + FailureCodeErrorsDropdown(onErrorCodeChanged) + } + + else -> error("This should never happen") + } + } + } + } + } + } + + item { + Buttons.LargeTonal( + enabled = state.badge != null, + onClick = onSave + ) { + Text(text = "Save") + } + } + } + } +} + +@Composable +private fun ColumnScope.ProcessorErrorsDropdown( + paymentMethodType: PendingOneTimeDonation.PaymentMethodType, + onErrorCodeSelected: (String) -> Unit +) { + val values = when (paymentMethodType) { + PendingOneTimeDonation.PaymentMethodType.PAYPAL -> arrayOf("2046", "2074") + else -> arrayOf("currency_not_supported", "call_issuer") + } + + ValuesDropdown(values = values, onErrorCodeSelected = onErrorCodeSelected) +} + +@Composable +private fun ColumnScope.DeclineCodeErrorsDropdown( + paymentMethodType: PendingOneTimeDonation.PaymentMethodType, + onErrorCodeSelected: (String) -> Unit +) { + val values = remember(paymentMethodType) { + when (paymentMethodType) { + PendingOneTimeDonation.PaymentMethodType.PAYPAL -> PayPalDeclineCode.KnownCode.values() + else -> StripeDeclineCode.Code.values() + }.map { it.name }.toTypedArray() + } + + ValuesDropdown(values = values, onErrorCodeSelected = onErrorCodeSelected) +} + +@Composable +private fun ColumnScope.FailureCodeErrorsDropdown( + onErrorCodeSelected: (String) -> Unit +) { + val values = remember { + StripeFailureCode.Code.values().map { it.name }.toTypedArray() + } + + ValuesDropdown(values = values, onErrorCodeSelected = onErrorCodeSelected) +} + +@Composable +private fun ValuesDropdown(values: Array, onErrorCodeSelected: (String) -> Unit) { + values.forEach { item -> + DropdownMenuItem( + text = { Text(text = item) }, + onClick = { + onErrorCodeSelected(item) + } + ) + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/internal/InternalPendingOneTimeDonationConfigurationViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/internal/InternalPendingOneTimeDonationConfigurationViewModel.kt new file mode 100644 index 0000000000..2bdee05aff --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/internal/InternalPendingOneTimeDonationConfigurationViewModel.kt @@ -0,0 +1,52 @@ +/* + * Copyright 2023 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.components.settings.app.internal + +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.mutableStateOf +import androidx.lifecycle.ViewModel +import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers +import io.reactivex.rxjava3.core.Single +import io.reactivex.rxjava3.disposables.Disposable +import io.reactivex.rxjava3.schedulers.Schedulers +import org.signal.core.util.money.FiatMoney +import org.thoughtcrime.securesms.badges.Badges +import org.thoughtcrime.securesms.components.settings.app.subscription.DonationSerializationHelper.toFiatValue +import org.thoughtcrime.securesms.database.model.databaseprotos.PendingOneTimeDonation +import org.thoughtcrime.securesms.dependencies.ApplicationDependencies +import java.math.BigDecimal +import java.util.Currency +import java.util.Locale + +/** + * Fetches a badge for our pending donation, which requires downloading the donation config. + */ +class InternalPendingOneTimeDonationConfigurationViewModel : ViewModel() { + + val state: MutableState = mutableStateOf( + PendingOneTimeDonation( + timestamp = System.currentTimeMillis(), + amount = FiatMoney(BigDecimal.valueOf(20), Currency.getInstance("EUR")).toFiatValue() + ) + ) + + val disposable: Disposable = Single + .fromCallable { + ApplicationDependencies.getDonationsService() + .getDonationsConfiguration(Locale.getDefault()) + } + .flatMap { it.flattenResult() } + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe { config -> + val badge = Badges.fromServiceBadge(config.levels.values.first().badge) + state.value = state.value.copy(badge = Badges.toDatabaseBadge(badge)) + } + + override fun onCleared() { + super.onCleared() + } +} 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 f74891e6b6..3d1286c1d8 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 @@ -475,6 +475,22 @@ class InternalSettingsFragment : DSLSettingsFragment(R.string.preferences__inter ) } + if (state.hasPendingOneTimeDonation) { + clickPref( + title = DSLSettingsText.from("Clear pending one-time donation."), + onClick = { + SignalStore.donationsValues().setPendingOneTimeDonation(null) + } + ) + } else { + clickPref( + title = DSLSettingsText.from("Set pending one-time donation."), + onClick = { + findNavController().safeNavigate(InternalSettingsFragmentDirections.actionInternalSettingsFragmentToOneTimeDonationConfigurationFragment()) + } + ) + } + dividerPref() sectionHeaderPref(DSLSettingsText.from("Release channel")) diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/internal/InternalSettingsState.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/internal/InternalSettingsState.kt index 699acb2ba2..bee7b82be2 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/internal/InternalSettingsState.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/internal/InternalSettingsState.kt @@ -22,5 +22,6 @@ data class InternalSettingsState( val disableStorageService: Boolean, val canClearOnboardingState: Boolean, val pnpInitialized: Boolean, - val useConversationItemV2ForMedia: Boolean + val useConversationItemV2ForMedia: Boolean, + val hasPendingOneTimeDonation: Boolean ) diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/internal/InternalSettingsViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/internal/InternalSettingsViewModel.kt index 835168e70e..cbc9cd62a7 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/internal/InternalSettingsViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/internal/InternalSettingsViewModel.kt @@ -3,6 +3,7 @@ package org.thoughtcrime.securesms.components.settings.app.internal import androidx.lifecycle.LiveData import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModelProvider +import io.reactivex.rxjava3.core.Observable import org.signal.ringrtc.CallManager import org.thoughtcrime.securesms.jobs.StoryOnboardingDownloadJob import org.thoughtcrime.securesms.keyvalue.InternalValues @@ -20,6 +21,14 @@ class InternalSettingsViewModel(private val repository: InternalSettingsReposito repository.getEmojiVersionInfo { version -> store.update { it.copy(emojiVersion = version) } } + + val pendingOneTimeDonation: Observable = SignalStore.donationsValues().observablePendingOneTimeDonation + .distinctUntilChanged() + .map { it.isPresent } + + store.update(pendingOneTimeDonation) { pending, state -> + state.copy(hasPendingOneTimeDonation = pending) + } } val state: LiveData = store.stateLiveData @@ -136,7 +145,8 @@ class InternalSettingsViewModel(private val repository: InternalSettingsReposito disableStorageService = SignalStore.internalValues().storageServiceDisabled(), canClearOnboardingState = SignalStore.storyValues().hasDownloadedOnboardingStory && Stories.isFeatureEnabled(), pnpInitialized = SignalStore.misc().hasPniInitializedDevices(), - useConversationItemV2ForMedia = SignalStore.internalValues().useConversationItemV2Media() + useConversationItemV2ForMedia = SignalStore.internalValues().useConversationItemV2Media(), + hasPendingOneTimeDonation = SignalStore.donationsValues().getPendingOneTimeDonation() != null ) fun onClearOnboardingState() { diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/DonationSerializationHelper.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/DonationSerializationHelper.kt index 178dc3fc29..d203438557 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/DonationSerializationHelper.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/DonationSerializationHelper.kt @@ -68,7 +68,7 @@ object DonationSerializationHelper { ) } - private fun FiatMoney.toFiatValue(): FiatValue { + fun FiatMoney.toFiatValue(): FiatValue { return FiatValue( currencyCode = currency.currencyCode, amount = amount.toDecimalValue() diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/StripeRepository.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/StripeRepository.kt index fe60ae6503..1228f4cfcf 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/StripeRepository.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/StripeRepository.kt @@ -233,6 +233,7 @@ class StripeRepository(activity: Activity) : StripeApi.PaymentIntentFetcher, Str SignalStore.donationsValues().requireSubscriber() }.flatMap { Log.d(TAG, "Setting default payment method via Signal service...") + // TODO [sepa] -- iDEAL has its own call Single.fromCallable { ApplicationDependencies .getDonationsService() diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/DonateToSignalViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/DonateToSignalViewModel.kt index 21fca103e8..dd564f19f1 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/DonateToSignalViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/DonateToSignalViewModel.kt @@ -13,6 +13,7 @@ import org.signal.core.util.StringUtil import org.signal.core.util.logging.Log import org.signal.core.util.money.FiatMoney import org.signal.core.util.money.PlatformCurrencyUtil +import org.thoughtcrime.securesms.components.settings.app.subscription.DonationSerializationHelper.isExpired import org.thoughtcrime.securesms.components.settings.app.subscription.MonthlyDonationRepository import org.thoughtcrime.securesms.components.settings.app.subscription.OneTimeDonationRepository import org.thoughtcrime.securesms.components.settings.app.subscription.boost.Boost @@ -218,7 +219,7 @@ class DonateToSignalViewModel( }.distinctUntilChanged() val isOneTimeDonationPending: Observable = SignalStore.donationsValues().observablePendingOneTimeDonation - .map { it.isPresent } + .map { pending -> pending.filter { !it.isExpired }.isPresent } .distinctUntilChanged() oneTimeDonationDisposables += Observable 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 index 42d71fd34c..b0c5ba54aa 100644 --- 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 @@ -11,7 +11,7 @@ fun StripeFailureCode.mapToErrorStringResource(): Int { is StripeFailureCode.Known -> when (this.code) { StripeFailureCode.Code.REFER_TO_CUSTOMER -> R.string.StripeFailureCode__verify_your_bank_details_are_correct StripeFailureCode.Code.INSUFFICIENT_FUNDS -> R.string.StripeFailureCode__the_bank_account_provided - StripeFailureCode.Code.DEBIT_DISPUTED -> R.string.StripeFailureCode__verify_your_bank_details_are_correct // TODO [sepa] -- verify + StripeFailureCode.Code.DEBIT_DISPUTED -> R.string.StripeFailureCode__verify_your_bank_details_are_correct StripeFailureCode.Code.AUTHORIZATION_REVOKED -> R.string.StripeFailureCode__this_payment_was_revoked StripeFailureCode.Code.DEBIT_NOT_AUTHORIZED -> R.string.StripeFailureCode__this_payment_was_revoked StripeFailureCode.Code.ACCOUNT_CLOSED -> R.string.StripeFailureCode__the_bank_details_provided_could_not_be_processed 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 8b2481af11..a9fbfcb005 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 @@ -26,6 +26,7 @@ import org.thoughtcrime.securesms.components.settings.app.subscription.donate.Do import org.thoughtcrime.securesms.components.settings.app.subscription.models.NetworkFailure import org.thoughtcrime.securesms.components.settings.configure import org.thoughtcrime.securesms.components.settings.models.IndeterminateLoadingCircle +import org.thoughtcrime.securesms.database.model.databaseprotos.PendingOneTimeDonation import org.thoughtcrime.securesms.dependencies.ApplicationDependencies import org.thoughtcrime.securesms.help.HelpFragment import org.thoughtcrime.securesms.keyvalue.SignalStore @@ -183,6 +184,9 @@ class ManageDonationsFragment : pendingOneTimeDonation = pendingOneTimeDonation, onPendingClick = { displayPendingDialog(it) + }, + onErrorClick = { + displayPendingOneTimeDonationErrorDialog(it) } ) ) @@ -340,6 +344,16 @@ class ManageDonationsFragment : .show() } + private fun displayPendingOneTimeDonationErrorDialog(error: PendingOneTimeDonation.Error) { + // TODO [sepa] -- actual dialog text? + MaterialAlertDialogBuilder(requireContext()) + .setTitle(R.string.DonationsErrors__error_processing_payment) + .setPositiveButton(android.R.string.ok) { _, _ -> + SignalStore.donationsValues().setPendingOneTimeDonation(null) + } + .show() + } + override fun onMakeAMonthlyDonation() { findNavController().safeNavigate(ManageDonationsFragmentDirections.actionManageDonationsFragmentToDonateToSignalFragment(DonateToSignalType.MONTHLY)) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/manage/OneTimeDonationPreference.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/manage/OneTimeDonationPreference.kt index aa1262d19d..004889c24b 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/manage/OneTimeDonationPreference.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/manage/OneTimeDonationPreference.kt @@ -32,7 +32,8 @@ object OneTimeDonationPreference { class Model( val pendingOneTimeDonation: PendingOneTimeDonation, - val onPendingClick: (FiatMoney) -> Unit + val onPendingClick: (FiatMoney) -> Unit, + val onErrorClick: (PendingOneTimeDonation.Error) -> Unit ) : MappingModel { override fun areItemsTheSame(newItem: Model): Boolean = true @@ -55,15 +56,38 @@ object OneTimeDonationPreference { FiatMoneyUtil.format(context.resources, model.pendingOneTimeDonation.amount!!.toFiatMoney(), FiatMoneyUtil.formatOptions().trimZerosAfterDecimal()) ) + if (model.pendingOneTimeDonation.error != null) { + presentErrorState(model, model.pendingOneTimeDonation.error) + } else { + presentPendingState(model) + } + } + + private fun presentErrorState(model: Model, error: PendingOneTimeDonation.Error) { + expiry.text = getErrorSubtitle(error) + + itemView.setOnClickListener { model.onErrorClick(error) } + + progress.visible = false + } + + private fun presentPendingState(model: Model) { expiry.text = getPendingSubtitle(model.pendingOneTimeDonation.paymentMethodType) if (model.pendingOneTimeDonation.paymentMethodType == PendingOneTimeDonation.PaymentMethodType.SEPA_DEBIT) { - itemView.setOnClickListener { model.onPendingClick(model.pendingOneTimeDonation.amount.toFiatMoney()) } + itemView.setOnClickListener { model.onPendingClick(model.pendingOneTimeDonation.amount!!.toFiatMoney()) } } progress.visible = model.pendingOneTimeDonation.paymentMethodType != PendingOneTimeDonation.PaymentMethodType.SEPA_DEBIT } + private fun getErrorSubtitle(error: PendingOneTimeDonation.Error): String { + return when (error.type) { + PendingOneTimeDonation.Error.Type.REDEMPTION -> context.getString(R.string.DonationsErrors__couldnt_add_badge) + else -> context.getString(R.string.DonationsErrors__donation_failed) + } + } + private fun getPendingSubtitle(paymentMethodType: PendingOneTimeDonation.PaymentMethodType): String { return when (paymentMethodType) { PendingOneTimeDonation.PaymentMethodType.CARD -> context.getString(R.string.OneTimeDonationPreference__donation_processing) diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/BoostReceiptRequestResponseJob.java b/app/src/main/java/org/thoughtcrime/securesms/jobs/BoostReceiptRequestResponseJob.java index 79f57c1b3c..ef174e025c 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/jobs/BoostReceiptRequestResponseJob.java +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/BoostReceiptRequestResponseJob.java @@ -7,6 +7,8 @@ import androidx.annotation.Nullable; import androidx.annotation.VisibleForTesting; import org.signal.core.util.logging.Log; +import org.signal.donations.StripeDeclineCode; +import org.signal.donations.StripeFailureCode; import org.signal.libsignal.zkgroup.InvalidInputException; import org.signal.libsignal.zkgroup.VerificationFailedException; import org.signal.libsignal.zkgroup.receipts.ClientZkReceiptOperations; @@ -18,6 +20,7 @@ import org.signal.libsignal.zkgroup.receipts.ReceiptSerial; import org.thoughtcrime.securesms.components.settings.app.subscription.errors.DonationError; import org.thoughtcrime.securesms.components.settings.app.subscription.errors.DonationErrorSource; import org.thoughtcrime.securesms.database.model.databaseprotos.DonationCompletedQueue; +import org.thoughtcrime.securesms.database.model.databaseprotos.PendingOneTimeDonation; import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; import org.thoughtcrime.securesms.jobmanager.Job; import org.thoughtcrime.securesms.jobmanager.JobManager; @@ -25,9 +28,11 @@ import org.thoughtcrime.securesms.jobmanager.JsonJobData; import org.thoughtcrime.securesms.jobmanager.impl.NetworkConstraint; import org.thoughtcrime.securesms.keyvalue.SignalStore; import org.thoughtcrime.securesms.recipients.RecipientId; +import org.whispersystems.signalservice.api.subscriptions.ActiveSubscription; import org.whispersystems.signalservice.api.subscriptions.SubscriptionLevels; import org.whispersystems.signalservice.internal.ServiceResponse; import org.whispersystems.signalservice.internal.push.DonationProcessor; +import org.whispersystems.signalservice.internal.push.exceptions.DonationReceiptCredentialError; import java.io.IOException; import java.security.SecureRandom; @@ -208,6 +213,7 @@ public class BoostReceiptRequestResponseJob extends BaseJob { if (!isCredentialValid(receiptCredential)) { DonationError.routeBackgroundError(context, uiSessionKey, DonationError.badgeCredentialVerificationFailure(donationErrorSource)); + setPendingOneTimeDonationGenericRedemptionError(-1); throw new IOException("Could not validate receipt credential"); } @@ -232,6 +238,54 @@ public class BoostReceiptRequestResponseJob extends BaseJob { SignalStore.donationsValues().setPendingOneTimeDonation(null); } + /** + * Sets the pending one-time donation error according to the status code. + */ + private void setPendingOneTimeDonationGenericRedemptionError(int statusCode) { + SignalStore.donationsValues().setPendingOneTimeDonationError( + new PendingOneTimeDonation.Error.Builder() + .type(statusCode == 402 + ? PendingOneTimeDonation.Error.Type.PAYMENT + : PendingOneTimeDonation.Error.Type.REDEMPTION) + .code(Integer.toString(statusCode)) + .build() + ); + } + + /** + * Sets the pending one-time donation error according to the given charge failure. + */ + private void setPendingOneTimeDonationChargeFailureError(@NonNull ActiveSubscription.ChargeFailure chargeFailure) { + final PendingOneTimeDonation.Error.Type type; + final String code; + + if (donationProcessor == DonationProcessor.PAYPAL) { + code = chargeFailure.getCode(); + type = PendingOneTimeDonation.Error.Type.PROCESSOR_CODE; + } else { + StripeDeclineCode declineCode = StripeDeclineCode.Companion.getFromCode(chargeFailure.getOutcomeNetworkReason()); + StripeFailureCode failureCode = StripeFailureCode.Companion.getFromCode(chargeFailure.getCode()); + + if (failureCode.isKnown()) { + code = failureCode.toString(); + type = PendingOneTimeDonation.Error.Type.FAILURE_CODE; + } else if (declineCode.isKnown()) { + code = declineCode.toString(); + type = PendingOneTimeDonation.Error.Type.DECLINE_CODE; + } else { + code = chargeFailure.getCode(); + type = PendingOneTimeDonation.Error.Type.PROCESSOR_CODE; + } + } + + SignalStore.donationsValues().setPendingOneTimeDonationError( + new PendingOneTimeDonation.Error.Builder() + .type(type) + .code(code) + .build() + ); + } + private void handleApplicationError(Context context, ServiceResponse response, @NonNull DonationErrorSource donationErrorSource) throws Exception { Throwable applicationException = response.getApplicationError().get(); switch (response.getStatus()) { @@ -241,14 +295,23 @@ public class BoostReceiptRequestResponseJob extends BaseJob { case 400: Log.w(TAG, "Receipt credential request failed to validate.", applicationException, true); DonationError.routeBackgroundError(context, uiSessionKey, DonationError.genericBadgeRedemptionFailure(donationErrorSource)); + setPendingOneTimeDonationGenericRedemptionError(response.getStatus()); throw new Exception(applicationException); case 402: Log.w(TAG, "User payment failed.", applicationException, true); DonationError.routeBackgroundError(context, uiSessionKey, DonationError.genericPaymentFailure(donationErrorSource)); + + if (applicationException instanceof DonationReceiptCredentialError) { + setPendingOneTimeDonationChargeFailureError(((DonationReceiptCredentialError) applicationException).getChargeFailure()); + } else { + setPendingOneTimeDonationGenericRedemptionError(response.getStatus()); + } + throw new Exception(applicationException); case 409: Log.w(TAG, "Receipt already redeemed with a different request credential.", response.getApplicationError().get(), true); DonationError.routeBackgroundError(context, uiSessionKey, DonationError.genericBadgeRedemptionFailure(donationErrorSource)); + setPendingOneTimeDonationGenericRedemptionError(response.getStatus()); throw new Exception(applicationException); default: Log.w(TAG, "Encountered a server failure: " + response.getStatus(), applicationException, true); diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/DonationReceiptRedemptionJob.java b/app/src/main/java/org/thoughtcrime/securesms/jobs/DonationReceiptRedemptionJob.java index 90c6930cd0..99b8777bae 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/jobs/DonationReceiptRedemptionJob.java +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/DonationReceiptRedemptionJob.java @@ -14,6 +14,7 @@ import org.thoughtcrime.securesms.database.SignalDatabase; import org.thoughtcrime.securesms.database.model.MessageRecord; import org.thoughtcrime.securesms.database.model.databaseprotos.DonationCompletedQueue; import org.thoughtcrime.securesms.database.model.databaseprotos.GiftBadge; +import org.thoughtcrime.securesms.database.model.databaseprotos.PendingOneTimeDonation; import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; import org.thoughtcrime.securesms.jobmanager.Job; import org.thoughtcrime.securesms.jobmanager.JobManager; @@ -127,9 +128,9 @@ public class DonationReceiptRedemptionJob extends BaseJob { private DonationReceiptRedemptionJob(long giftMessageId, boolean primary, @NonNull DonationErrorSource errorSource, long uiSessionKey, boolean isLongRunning, @NonNull Job.Parameters parameters) { super(parameters); - this.giftMessageId = giftMessageId; - this.makePrimary = primary; - this.errorSource = errorSource; + this.giftMessageId = giftMessageId; + this.makePrimary = primary; + this.errorSource = errorSource; this.uiSessionKey = uiSessionKey; this.isLongRunningDonationPaymentType = isLongRunning; } @@ -158,8 +159,6 @@ public class DonationReceiptRedemptionJob extends BaseJob { MultiDeviceSubscriptionSyncRequestJob.enqueue(); } else if (giftMessageId != NO_ID) { SignalDatabase.messages().markGiftRedemptionFailed(giftMessageId); - } else { - SignalStore.donationsValues().setPendingOneTimeDonation(null); } } @@ -207,6 +206,16 @@ public class DonationReceiptRedemptionJob extends BaseJob { } else { Log.w(TAG, "Encountered a non-recoverable exception " + response.getStatus(), response.getApplicationError().get(), true); DonationError.routeBackgroundError(context, uiSessionKey, DonationError.genericBadgeRedemptionFailure(errorSource)); + + if (isForOneTimeDonation()) { + SignalStore.donationsValues().setPendingOneTimeDonationError( + new PendingOneTimeDonation.Error.Builder() + .type(PendingOneTimeDonation.Error.Type.REDEMPTION) + .code(Integer.toString(response.getStatus())) + .build() + ); + } + throw new IOException(response.getApplicationError().get()); } } else if (response.getExecutionError().isPresent()) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/keyvalue/DonationsValues.kt b/app/src/main/java/org/thoughtcrime/securesms/keyvalue/DonationsValues.kt index df1f8385c6..d1b92a92db 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/keyvalue/DonationsValues.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/keyvalue/DonationsValues.kt @@ -160,9 +160,13 @@ internal class DonationsValues internal constructor(store: KeyValueStore) : Sign private var _pendingOneTimeDonation: PendingOneTimeDonation? by protoValue(PENDING_ONE_TIME_DONATION, PendingOneTimeDonation.ADAPTER) private val pendingOneTimeDonationPublisher: Subject> by lazy { BehaviorSubject.createDefault(Optional.ofNullable(_pendingOneTimeDonation)) } + + /** + * Returns a stream of PendingOneTimeDonation, filtering out expired donations that do not have an error attached to them. + */ val observablePendingOneTimeDonation: Observable> by lazy { pendingOneTimeDonationPublisher.map { optionalPendingOneTimeDonation -> - optionalPendingOneTimeDonation.filter { !it.isExpired } + optionalPendingOneTimeDonation.filter { (it.error != null) || !it.isExpired } } } @@ -534,11 +538,28 @@ internal class DonationsValues internal constructor(store: KeyValueStore) : Sign } } - fun getPendingOneTimeDonation(): PendingOneTimeDonation? = _pendingOneTimeDonation.takeUnless { it?.isExpired == true } + fun getPendingOneTimeDonation(): PendingOneTimeDonation? { + return synchronized(this) { + _pendingOneTimeDonation.takeUnless { it?.isExpired == true } + } + } fun setPendingOneTimeDonation(pendingOneTimeDonation: PendingOneTimeDonation?) { - this._pendingOneTimeDonation = pendingOneTimeDonation - pendingOneTimeDonationPublisher.onNext(Optional.ofNullable(pendingOneTimeDonation)) + synchronized(this) { + this._pendingOneTimeDonation = pendingOneTimeDonation + pendingOneTimeDonationPublisher.onNext(Optional.ofNullable(pendingOneTimeDonation)) + } + } + + fun setPendingOneTimeDonationError(error: PendingOneTimeDonation.Error) { + synchronized(this) { + val pendingOneTimeDonation = getPendingOneTimeDonation() + if (pendingOneTimeDonation != null) { + setPendingOneTimeDonation(pendingOneTimeDonation.newBuilder().error(error).build()) + } else { + Log.w(TAG, "PendingOneTimeDonation was null, ignoring error.") + } + } } fun consumePending3DSData(uiSessionKey: Long): Stripe3DSData? { diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/FeatureFlags.java b/app/src/main/java/org/thoughtcrime/securesms/util/FeatureFlags.java index 1fe9f2049b..5549cfbbd6 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/util/FeatureFlags.java +++ b/app/src/main/java/org/thoughtcrime/securesms/util/FeatureFlags.java @@ -361,7 +361,7 @@ public final class FeatureFlags { /** Internal testing extensions. */ public static boolean internalUser() { - return getBoolean(INTERNAL_USER, false) || Environment.IS_PNP; + return getBoolean(INTERNAL_USER, false) || Environment.IS_PNP || Environment.IS_STAGING; } /** Whether or not to use the UUID in verification codes. */ diff --git a/app/src/main/protowire/Database.proto b/app/src/main/protowire/Database.proto index 92c35229f0..50507aacbc 100644 --- a/app/src/main/protowire/Database.proto +++ b/app/src/main/protowire/Database.proto @@ -286,6 +286,19 @@ message FiatValue { } message PendingOneTimeDonation { + message Error { + enum Type { + PROCESSOR_CODE = 0; // Generic processor error (e.g. Stripe returned an error code) + DECLINE_CODE = 1; // Stripe or PayPal decline Code + FAILURE_CODE = 2; // Stripe bank transfer failure code + REDEMPTION = 3; // Generic redemption error (status is HTTP code) + PAYMENT = 4; // Generic payment error (status is HTTP code) + } + + Type type = 1; + string code = 2; + } + enum PaymentMethodType { CARD = 0; SEPA_DEBIT = 1; @@ -293,10 +306,11 @@ message PendingOneTimeDonation { IDEAL = 3; } - PaymentMethodType paymentMethodType = 1; - FiatValue amount = 2; - BadgeList.Badge badge = 3; - int64 timestamp = 4; + PaymentMethodType paymentMethodType = 1; + FiatValue amount = 2; + BadgeList.Badge badge = 3; + int64 timestamp = 4; + optional Error error = 5; } message DonationCompletedQueue { diff --git a/app/src/main/res/navigation/app_settings.xml b/app/src/main/res/navigation/app_settings.xml index d2fffbd3fc..62bf222df5 100644 --- a/app/src/main/res/navigation/app_settings.xml +++ b/app/src/main/res/navigation/app_settings.xml @@ -589,8 +589,16 @@ + + + { if (code == 204) throw new NonSuccessfulResponseCodeException(204); + if (code == 402) { + DonationReceiptCredentialError donationReceiptCredentialError; + try { + donationReceiptCredentialError = JsonUtil.fromJson(body.string(), DonationReceiptCredentialError.class); + } catch (IOException e) { + throw new NonSuccessfulResponseCodeException(402); + } + + throw donationReceiptCredentialError; + } }); ReceiptCredentialResponseJson responseJson = JsonUtil.fromJson(response, ReceiptCredentialResponseJson.class); @@ -2668,11 +2679,14 @@ public class PushServiceSocket { } if (responseCode == 440) { + DonationProcessorError exception; try { - throw JsonUtil.fromJson(body.string(), DonationProcessorError.class); + exception = JsonUtil.fromJson(body.string(), DonationProcessorError.class); } catch (IOException e) { throw new NonSuccessfulResponseCodeException(440); } + + throw exception; } else { throw new NonSuccessfulResponseCodeException(responseCode); } diff --git a/libsignal-service/src/main/java/org/whispersystems/signalservice/internal/push/exceptions/DonationReceiptCredentialError.kt b/libsignal-service/src/main/java/org/whispersystems/signalservice/internal/push/exceptions/DonationReceiptCredentialError.kt new file mode 100644 index 0000000000..88f30e641c --- /dev/null +++ b/libsignal-service/src/main/java/org/whispersystems/signalservice/internal/push/exceptions/DonationReceiptCredentialError.kt @@ -0,0 +1,25 @@ +/* + * Copyright 2023 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.whispersystems.signalservice.internal.push.exceptions + +import com.fasterxml.jackson.annotation.JsonCreator +import org.whispersystems.signalservice.api.push.exceptions.NonSuccessfulResponseCodeException +import org.whispersystems.signalservice.api.subscriptions.ActiveSubscription.ChargeFailure + +/** + * HTTP 402 Exception when trying to submit credentials for a donation with + * a failed payment. + */ +class DonationReceiptCredentialError @JsonCreator constructor( + val chargeFailure: ChargeFailure +) : NonSuccessfulResponseCodeException(402) { + override fun toString(): String { + return """ + DonationReceiptCredentialError (402) + Charge Failure: $chargeFailure + """.trimIndent() + } +}