Implement pending one-time donation error handling.

This commit is contained in:
Alex Hart
2023-10-23 12:50:54 -04:00
committed by GitHub
parent d497ed4195
commit 10eec025d2
19 changed files with 615 additions and 22 deletions

View File

@@ -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<String>, onErrorCodeSelected: (String) -> Unit) {
values.forEach { item ->
DropdownMenuItem(
text = { Text(text = item) },
onClick = {
onErrorCodeSelected(item)
}
)
}
}

View File

@@ -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<PendingOneTimeDonation> = 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()
}
}

View File

@@ -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"))

View File

@@ -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
)

View File

@@ -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<Boolean> = SignalStore.donationsValues().observablePendingOneTimeDonation
.distinctUntilChanged()
.map { it.isPresent }
store.update(pendingOneTimeDonation) { pending, state ->
state.copy(hasPendingOneTimeDonation = pending)
}
}
val state: LiveData<InternalSettingsState> = 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() {

View File

@@ -68,7 +68,7 @@ object DonationSerializationHelper {
)
}
private fun FiatMoney.toFiatValue(): FiatValue {
fun FiatMoney.toFiatValue(): FiatValue {
return FiatValue(
currencyCode = currency.currencyCode,
amount = amount.toDecimalValue()

View File

@@ -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()

View File

@@ -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<Boolean> = SignalStore.donationsValues().observablePendingOneTimeDonation
.map { it.isPresent }
.map { pending -> pending.filter { !it.isExpired }.isPresent }
.distinctUntilChanged()
oneTimeDonationDisposables += Observable

View File

@@ -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

View File

@@ -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))
}

View File

@@ -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<Model> {
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)

View File

@@ -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<ReceiptCredentialResponse> 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);

View File

@@ -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()) {

View File

@@ -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<Optional<PendingOneTimeDonation>> 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<Optional<PendingOneTimeDonation>> 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? {

View File

@@ -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. */

View File

@@ -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 {

View File

@@ -589,8 +589,16 @@
<action
android:id="@+id/action_internalSettingsFragment_to_internalConversationSpringboardFragment"
app:destination="@id/internalConversationSpringboardFragment" />
<action
android:id="@+id/action_internalSettingsFragment_to_oneTimeDonationConfigurationFragment"
app:destination="@id/oneTimeDonationConfigurationFragment" />
</fragment>
<fragment
android:id="@+id/oneTimeDonationConfigurationFragment"
android:name="org.thoughtcrime.securesms.components.settings.app.internal.InternalPendingOneTimeDonationConfigurationFragment"
android:label="one_time_donation_configuration_fragment" />
<fragment
android:id="@+id/donorErrorConfigurationFragment"
android:name="org.thoughtcrime.securesms.components.settings.app.internal.donor.InternalDonorErrorConfigurationFragment"

View File

@@ -113,6 +113,7 @@ import org.whispersystems.signalservice.internal.contacts.entities.KeyBackupResp
import org.whispersystems.signalservice.internal.contacts.entities.TokenResponse;
import org.whispersystems.signalservice.internal.crypto.AttachmentDigest;
import org.whispersystems.signalservice.internal.push.exceptions.DonationProcessorError;
import org.whispersystems.signalservice.internal.push.exceptions.DonationReceiptCredentialError;
import org.whispersystems.signalservice.internal.push.exceptions.ForbiddenException;
import org.whispersystems.signalservice.internal.push.exceptions.GroupExistsException;
import org.whispersystems.signalservice.internal.push.exceptions.GroupMismatchedDevicesException;
@@ -1174,6 +1175,16 @@ public class PushServiceSocket {
NO_HEADERS,
(code, body) -> {
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);
}

View File

@@ -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()
}
}