Remove bank selection from iDEAL.

This commit is contained in:
Alex Hart
2025-05-14 16:15:30 -03:00
committed by Michelle Tang
parent c865ed0cdc
commit a050b37f3a
32 changed files with 47 additions and 757 deletions

View File

@@ -33,7 +33,6 @@ fun PaymentSource.toProto(): InAppPaymentSourceData {
code = type.toInAppPaymentSourceDataCode(),
idealData = if (this is IDEALPaymentSource) {
InAppPaymentSourceData.IDEALData(
bank = idealData.bank,
name = idealData.name,
email = idealData.email
)
@@ -77,8 +76,7 @@ fun InAppPaymentSourceData.toPaymentSource(): PaymentSource {
InAppPaymentSourceData.Code.IDEAL -> {
IDEALPaymentSource(
StripeApi.IDEALData(
bank = idealData!!.bank,
name = idealData.name,
name = idealData!!.name,
email = idealData.email
)
)

View File

@@ -33,7 +33,7 @@ class InAppPaymentError(
is DonationError.PaymentSetupError.StripeCodedError -> InAppPaymentData.Error(type = InAppPaymentData.Error.Type.STRIPE_CODED_ERROR, data_ = donationError.errorCode)
is DonationError.PaymentSetupError.StripeDeclinedError -> InAppPaymentData.Error(type = InAppPaymentData.Error.Type.STRIPE_DECLINED_ERROR, data_ = donationError.declineCode.rawCode)
is DonationError.PaymentSetupError.StripeFailureCodeError -> InAppPaymentData.Error(type = InAppPaymentData.Error.Type.STRIPE_FAILURE, data_ = donationError.failureCode.rawCode)
is DonationError.UserCancelledPaymentError -> null
is DonationError.UserCancelledPaymentError -> InAppPaymentData.Error(type = InAppPaymentData.Error.Type.SETUP_CANCELLED)
is DonationError.UserLaunchedExternalApplication -> null
}

View File

@@ -33,6 +33,7 @@ import org.thoughtcrime.securesms.components.settings.app.subscription.donate.Do
import org.thoughtcrime.securesms.database.SignalDatabase
import org.thoughtcrime.securesms.database.model.databaseprotos.InAppPaymentData
import org.thoughtcrime.securesms.databinding.DonationWebviewFragmentBinding
import org.thoughtcrime.securesms.util.Environment
import org.thoughtcrime.securesms.util.RemoteConfig
import org.thoughtcrime.securesms.util.visible
@@ -86,7 +87,7 @@ class Stripe3DSDialogFragment : DialogFragment(R.layout.donation_webview_fragmen
)
)
if (RemoteConfig.internalUser && args.waitingForAuthPayment.data.paymentMethodType == InAppPaymentData.PaymentMethodType.IDEAL) {
if (Environment.IS_STAGING && RemoteConfig.internalUser && args.waitingForAuthPayment.data.paymentMethodType == InAppPaymentData.PaymentMethodType.IDEAL) {
val openApp = MaterialButton(requireContext()).apply {
text = "Open App"
layoutParams = FrameLayout.LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT).apply {

View File

@@ -1,108 +0,0 @@
/*
* Copyright 2023 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.components.settings.app.subscription.donate.transfer.ideal
import androidx.annotation.DrawableRes
import androidx.annotation.StringRes
import org.thoughtcrime.securesms.R
import java.util.EnumMap
/**
* Set of banks that are supported for iDEAL transfers, as listed here:
* https://stripe.com/docs/api/payment_methods/object#payment_method_object-ideal-bank
*/
enum class IdealBank(
val code: String
) {
ABN_AMRO("abn_amro"),
ASN_BANK("asn_bank"),
BUNQ("bunq"),
ING("ing"),
KNAB("knab"),
N26("n26"),
RABOBANK("rabobank"),
REGIOBANK("regiobank"),
REVOLUT("revolut"),
SNS_BANK("sns_bank"),
TRIODOS_BANK("triodos_bank"),
VAN_LANSCHOT("van_lanschot"),
YOURSAFE("yoursafe");
fun getUIValues(): UIValues = bankToUIValues[this]!!
companion object {
private val bankToUIValues: Map<IdealBank, UIValues> by lazy {
EnumMap<IdealBank, UIValues>(IdealBank::class.java).apply {
putAll(
arrayOf(
ABN_AMRO to UIValues(
name = R.string.IdealBank__abn_amro,
icon = R.drawable.ideal_abn_amro
),
ASN_BANK to UIValues(
name = R.string.IdealBank__asn_bank,
icon = R.drawable.ideal_asn
),
BUNQ to UIValues(
name = R.string.IdealBank__bunq,
icon = R.drawable.ideal_bunq
),
ING to UIValues(
name = R.string.IdealBank__ing,
icon = R.drawable.ideal_ing
),
KNAB to UIValues(
name = R.string.IdealBank__knab,
icon = R.drawable.ideal_knab
),
N26 to UIValues(
name = R.string.IdealBank__n26,
icon = R.drawable.ideal_n26
),
RABOBANK to UIValues(
name = R.string.IdealBank__rabobank,
icon = R.drawable.ideal_rabobank
),
REGIOBANK to UIValues(
name = R.string.IdealBank__regiobank,
icon = R.drawable.ideal_regiobank
),
REVOLUT to UIValues(
name = R.string.IdealBank__revolut,
icon = R.drawable.ideal_revolut
),
SNS_BANK to UIValues(
name = R.string.IdealBank__sns_bank,
icon = R.drawable.ideal_sns
),
TRIODOS_BANK to UIValues(
name = R.string.IdealBank__triodos_bank,
icon = R.drawable.ideal_triodos_bank
),
VAN_LANSCHOT to UIValues(
name = R.string.IdealBank__van_lanschot,
icon = R.drawable.ideal_van_lanschot
),
YOURSAFE to UIValues(
name = R.string.IdealBank__yoursafe,
icon = R.drawable.ideal_yoursafe
)
)
)
}
}
fun fromCode(code: String): IdealBank {
return entries.first { it.code == code }
}
}
data class UIValues(
@StringRes val name: Int,
@DrawableRes val icon: Int
)
}

View File

@@ -1,114 +0,0 @@
/*
* Copyright 2023 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.components.settings.app.subscription.donate.transfer.ideal
import android.os.Bundle
import androidx.compose.foundation.Image
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.defaultMinSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment.Companion.CenterVertically
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.dimensionResource
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.core.os.bundleOf
import androidx.fragment.app.setFragmentResult
import androidx.navigation.fragment.findNavController
import org.signal.core.ui.compose.Scaffolds
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.compose.ComposeDialogFragment
import org.signal.core.ui.R as CoreUiR
/**
* Dialog fragment for selecting the bank for the iDEAL donation.
*/
class IdealTransferDetailsBankSelectionDialogFragment : ComposeDialogFragment() {
companion object {
const val IDEAL_SELECTED_BANK = "ideal.selected.bank"
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setStyle(STYLE_NO_FRAME, R.style.Signal_DayNight_Dialog_FullScreen)
}
@Composable
override fun DialogContent() {
BankSelectionContent(
onNavigationClick = { findNavController().popBackStack() },
onBankSelected = {
dismissAllowingStateLoss()
setFragmentResult(
IDEAL_SELECTED_BANK,
bundleOf(
IDEAL_SELECTED_BANK to it.code
)
)
}
)
}
}
@Preview
@Composable
private fun BankSelectionContentPreview() {
BankSelectionContent(
onNavigationClick = {},
onBankSelected = {}
)
}
@Composable
private fun BankSelectionContent(
onNavigationClick: () -> Unit,
onBankSelected: (IdealBank) -> Unit
) {
Scaffolds.Settings(
title = stringResource(R.string.IdealTransferDetailsBankSelectionDialogFragment__choose_your_bank),
onNavigationClick = onNavigationClick,
navigationIconPainter = painterResource(id = R.drawable.symbol_x_24)
) { paddingValues ->
LazyColumn(modifier = Modifier.padding(paddingValues)) {
items(IdealBank.entries.toTypedArray()) {
val uiValues = it.getUIValues()
Row(
verticalAlignment = CenterVertically,
modifier = Modifier
.clickable { onBankSelected(it) }
.fillMaxWidth()
.defaultMinSize(minHeight = 56.dp)
.padding(horizontal = dimensionResource(id = CoreUiR.dimen.gutter), vertical = 8.dp)
) {
Image(
painter = painterResource(id = uiValues.icon),
contentDescription = null,
modifier = Modifier
.size(40.dp)
)
Text(
text = stringResource(uiValues.name),
modifier = Modifier.padding(start = 24.dp)
)
}
}
}
}
}

View File

@@ -8,21 +8,16 @@ package org.thoughtcrime.securesms.components.settings.app.subscription.donate.t
import android.os.Bundle
import android.view.View
import androidx.annotation.StringRes
import androidx.compose.foundation.Image
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.defaultMinSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextField
import androidx.compose.material3.TextFieldDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
@@ -30,16 +25,13 @@ import androidx.compose.ui.Alignment.Companion.CenterHorizontally
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusDirection
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardCapitalization
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.core.os.bundleOf
import androidx.fragment.app.setFragmentResult
@@ -98,11 +90,6 @@ class IdealTransferDetailsFragment : ComposeFragment(), InAppPaymentCheckoutDele
setFragmentResult(BankTransferRequestKeys.REQUEST_KEY, bundle)
}
}
setFragmentResultListener(IdealTransferDetailsBankSelectionDialogFragment.IDEAL_SELECTED_BANK) { _, bundle ->
val bankCode = bundle.getString(IdealTransferDetailsBankSelectionDialogFragment.IDEAL_SELECTED_BANK)!!
viewModel.onBankSelected(IdealBank.fromCode(bankCode))
}
}
@Composable
@@ -166,7 +153,7 @@ class IdealTransferDetailsFragment : ComposeFragment(), InAppPaymentCheckoutDele
if (state.inAppPayment!!.type.recurring) { // TODO [message-requests] -- handle backup
val formattedMoney = FiatMoneyUtil.format(requireContext().resources, state.inAppPayment.data.amount!!.toFiatMoney(), FiatMoneyUtil.formatOptions().trimZerosAfterDecimal())
MaterialAlertDialogBuilder(requireContext())
.setTitle(getString(R.string.IdealTransferDetailsFragment__confirm_your_donation_with_s, getString(state.idealBank!!.getUIValues().name)))
.setTitle(getString(R.string.IdealTransferDetailsFragment__confirm_your_donation_with_ideal))
.setMessage(getString(R.string.IdealTransferDetailsFragment__to_setup_your_recurring_donation, formattedMoney))
.setPositiveButton(R.string.IdealTransferDetailsFragment__continue) { _, _ ->
continueTransfer()
@@ -262,15 +249,6 @@ private fun IdealTransferDetailsContent(
)
}
item {
IdealBankSelector(
idealBank = state.idealBank,
onSelectBankClick = onSelectBankClick,
modifier = Modifier
.fillMaxWidth()
)
}
item {
TextField(
value = state.name,
@@ -346,60 +324,3 @@ private fun IdealTransferDetailsContent(
}
}
}
@Preview
@Composable
private fun IdealBankSelectorPreview() {
IdealBankSelector(
idealBank = null,
onSelectBankClick = {}
)
}
@Composable
private fun IdealBankSelector(
idealBank: IdealBank?,
onSelectBankClick: () -> Unit,
modifier: Modifier = Modifier
) {
val uiValues: IdealBank.UIValues? = remember(idealBank) { idealBank?.getUIValues() }
val imagePadding: Dp = if (idealBank == null) 4.dp else 0.dp
TextField(
value = stringResource(id = uiValues?.name ?: R.string.IdealTransferDetailsFragment__choose_your_bank),
textStyle = MaterialTheme.typography.bodyLarge,
onValueChange = {},
enabled = false,
readOnly = true,
leadingIcon = {
Image(
painter = painterResource(id = uiValues?.icon ?: R.drawable.bank_transfer),
contentDescription = null,
colorFilter = if (uiValues?.icon == null) ColorFilter.tint(MaterialTheme.colorScheme.onSurface) else null,
modifier = Modifier
.padding(start = 16.dp, end = 12.dp)
.size(32.dp)
.padding(imagePadding)
)
},
trailingIcon = {
Icon(
painter = painterResource(id = R.drawable.symbol_dropdown_triangle_compat_bold_16),
contentDescription = null
)
},
colors = TextFieldDefaults.colors(
disabledTextColor = MaterialTheme.colorScheme.onSurface,
disabledTrailingIconColor = MaterialTheme.colorScheme.onSurface,
disabledContainerColor = MaterialTheme.colorScheme.surfaceVariant,
disabledIndicatorColor = MaterialTheme.colorScheme.onSurface
),
supportingText = {},
modifier = modifier
.defaultMinSize(minHeight = 78.dp)
.clickable(
onClick = onSelectBankClick,
role = Role.Button
)
)
}

View File

@@ -11,7 +11,6 @@ import org.thoughtcrime.securesms.database.InAppPaymentTable
data class IdealTransferDetailsState(
val inAppPayment: InAppPaymentTable.InAppPayment? = null,
val idealBank: IdealBank? = null,
val name: String = "",
val nameFocusState: FocusState = FocusState.NOT_FOCUSED,
val email: String = "",
@@ -28,14 +27,13 @@ data class IdealTransferDetailsState(
fun asIDEALData(): StripeApi.IDEALData {
return StripeApi.IDEALData(
bank = idealBank!!.code,
name = name.trim(),
email = email.trim()
)
}
fun canProceed(): Boolean {
return idealBank != null && BankDetailsValidator.validName(name) && (inAppPayment?.type?.recurring != true || BankDetailsValidator.validEmail(email))
return BankDetailsValidator.validName(name) && (inAppPayment?.type?.recurring != true || BankDetailsValidator.validEmail(email))
}
enum class FocusState {

View File

@@ -71,12 +71,6 @@ class IdealTransferDetailsViewModel(inAppPaymentId: InAppPaymentTable.InAppPayme
}
}
fun onBankSelected(idealBank: IdealBank) {
internalState.value = internalState.value.copy(
idealBank = idealBank
)
}
enum class Field {
NAME,
EMAIL

View File

@@ -14,8 +14,14 @@ import java.util.Locale
object BankTransferMandateRepository {
fun getMandate(paymentSourceType: PaymentSourceType.Stripe): Single<String> {
val sourceString = if (paymentSourceType == PaymentSourceType.Stripe.IDEAL) {
PaymentSourceType.Stripe.SEPADebit.paymentMethod
} else {
paymentSourceType.paymentMethod
}
return Single
.fromCallable { AppDependencies.donationsService.getBankMandate(Locale.getDefault(), paymentSourceType.paymentMethod) }
.fromCallable { AppDependencies.donationsService.getBankMandate(Locale.getDefault(), sourceString) }
.flatMap { it.flattenResult() }
.map { it.mandate }
.subscribeOn(Schedulers.io())

View File

@@ -85,6 +85,7 @@ class DonationErrorParams<V> private constructor(
InAppPaymentData.Error.Type.PAYMENT_PROCESSING -> getGenericRedemptionError(context, inAppPayment.type, callback)
InAppPaymentData.Error.Type.CREDENTIAL_VALIDATION -> getBadgeCredentialValidationErrorParams(context, callback)
InAppPaymentData.Error.Type.REDEMPTION -> getGenericRedemptionError(context, inAppPayment.type, callback)
InAppPaymentData.Error.Type.SETUP_CANCELLED -> error("Shouldn't show a dialog.")
null -> error("No error in data!")
}
}

View File

@@ -102,7 +102,12 @@ class InAppPaymentRecurringContextJob private constructor(
warning("A permanent failure occurred.")
val inAppPayment = SignalDatabase.inAppPayments.getById(inAppPaymentId)
if (inAppPayment != null && inAppPayment.data.error == null) {
val isRedeemed = inAppPayment?.state == InAppPaymentTable.State.END && inAppPayment.data.redemption?.stage != InAppPaymentData.RedemptionState.Stage.REDEEMED
if (isRedeemed) {
info("Already redeemed. Exiting quietly.")
return
} else if (inAppPayment != null && inAppPayment.data.error == null) {
warning("Unredeemed payment failed.")
SignalDatabase.inAppPayments.update(
inAppPayment.copy(
notified = false,

View File

@@ -204,7 +204,7 @@ abstract class InAppPaymentSetupJob(
}
}
private fun handleFailure(inAppPaymentId: InAppPaymentTable.InAppPaymentId, exception: Exception) {
protected fun handleFailure(inAppPaymentId: InAppPaymentTable.InAppPaymentId, exception: Exception) {
warning("Failed to process transaction.", exception)
val freshPayment = SignalDatabase.inAppPayments.getById(inAppPaymentId)!!

View File

@@ -12,6 +12,8 @@ import org.signal.donations.StripeIntentAccessor
import org.thoughtcrime.securesms.components.settings.app.subscription.DonationSerializationHelper.toFiatMoney
import org.thoughtcrime.securesms.components.settings.app.subscription.OneTimeInAppPaymentRepository
import org.thoughtcrime.securesms.components.settings.app.subscription.StripeRepository
import org.thoughtcrime.securesms.components.settings.app.subscription.errors.DonationError
import org.thoughtcrime.securesms.components.settings.app.subscription.errors.DonationErrorSource
import org.thoughtcrime.securesms.components.settings.app.subscription.toPaymentSource
import org.thoughtcrime.securesms.database.InAppPaymentTable
import org.thoughtcrime.securesms.jobmanager.Job
@@ -74,7 +76,13 @@ class InAppPaymentStripeOneTimeSetupJob private constructor(
)
info("Getting status and payment method id from stripe.")
StripeRepository.getStatusAndPaymentMethodId(intentAccessor, paymentMethodId)
val data = StripeRepository.getStatusAndPaymentMethodId(intentAccessor, paymentMethodId)
if (!data.status.canProceed()) {
warning("Cannot proceed with status ${data.status}.")
handleFailure(inAppPayment.id, DonationError.UserCancelledPaymentError(DonationErrorSource.ONE_TIME))
return Result.failure()
}
info("Received status and payment method id. Submitting redemption job chain.")
OneTimeInAppPaymentRepository.submitRedemptionJobChain(inAppPayment, intentAccessor.intentId)

View File

@@ -16,6 +16,8 @@ import org.thoughtcrime.securesms.components.settings.app.subscription.InAppPaym
import org.thoughtcrime.securesms.components.settings.app.subscription.InAppPaymentsRepository.toPaymentSourceType
import org.thoughtcrime.securesms.components.settings.app.subscription.RecurringInAppPaymentRepository
import org.thoughtcrime.securesms.components.settings.app.subscription.StripeRepository
import org.thoughtcrime.securesms.components.settings.app.subscription.errors.DonationError
import org.thoughtcrime.securesms.components.settings.app.subscription.errors.DonationErrorSource
import org.thoughtcrime.securesms.components.settings.app.subscription.toPaymentSource
import org.thoughtcrime.securesms.database.InAppPaymentTable
import org.thoughtcrime.securesms.jobmanager.Job
@@ -79,6 +81,12 @@ class InAppPaymentStripeRecurringSetupJob private constructor(
info("Requesting status and payment method id from stripe service.")
val statusAndPaymentMethodId = StripeRepository.getStatusAndPaymentMethodId(intentAccessor, paymentMethodId)
if (!statusAndPaymentMethodId.status.canProceed()) {
warning("Cannot proceed with status ${statusAndPaymentMethodId.status}.")
handleFailure(inAppPayment.id, DonationError.UserCancelledPaymentError(DonationErrorSource.ONE_TIME))
return Result.failure()
}
info("Setting default payment method.")
StripeRepository.setDefaultPaymentMethod(
paymentMethodId = statusAndPaymentMethodId.paymentMethod!!,