Implement beginnings of support for iDEAL payments.

This commit is contained in:
Alex Hart
2023-10-18 14:30:09 -04:00
committed by Cody Henthorne
parent 280da481ee
commit 5e1025453a
35 changed files with 1018 additions and 125 deletions

View File

@@ -26,6 +26,7 @@ import org.thoughtcrime.securesms.components.settings.app.subscription.donate.Do
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.DonationCheckoutDelegate
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.DonationProcessorAction
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.gateway.GatewayRequest
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.gateway.GatewayResponse
import org.thoughtcrime.securesms.components.settings.app.subscription.errors.DonationErrorSource
import org.thoughtcrime.securesms.components.settings.configure
import org.thoughtcrime.securesms.components.settings.conversation.preferences.RecipientPreference
@@ -263,8 +264,8 @@ class GiftFlowConfirmationFragment :
findNavController().safeNavigate(GiftFlowConfirmationFragmentDirections.actionGiftFlowConfirmationFragmentToCreditCardFragment(gatewayRequest))
}
override fun navigateToBankTransferMandate(gatewayRequest: GatewayRequest) {
findNavController().safeNavigate(GiftFlowConfirmationFragmentDirections.actionGiftFlowConfirmationFragmentToBankTransferMandateFragment(gatewayRequest))
override fun navigateToBankTransferMandate(gatewayResponse: GatewayResponse) {
error("Unsupported operation")
}
override fun onPaymentComplete(gatewayRequest: GatewayRequest) {
@@ -284,7 +285,5 @@ class GiftFlowConfirmationFragment :
findNavController().popBackStack(R.id.giftFlowConfirmationFragment, false)
}
override fun navigateToDonationPending(gatewayRequest: GatewayRequest) {
findNavController().safeNavigate(GiftFlowConfirmationFragmentDirections.actionGiftFlowConfirmationFragmentToDonationPendingBottomSheet(gatewayRequest))
}
override fun navigateToDonationPending(gatewayRequest: GatewayRequest) = error("Unsupported operation")
}

View File

@@ -20,7 +20,7 @@ object InAppDonations {
* - Able to use PayPal and is in a region where it is able to be accepted.
*/
fun hasAtLeastOnePaymentMethodAvailable(): Boolean {
return isCreditCardAvailable() || isPayPalAvailable() || isGooglePayAvailable() || isSEPADebitAvailable()
return isCreditCardAvailable() || isPayPalAvailable() || isGooglePayAvailable() || isSEPADebitAvailable() || isIDEALAvailable()
}
fun isPaymentSourceAvailable(paymentSourceType: PaymentSourceType, donateToSignalType: DonateToSignalType): Boolean {
@@ -29,6 +29,7 @@ object InAppDonations {
PaymentSourceType.Stripe.CreditCard -> isCreditCardAvailable()
PaymentSourceType.Stripe.GooglePay -> isGooglePayAvailable()
PaymentSourceType.Stripe.SEPADebit -> isSEPADebitAvailableForDonateToSignalType(donateToSignalType)
PaymentSourceType.Stripe.IDEAL -> isIDEALAvailbleForDonateToSignalType(donateToSignalType)
PaymentSourceType.Unknown -> false
}
}
@@ -68,6 +69,13 @@ object InAppDonations {
return FeatureFlags.sepaDebitDonations()
}
/**
* Whether the user is in a region which supports IDEAL transfers, based off local phone number.
*/
fun isIDEALAvailable(): Boolean {
return FeatureFlags.idealDonations()
}
/**
* Whether the user is in a region which supports SEPA Debit transfers, based off local phone number
* and donation type.
@@ -75,4 +83,12 @@ object InAppDonations {
fun isSEPADebitAvailableForDonateToSignalType(donateToSignalType: DonateToSignalType): Boolean {
return donateToSignalType != DonateToSignalType.GIFT && FeatureFlags.sepaDebitDonations()
}
/**
* Whether the user is in a region which suports IDEAL transfers, based off local phone number and
* donation type
*/
fun isIDEALAvailbleForDonateToSignalType(donateToSignalType: DonateToSignalType): Boolean {
return donateToSignalType != DonateToSignalType.GIFT && FeatureFlags.idealDonations()
}
}

View File

@@ -46,6 +46,7 @@ object PendingOneTimeDonationSerializer {
PaymentSourceType.PayPal -> PendingOneTimeDonation.PaymentMethodType.PAYPAL
PaymentSourceType.Stripe.CreditCard, PaymentSourceType.Stripe.GooglePay, PaymentSourceType.Unknown -> PendingOneTimeDonation.PaymentMethodType.CARD
PaymentSourceType.Stripe.SEPADebit -> PendingOneTimeDonation.PaymentMethodType.SEPA_DEBIT
PaymentSourceType.Stripe.IDEAL -> PendingOneTimeDonation.PaymentMethodType.IDEAL
},
amount = amount.toFiatValue(),
timestamp = System.currentTimeMillis()

View File

@@ -203,7 +203,7 @@ class StripeRepository(activity: Activity) : StripeApi.PaymentIntentFetcher, Str
* that we are successful and proceed as normal. If the payment didn't actually succeed, then we
* expect an error later in the chain to inform us of this.
*/
fun getStatusAndPaymentMethodId(stripeIntentAccessor: StripeIntentAccessor): Single<StatusAndPaymentMethodId> {
fun getStatusAndPaymentMethodId(stripeIntentAccessor: StripeIntentAccessor, paymentSourceType: PaymentSourceType): Single<StatusAndPaymentMethodId> {
return Single.fromCallable {
when (stripeIntentAccessor.objectType) {
StripeIntentAccessor.ObjectType.NONE -> StatusAndPaymentMethodId(StripeIntentStatus.SUCCEEDED, null)
@@ -215,7 +215,11 @@ class StripeRepository(activity: Activity) : StripeApi.PaymentIntentFetcher, Str
}
StripeIntentAccessor.ObjectType.SETUP_INTENT -> stripeApi.getSetupIntent(stripeIntentAccessor).let {
StatusAndPaymentMethodId(it.status, it.paymentMethod)
if (paymentSourceType == PaymentSourceType.Stripe.IDEAL) {
StatusAndPaymentMethodId(it.status, it.requireGeneratedSepaDebit())
} else {
StatusAndPaymentMethodId(it.status, it.paymentMethod)
}
}
}
}
@@ -257,6 +261,11 @@ class StripeRepository(activity: Activity) : StripeApi.PaymentIntentFetcher, Str
return stripeApi.createPaymentSourceFromSEPADebitData(sepaDebitData)
}
fun createIdealPaymentSource(idealData: StripeApi.IDEALData): Single<StripeApi.PaymentSource> {
Log.d(TAG, "Creating iDEAL payment source via Stripe api...")
return stripeApi.createPaymentSourceFromIDEALData(idealData)
}
data class StatusAndPaymentMethodId(
val status: StripeIntentStatus,
val paymentMethod: String?

View File

@@ -28,6 +28,7 @@ import org.thoughtcrime.securesms.components.settings.DSLSettingsFragment
import org.thoughtcrime.securesms.components.settings.DSLSettingsText
import org.thoughtcrime.securesms.components.settings.app.subscription.boost.Boost
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.gateway.GatewayRequest
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.gateway.GatewayResponse
import org.thoughtcrime.securesms.components.settings.app.subscription.errors.DonationErrorSource
import org.thoughtcrime.securesms.components.settings.app.subscription.models.CurrencySelection
import org.thoughtcrime.securesms.components.settings.app.subscription.models.NetworkFailure
@@ -417,8 +418,8 @@ class DonateToSignalFragment :
findNavController().safeNavigate(DonateToSignalFragmentDirections.actionDonateToSignalFragmentToCreditCardFragment(gatewayRequest))
}
override fun navigateToBankTransferMandate(gatewayRequest: GatewayRequest) {
findNavController().safeNavigate(DonateToSignalFragmentDirections.actionDonateToSignalFragmentToBankTransferMandateFragment(gatewayRequest))
override fun navigateToBankTransferMandate(gatewayResponse: GatewayResponse) {
findNavController().safeNavigate(DonateToSignalFragmentDirections.actionDonateToSignalFragmentToBankTransferMandateFragment(gatewayResponse))
}
override fun onPaymentComplete(gatewayRequest: GatewayRequest) {

View File

@@ -30,7 +30,7 @@ import org.thoughtcrime.securesms.components.settings.app.subscription.donate.ga
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.paypal.PayPalPaymentInProgressFragment
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.stripe.StripePaymentInProgressFragment
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.stripe.StripePaymentInProgressViewModel
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.transfer.details.BankTransferDetailsFragment
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.transfer.BankTransferRequestKeys
import org.thoughtcrime.securesms.components.settings.app.subscription.errors.DonationError
import org.thoughtcrime.securesms.components.settings.app.subscription.errors.DonationErrorDialogs
import org.thoughtcrime.securesms.components.settings.app.subscription.errors.DonationErrorParams
@@ -90,13 +90,13 @@ class DonationCheckoutDelegate(
handleDonationProcessorActionResult(result)
}
fragment.setFragmentResultListener(BankTransferDetailsFragment.REQUEST_KEY) { _, bundle ->
fragment.setFragmentResultListener(BankTransferRequestKeys.REQUEST_KEY) { _, bundle ->
val result: DonationProcessorActionResult = bundle.getParcelableCompat(StripePaymentInProgressFragment.REQUEST_KEY, DonationProcessorActionResult::class.java)!!
handleDonationProcessorActionResult(result)
}
fragment.setFragmentResultListener(BankTransferDetailsFragment.PENDING_KEY) { _, bundle ->
val request: GatewayRequest = bundle.getParcelableCompat(BankTransferDetailsFragment.PENDING_KEY, GatewayRequest::class.java)!!
fragment.setFragmentResultListener(BankTransferRequestKeys.PENDING_KEY) { _, bundle ->
val request: GatewayRequest = bundle.getParcelableCompat(BankTransferRequestKeys.PENDING_KEY, GatewayRequest::class.java)!!
callback.navigateToDonationPending(gatewayRequest = request)
}
@@ -112,7 +112,8 @@ class DonationCheckoutDelegate(
GatewayResponse.Gateway.GOOGLE_PAY -> launchGooglePay(gatewayResponse)
GatewayResponse.Gateway.PAYPAL -> launchPayPal(gatewayResponse)
GatewayResponse.Gateway.CREDIT_CARD -> launchCreditCard(gatewayResponse)
GatewayResponse.Gateway.SEPA_DEBIT -> launchSEPADebit(gatewayResponse)
GatewayResponse.Gateway.SEPA_DEBIT -> launchBankTransfer(gatewayResponse)
GatewayResponse.Gateway.IDEAL -> launchBankTransfer(gatewayResponse)
}
} else {
error("Unsupported combination! ${gatewayResponse.gateway} ${gatewayResponse.request.donateToSignalType}")
@@ -167,8 +168,8 @@ class DonationCheckoutDelegate(
callback.navigateToCreditCardForm(gatewayResponse.request)
}
private fun launchSEPADebit(gatewayResponse: GatewayResponse) {
callback.navigateToBankTransferMandate(gatewayResponse.request)
private fun launchBankTransfer(gatewayResponse: GatewayResponse) {
callback.navigateToBankTransferMandate(gatewayResponse)
}
private fun registerGooglePayCallback() {
@@ -325,7 +326,7 @@ class DonationCheckoutDelegate(
fun navigateToStripePaymentInProgress(gatewayRequest: GatewayRequest)
fun navigateToPayPalPaymentInProgress(gatewayRequest: GatewayRequest)
fun navigateToCreditCardForm(gatewayRequest: GatewayRequest)
fun navigateToBankTransferMandate(gatewayRequest: GatewayRequest)
fun navigateToBankTransferMandate(gatewayResponse: GatewayResponse)
fun onPaymentComplete(gatewayRequest: GatewayRequest)
fun onProcessorActionProcessed()
}

View File

@@ -18,7 +18,8 @@ sealed interface GatewayOrderStrategy {
GatewayResponse.Gateway.CREDIT_CARD,
GatewayResponse.Gateway.PAYPAL,
GatewayResponse.Gateway.GOOGLE_PAY,
GatewayResponse.Gateway.SEPA_DEBIT
GatewayResponse.Gateway.SEPA_DEBIT,
GatewayResponse.Gateway.IDEAL
)
}
@@ -28,7 +29,8 @@ sealed interface GatewayOrderStrategy {
GatewayResponse.Gateway.GOOGLE_PAY,
GatewayResponse.Gateway.PAYPAL,
GatewayResponse.Gateway.CREDIT_CARD,
GatewayResponse.Gateway.SEPA_DEBIT
GatewayResponse.Gateway.SEPA_DEBIT,
GatewayResponse.Gateway.IDEAL
)
}

View File

@@ -10,7 +10,8 @@ data class GatewayResponse(val gateway: Gateway, val request: GatewayRequest) :
GOOGLE_PAY,
PAYPAL,
CREDIT_CARD,
SEPA_DEBIT;
SEPA_DEBIT,
IDEAL;
fun toPaymentSourceType(): PaymentSourceType {
return when (this) {
@@ -18,6 +19,7 @@ data class GatewayResponse(val gateway: Gateway, val request: GatewayRequest) :
PAYPAL -> PaymentSourceType.PayPal
CREDIT_CARD -> PaymentSourceType.Stripe.CreditCard
SEPA_DEBIT -> PaymentSourceType.Stripe.SEPADebit
IDEAL -> PaymentSourceType.Stripe.IDEAL
}
}
}

View File

@@ -79,6 +79,7 @@ class GatewaySelectorBottomSheet : DSLSettingsBottomSheetFragment() {
GatewayResponse.Gateway.PAYPAL -> renderPayPalButton(state, isFirst)
GatewayResponse.Gateway.CREDIT_CARD -> renderCreditCardButton(state, isFirst)
GatewayResponse.Gateway.SEPA_DEBIT -> renderSEPADebitButton(state, isFirst)
GatewayResponse.Gateway.IDEAL -> renderIDEALButton(state, isFirst)
}
}
@@ -160,6 +161,25 @@ class GatewaySelectorBottomSheet : DSLSettingsBottomSheetFragment() {
}
}
private fun DSLConfiguration.renderIDEALButton(state: GatewaySelectorState, isFirstButton: Boolean) {
if (state.isIDEALAvailable) {
if (!isFirstButton) {
space(8.dp)
}
// TODO [sepa] -- Final assets and copy
tonalButton(
text = DSLSettingsText.from(R.string.GatewaySelectorBottomSheet__ideal),
icon = DSLSettingsIcon.from(R.drawable.bank_transfer),
onClick = {
findNavController().popBackStack()
val response = GatewayResponse(GatewayResponse.Gateway.IDEAL, args.request)
setFragmentResult(REQUEST_KEY, bundleOf(REQUEST_KEY to response))
}
)
}
}
companion object {
const val REQUEST_KEY = "payment_checkout_mode"

View File

@@ -3,6 +3,7 @@ package org.thoughtcrime.securesms.components.settings.app.subscription.donate.g
import io.reactivex.rxjava3.core.Single
import org.thoughtcrime.securesms.components.settings.app.subscription.getAvailablePaymentMethods
import org.whispersystems.signalservice.api.services.DonationsService
import org.whispersystems.signalservice.internal.push.DonationsConfiguration
import java.util.Locale
class GatewaySelectorRepository(
@@ -15,9 +16,10 @@ class GatewaySelectorRepository(
.map { configuration ->
configuration.getAvailablePaymentMethods(currencyCode).map {
when (it) {
"PAYPAL" -> listOf(GatewayResponse.Gateway.PAYPAL)
"CARD" -> listOf(GatewayResponse.Gateway.CREDIT_CARD, GatewayResponse.Gateway.GOOGLE_PAY)
"SEPA_DEBIT" -> listOf(GatewayResponse.Gateway.SEPA_DEBIT)
DonationsConfiguration.PAYPAL -> listOf(GatewayResponse.Gateway.PAYPAL)
DonationsConfiguration.CARD -> listOf(GatewayResponse.Gateway.CREDIT_CARD, GatewayResponse.Gateway.GOOGLE_PAY)
DonationsConfiguration.SEPA_DEBIT -> listOf(GatewayResponse.Gateway.SEPA_DEBIT)
DonationsConfiguration.IDEAL -> listOf(GatewayResponse.Gateway.IDEAL)
else -> listOf()
}
}.flatten().toSet()

View File

@@ -9,5 +9,6 @@ data class GatewaySelectorState(
val isGooglePayAvailable: Boolean = false,
val isPayPalAvailable: Boolean = false,
val isCreditCardAvailable: Boolean = false,
val isSEPADebitAvailable: Boolean = false
val isSEPADebitAvailable: Boolean = false,
val isIDEALAvailable: Boolean = false
)

View File

@@ -26,7 +26,8 @@ class GatewaySelectorViewModel(
isGooglePayAvailable = InAppDonations.isPaymentSourceAvailable(PaymentSourceType.Stripe.GooglePay, args.request.donateToSignalType),
isCreditCardAvailable = InAppDonations.isPaymentSourceAvailable(PaymentSourceType.Stripe.CreditCard, args.request.donateToSignalType),
isPayPalAvailable = InAppDonations.isPaymentSourceAvailable(PaymentSourceType.PayPal, args.request.donateToSignalType),
isSEPADebitAvailable = InAppDonations.isPaymentSourceAvailable(PaymentSourceType.Stripe.SEPADebit, args.request.donateToSignalType)
isSEPADebitAvailable = InAppDonations.isPaymentSourceAvailable(PaymentSourceType.Stripe.SEPADebit, args.request.donateToSignalType),
isIDEALAvailable = InAppDonations.isPaymentSourceAvailable(PaymentSourceType.Stripe.IDEAL, args.request.donateToSignalType)
)
)
private val disposables = CompositeDisposable()
@@ -44,7 +45,8 @@ class GatewaySelectorViewModel(
isCreditCardAvailable = it.isCreditCardAvailable && gatewaysAvailable.contains(GatewayResponse.Gateway.CREDIT_CARD),
isGooglePayAvailable = it.isGooglePayAvailable && googlePayAvailable && gatewaysAvailable.contains(GatewayResponse.Gateway.GOOGLE_PAY),
isPayPalAvailable = it.isPayPalAvailable && gatewaysAvailable.contains(GatewayResponse.Gateway.PAYPAL),
isSEPADebitAvailable = it.isSEPADebitAvailable && gatewaysAvailable.contains(GatewayResponse.Gateway.SEPA_DEBIT)
isSEPADebitAvailable = it.isSEPADebitAvailable && gatewaysAvailable.contains(GatewayResponse.Gateway.SEPA_DEBIT),
isIDEALAvailable = it.isIDEALAvailable && gatewaysAvailable.contains(GatewayResponse.Gateway.IDEAL)
)
}
}

View File

@@ -101,6 +101,10 @@ class StripePaymentInProgressViewModel(
PaymentSourceType.Stripe.SEPADebit,
stripeRepository.createSEPADebitPaymentSource(data.sepaDebitData).doAfterTerminate { clearPaymentInformation() }
)
is StripePaymentData.IDEAL -> PaymentSourceProvider(
PaymentSourceType.Stripe.IDEAL,
stripeRepository.createIdealPaymentSource(data.idealData).doAfterTerminate { clearPaymentInformation() }
)
else -> error("This should never happen.")
}
}
@@ -120,6 +124,11 @@ class StripePaymentInProgressViewModel(
this.stripePaymentData = StripePaymentData.SEPADebit(bankData)
}
fun provideIDEALData(bankData: StripeApi.IDEALData) {
requireNoPaymentInformation()
this.stripePaymentData = StripePaymentData.IDEAL(bankData)
}
private fun requireNoPaymentInformation() {
require(stripePaymentData == null)
}
@@ -135,7 +144,7 @@ class StripePaymentInProgressViewModel(
stripeRepository.createAndConfirmSetupIntent(it, paymentSourceProvider.paymentSourceType as PaymentSourceType.Stripe)
}
val setLevel: Completable = monthlyDonationRepository.setSubscriptionLevel(request, paymentSourceProvider.paymentSourceType.isLongRunning)
val setLevel: Completable = monthlyDonationRepository.setSubscriptionLevel(request, paymentSourceProvider.paymentSourceType.isBankTransfer)
Log.d(TAG, "Starting subscription payment pipeline...", true)
store.update { DonationProcessorStage.PAYMENT_PIPELINE }
@@ -145,7 +154,7 @@ class StripePaymentInProgressViewModel(
.andThen(createAndConfirmSetupIntent)
.flatMap { secure3DSAction ->
nextActionHandler(secure3DSAction)
.flatMap { secure3DSResult -> stripeRepository.getStatusAndPaymentMethodId(secure3DSResult) }
.flatMap { secure3DSResult -> stripeRepository.getStatusAndPaymentMethodId(secure3DSResult, paymentSourceProvider.paymentSourceType) }
.map { (_, paymentMethod) -> paymentMethod ?: secure3DSAction.paymentMethodId!! }
}
.flatMapCompletable { stripeRepository.setDefaultPaymentMethod(it, paymentSourceProvider.paymentSourceType) }
@@ -196,7 +205,7 @@ class StripePaymentInProgressViewModel(
disposables += intentAndSource.flatMapCompletable { (paymentIntent, paymentSource) ->
stripeRepository.confirmPayment(paymentSource, paymentIntent, request.recipientId)
.flatMap { nextActionHandler(it) }
.flatMap { stripeRepository.getStatusAndPaymentMethodId(it) }
.flatMap { stripeRepository.getStatusAndPaymentMethodId(it, paymentSourceProvider.paymentSourceType) }
.flatMapCompletable {
oneTimeDonationRepository.waitForOneTimeRedemption(
gatewayRequest = request,
@@ -275,6 +284,7 @@ class StripePaymentInProgressViewModel(
class GooglePay(val paymentData: PaymentData) : StripePaymentData
class CreditCard(val cardData: StripeApi.CardData) : StripePaymentData
class SEPADebit(val sepaDebitData: StripeApi.SEPADebitData) : StripePaymentData
class IDEAL(val idealData: StripeApi.IDEALData) : StripePaymentData
}
class Factory(

View File

@@ -0,0 +1,11 @@
/*
* Copyright 2023 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.components.settings.app.subscription.donate.transfer
object BankTransferRequestKeys {
const val REQUEST_KEY = "bank.transfer.result"
const val PENDING_KEY = "bank.transfer.pending"
}

View File

@@ -61,6 +61,7 @@ import org.thoughtcrime.securesms.components.settings.app.subscription.donate.Do
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.gateway.GatewayRequest
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.stripe.StripePaymentInProgressFragment
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.stripe.StripePaymentInProgressViewModel
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.transfer.BankTransferRequestKeys
import org.thoughtcrime.securesms.components.settings.app.subscription.errors.DonationErrorSource
import org.thoughtcrime.securesms.compose.ComposeFragment
import org.thoughtcrime.securesms.payments.FiatMoneyUtil
@@ -73,11 +74,6 @@ import org.thoughtcrime.securesms.util.navigation.safeNavigate
*/
class BankTransferDetailsFragment : ComposeFragment(), DonationCheckoutDelegate.ErrorHandlerCallback {
companion object {
const val REQUEST_KEY = "bank.transfer.result"
const val PENDING_KEY = "bank.transfer.pending"
}
private val args: BankTransferDetailsFragmentArgs by navArgs()
private val viewModel: BankTransferDetailsViewModel by viewModels()
@@ -103,7 +99,7 @@ class BankTransferDetailsFragment : ComposeFragment(), DonationCheckoutDelegate.
val result: DonationProcessorActionResult = bundle.getParcelableCompat(StripePaymentInProgressFragment.REQUEST_KEY, DonationProcessorActionResult::class.java)!!
if (result.status == DonationProcessorActionResult.Status.SUCCESS) {
findNavController().popBackStack(R.id.donateToSignalFragment, false)
setFragmentResult(REQUEST_KEY, bundle)
setFragmentResult(BankTransferRequestKeys.REQUEST_KEY, bundle)
}
}
}
@@ -166,7 +162,7 @@ class BankTransferDetailsFragment : ComposeFragment(), DonationCheckoutDelegate.
findNavController().popBackStack()
findNavController().popBackStack()
setFragmentResult(PENDING_KEY, bundleOf(PENDING_KEY to gatewayRequest))
setFragmentResult(BankTransferRequestKeys.PENDING_KEY, bundleOf(BankTransferRequestKeys.PENDING_KEY to gatewayRequest))
}
}
@@ -230,7 +226,7 @@ private fun BankTransferDetailsContent(
val fullString = stringResource(id = R.string.BankTransferDetailsFragment__enter_your_bank_details, learnMore)
Texts.LinkifiedText(
textWithUrlSpans = SpanUtil.urlSubsequence(fullString, learnMore, stringResource(id = R.string.donate_url)), // TODO [alex] -- final URL
textWithUrlSpans = SpanUtil.urlSubsequence(fullString, learnMore, stringResource(id = R.string.donate_url)), // TODO [sepa] -- final URL
onUrlClick = {
onLearnMoreClick()
},

View File

@@ -0,0 +1,108 @@
/*
* 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_LANCHOT("van_lanchot"),
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.ic_person_large // TODO [sepa] -- final icon
),
ASN_BANK to UIValues(
name = R.string.IdealBank__asn_bank,
icon = R.drawable.ic_person_large // TODO [sepa] -- final icon
),
BUNQ to UIValues(
name = R.string.IdealBank__bunq,
icon = R.drawable.ic_person_large // TODO [sepa] -- final icon
),
ING to UIValues(
name = R.string.IdealBank__ing,
icon = R.drawable.ic_person_large // TODO [sepa] -- final icon
),
KNAB to UIValues(
name = R.string.IdealBank__knab,
icon = R.drawable.ic_person_large // TODO [sepa] -- final icon
),
N26 to UIValues(
name = R.string.IdealBank__n26,
icon = R.drawable.ic_person_large // TODO [sepa] -- final icon
),
RABOBANK to UIValues(
name = R.string.IdealBank__rabobank,
icon = R.drawable.ic_person_large // TODO [sepa] -- final icon
),
REGIOBANK to UIValues(
name = R.string.IdealBank__regiobank,
icon = R.drawable.ic_person_large // TODO [sepa] -- final icon
),
REVOLUT to UIValues(
name = R.string.IdealBank__revolut,
icon = R.drawable.ic_person_large // TODO [sepa] -- final icon
),
SNS_BANK to UIValues(
name = R.string.IdealBank__sns_bank,
icon = R.drawable.ic_person_large // TODO [sepa] -- final icon
),
TRIODOS_BANK to UIValues(
name = R.string.IdealBank__triodos_bank,
icon = R.drawable.ic_person_large // TODO [sepa] -- final icon
),
VAN_LANCHOT to UIValues(
name = R.string.IdealBank__van_lanchot,
icon = R.drawable.ic_person_large // TODO [sepa] -- final icon
),
YOURSAFE to UIValues(
name = R.string.IdealBank__yoursafe,
icon = R.drawable.ic_person_large // TODO [sepa] -- final icon
)
)
)
}
}
fun fromCode(code: String): IdealBank {
return values().first { it.code == code }
}
}
data class UIValues(
@StringRes val name: Int,
@DrawableRes val icon: Int
)
}

View File

@@ -0,0 +1,113 @@
/*
* 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.Scaffolds
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.compose.ComposeDialogFragment
/**
* 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.values()) {
val uiValues = it.getUIValues()
Row(
verticalAlignment = CenterVertically,
modifier = Modifier
.clickable { onBankSelected(it) }
.fillMaxWidth()
.defaultMinSize(minHeight = 56.dp)
.padding(horizontal = dimensionResource(id = R.dimen.core_ui__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

@@ -0,0 +1,334 @@
/*
* 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 android.view.View
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
import androidx.compose.ui.Alignment.Companion.CenterHorizontally
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusDirection
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
import androidx.fragment.app.setFragmentResultListener
import androidx.fragment.app.viewModels
import androidx.navigation.fragment.findNavController
import androidx.navigation.fragment.navArgs
import androidx.navigation.navGraphViewModels
import org.signal.core.ui.Buttons
import org.signal.core.ui.Scaffolds
import org.signal.core.ui.Texts
import org.signal.core.util.getParcelableCompat
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.components.TemporaryScreenshotSecurity
import org.thoughtcrime.securesms.components.settings.app.subscription.DonationPaymentComponent
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.DonateToSignalType
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.DonationCheckoutDelegate
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.DonationProcessorAction
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.DonationProcessorActionResult
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.gateway.GatewayRequest
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.stripe.StripePaymentInProgressFragment
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.stripe.StripePaymentInProgressViewModel
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.transfer.BankTransferRequestKeys
import org.thoughtcrime.securesms.components.settings.app.subscription.errors.DonationErrorSource
import org.thoughtcrime.securesms.compose.ComposeFragment
import org.thoughtcrime.securesms.payments.FiatMoneyUtil
import org.thoughtcrime.securesms.util.SpanUtil
import org.thoughtcrime.securesms.util.fragments.requireListener
import org.thoughtcrime.securesms.util.navigation.safeNavigate
/**
* Fragment for inputting necessary bank transfer information for iDEAL donation
*/
class IdealTransferDetailsFragment : ComposeFragment(), DonationCheckoutDelegate.ErrorHandlerCallback {
private val args: IdealTransferDetailsFragmentArgs by navArgs()
private val viewModel: IdealTransferDetailsViewModel by viewModels()
private val stripePaymentViewModel: StripePaymentInProgressViewModel by navGraphViewModels(
R.id.donate_to_signal,
factoryProducer = {
StripePaymentInProgressViewModel.Factory(requireListener<DonationPaymentComponent>().stripeRepository)
}
)
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
TemporaryScreenshotSecurity.bindToViewLifecycleOwner(this)
val errorSource: DonationErrorSource = when (args.request.donateToSignalType) {
DonateToSignalType.ONE_TIME -> DonationErrorSource.ONE_TIME
DonateToSignalType.MONTHLY -> DonationErrorSource.MONTHLY
DonateToSignalType.GIFT -> DonationErrorSource.GIFT
}
DonationCheckoutDelegate.ErrorHandler().attach(this, this, args.request.uiSessionKey, errorSource)
setFragmentResultListener(StripePaymentInProgressFragment.REQUEST_KEY) { _, bundle ->
val result: DonationProcessorActionResult = bundle.getParcelableCompat(StripePaymentInProgressFragment.REQUEST_KEY, DonationProcessorActionResult::class.java)!!
if (result.status == DonationProcessorActionResult.Status.SUCCESS) {
findNavController().popBackStack(R.id.donateToSignalFragment, false)
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
override fun FragmentContent() {
val state by viewModel.state
val donateLabel = remember(args.request) {
if (args.request.donateToSignalType == DonateToSignalType.MONTHLY) {
getString(
R.string.BankTransferDetailsFragment__donate_s_month,
FiatMoneyUtil.format(resources, args.request.fiat, FiatMoneyUtil.formatOptions().trimZerosAfterDecimal())
)
} else {
getString(
R.string.BankTransferDetailsFragment__donate_s,
FiatMoneyUtil.format(resources, args.request.fiat)
)
}
}
IdealTransferDetailsContent(
state = state,
donateLabel = donateLabel,
onNavigationClick = { findNavController().popBackStack() },
onLearnMoreClick = { findNavController().navigate(IdealTransferDetailsFragmentDirections.actionBankTransferDetailsFragmentToYourInformationIsPrivateBottomSheet()) },
onSelectBankClick = { findNavController().navigate(IdealTransferDetailsFragmentDirections.actionIdealTransferDetailsFragmentToIdealTransferBankSelectionDialogFragment()) },
onNameChanged = viewModel::onNameChanged,
onEmailChanged = viewModel::onEmailChanged,
onDonateClick = this::onDonateClick
)
}
private fun onDonateClick() {
stripePaymentViewModel.provideIDEALData(viewModel.state.value.asIDEALData())
findNavController().safeNavigate(
IdealTransferDetailsFragmentDirections.actionBankTransferDetailsFragmentToStripePaymentInProgressFragment(
DonationProcessorAction.PROCESS_NEW_DONATION,
args.request
)
)
}
override fun onUserCancelledPaymentFlow() = Unit
override fun navigateToDonationPending(gatewayRequest: GatewayRequest) {
findNavController().popBackStack()
findNavController().popBackStack()
setFragmentResult(BankTransferRequestKeys.PENDING_KEY, bundleOf(BankTransferRequestKeys.PENDING_KEY to gatewayRequest))
}
}
@Preview
@Composable
private fun IdealTransferDetailsContentPreview() {
IdealTransferDetailsContent(
state = IdealTransferDetailsState(),
donateLabel = "Donate $5/month",
onNavigationClick = {},
onLearnMoreClick = {},
onSelectBankClick = {},
onNameChanged = {},
onEmailChanged = {},
onDonateClick = {}
)
}
@Composable
private fun IdealTransferDetailsContent(
state: IdealTransferDetailsState,
donateLabel: String,
onNavigationClick: () -> Unit,
onLearnMoreClick: () -> Unit,
onSelectBankClick: () -> Unit,
onNameChanged: (String) -> Unit,
onEmailChanged: (String) -> Unit,
onDonateClick: () -> Unit
) {
Scaffolds.Settings(
title = stringResource(id = R.string.GatewaySelectorBottomSheet__ideal),
onNavigationClick = onNavigationClick,
navigationIconPainter = painterResource(id = R.drawable.symbol_arrow_left_24)
) {
val focusManager = LocalFocusManager.current
Column(
horizontalAlignment = CenterHorizontally,
modifier = Modifier.padding(it)
) {
LazyColumn(
modifier = Modifier
.weight(1f)
.padding(horizontal = 24.dp)
) {
item {
val learnMore = stringResource(id = R.string.IdealTransferDetailsFragment__learn_more)
val fullString = stringResource(id = R.string.IdealTransferDetailsFragment__enter_your_bank, learnMore)
Texts.LinkifiedText(
textWithUrlSpans = SpanUtil.urlSubsequence(fullString, learnMore, stringResource(id = R.string.donate_url)), // TODO [sepa] -- final URL
onUrlClick = {
onLearnMoreClick()
},
style = MaterialTheme.typography.bodyMedium.copy(
color = MaterialTheme.colorScheme.onSurfaceVariant
),
modifier = Modifier.padding(vertical = 12.dp)
)
}
item {
IdealBankSelector(
idealBank = state.idealBank,
onSelectBankClick = onSelectBankClick,
modifier = Modifier
.fillMaxWidth()
.padding(bottom = 16.dp)
)
}
item {
TextField(
value = state.name,
onValueChange = onNameChanged,
label = {
Text(text = stringResource(id = R.string.IdealTransferDetailsFragment__name_on_bank_account))
},
keyboardOptions = KeyboardOptions(
capitalization = KeyboardCapitalization.Words,
imeAction = ImeAction.Next
),
keyboardActions = KeyboardActions(
onNext = { focusManager.moveFocus(FocusDirection.Down) }
),
modifier = Modifier
.fillMaxWidth()
.padding(bottom = 16.dp)
)
}
item {
TextField(
value = state.email,
onValueChange = onEmailChanged,
label = {
Text(text = stringResource(id = R.string.IdealTransferDetailsFragment__email))
},
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Email,
imeAction = ImeAction.Done
),
keyboardActions = KeyboardActions(
onDone = { onDonateClick() }
),
modifier = Modifier
.fillMaxWidth()
.padding(bottom = 16.dp)
)
}
}
Buttons.LargeTonal(
enabled = state.canProceed(),
onClick = onDonateClick,
modifier = Modifier
.defaultMinSize(minWidth = 220.dp)
.padding(bottom = 16.dp)
) {
Text(text = donateLabel)
}
}
}
}
@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,
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
),
modifier = modifier
.clickable(
onClick = onSelectBankClick,
role = Role.Button
)
)
}

View File

@@ -0,0 +1,26 @@
/*
* Copyright 2023 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.components.settings.app.subscription.donate.transfer.ideal
import org.signal.donations.StripeApi
data class IdealTransferDetailsState(
val idealBank: IdealBank? = null,
val name: String = "",
val email: String = ""
) {
fun asIDEALData(): StripeApi.IDEALData {
return StripeApi.IDEALData(
bank = idealBank!!.code,
name = name.trim(),
email = email.trim()
)
}
fun canProceed(): Boolean {
return idealBank != null && name.isNotBlank() && email.isNotBlank()
}
}

View File

@@ -0,0 +1,34 @@
/*
* 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.compose.runtime.State
import androidx.compose.runtime.mutableStateOf
import androidx.lifecycle.ViewModel
class IdealTransferDetailsViewModel : ViewModel() {
private val internalState = mutableStateOf(IdealTransferDetailsState())
var state: State<IdealTransferDetailsState> = internalState
fun onNameChanged(name: String) {
internalState.value = internalState.value.copy(
name = name
)
}
fun onEmailChanged(email: String) {
internalState.value = internalState.value.copy(
email = email
)
}
fun onBankSelected(idealBank: IdealBank) {
internalState.value = internalState.value.copy(
idealBank = idealBank
)
}
}

View File

@@ -44,6 +44,7 @@ import org.signal.core.ui.Scaffolds
import org.signal.core.ui.Texts
import org.signal.core.ui.theme.SignalTheme
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.gateway.GatewayResponse
import org.thoughtcrime.securesms.compose.ComposeFragment
import org.thoughtcrime.securesms.compose.StatusBarColorNestedScrollConnection
import org.thoughtcrime.securesms.util.SpanUtil
@@ -91,9 +92,15 @@ class BankTransferMandateFragment : ComposeFragment() {
}
private fun onContinueClick() {
findNavController().safeNavigate(
BankTransferMandateFragmentDirections.actionBankTransferMandateFragmentToBankTransferDetailsFragment(args.request)
)
if (args.response.gateway == GatewayResponse.Gateway.SEPA_DEBIT) {
findNavController().safeNavigate(
BankTransferMandateFragmentDirections.actionBankTransferMandateFragmentToBankTransferDetailsFragment(args.response.request)
)
} else {
findNavController().safeNavigate(
BankTransferMandateFragmentDirections.actionBankTransferMandateFragmentToIdealTransferDetailsFragment(args.response.request)
)
}
}
}

View File

@@ -117,7 +117,7 @@ class DonationErrorParams<V> private constructor(
val getStripeDeclineCodePositiveActionParams: (Context, Callback<V>, Int) -> DonationErrorParams<V> = when (declinedError.method) {
PaymentSourceType.Stripe.CreditCard -> this::getTryCreditCardAgainParams
PaymentSourceType.Stripe.GooglePay -> this::getGoToGooglePayParams
PaymentSourceType.Stripe.SEPADebit -> this::getLearnMoreParams
else -> this::getLearnMoreParams
}
return when (declinedError.declineCode) {
@@ -128,7 +128,7 @@ class DonationErrorParams<V> private constructor(
when (declinedError.method) {
PaymentSourceType.Stripe.CreditCard -> R.string.DeclineCode__verify_your_card_details_are_correct_and_try_again
PaymentSourceType.Stripe.GooglePay -> R.string.DeclineCode__verify_your_payment_method_is_up_to_date_in_google_pay_and_try_again
PaymentSourceType.Stripe.SEPADebit -> unexpectedDeclinedError(declinedError)
else -> unexpectedDeclinedError(declinedError)
}
)
@@ -138,7 +138,7 @@ class DonationErrorParams<V> private constructor(
when (declinedError.method) {
PaymentSourceType.Stripe.CreditCard -> R.string.DeclineCode__verify_your_card_details_are_correct_and_try_again_if_the_problem_continues
PaymentSourceType.Stripe.GooglePay -> R.string.DeclineCode__verify_your_payment_method_is_up_to_date_in_google_pay_and_try_again_if_the_problem
PaymentSourceType.Stripe.SEPADebit -> unexpectedDeclinedError(declinedError)
else -> unexpectedDeclinedError(declinedError)
}
)
@@ -149,7 +149,7 @@ class DonationErrorParams<V> private constructor(
when (declinedError.method) {
PaymentSourceType.Stripe.CreditCard -> R.string.DeclineCode__your_card_has_expired_verify_your_card_details
PaymentSourceType.Stripe.GooglePay -> R.string.DeclineCode__your_card_has_expired
PaymentSourceType.Stripe.SEPADebit -> unexpectedDeclinedError(declinedError)
else -> unexpectedDeclinedError(declinedError)
}
)
@@ -159,7 +159,7 @@ class DonationErrorParams<V> private constructor(
when (declinedError.method) {
PaymentSourceType.Stripe.CreditCard -> R.string.DeclineCode__your_card_number_is_incorrect_verify_your_card_details
PaymentSourceType.Stripe.GooglePay -> R.string.DeclineCode__your_card_number_is_incorrect
PaymentSourceType.Stripe.SEPADebit -> unexpectedDeclinedError(declinedError)
else -> unexpectedDeclinedError(declinedError)
}
)
@@ -169,7 +169,7 @@ class DonationErrorParams<V> private constructor(
when (declinedError.method) {
PaymentSourceType.Stripe.CreditCard -> R.string.DeclineCode__your_cards_cvc_number_is_incorrect_verify_your_card_details
PaymentSourceType.Stripe.GooglePay -> R.string.DeclineCode__your_cards_cvc_number_is_incorrect
PaymentSourceType.Stripe.SEPADebit -> unexpectedDeclinedError(declinedError)
else -> unexpectedDeclinedError(declinedError)
}
)
@@ -180,7 +180,7 @@ class DonationErrorParams<V> private constructor(
when (declinedError.method) {
PaymentSourceType.Stripe.CreditCard -> R.string.DeclineCode__your_cards_cvc_number_is_incorrect_verify_your_card_details
PaymentSourceType.Stripe.GooglePay -> R.string.DeclineCode__your_cards_cvc_number_is_incorrect
PaymentSourceType.Stripe.SEPADebit -> unexpectedDeclinedError(declinedError)
else -> unexpectedDeclinedError(declinedError)
}
)
@@ -190,7 +190,7 @@ class DonationErrorParams<V> private constructor(
when (declinedError.method) {
PaymentSourceType.Stripe.CreditCard -> R.string.DeclineCode__the_expiration_month_on_your_card_is_incorrect
PaymentSourceType.Stripe.GooglePay -> R.string.DeclineCode__the_expiration_month
PaymentSourceType.Stripe.SEPADebit -> unexpectedDeclinedError(declinedError)
else -> unexpectedDeclinedError(declinedError)
}
)
@@ -200,7 +200,7 @@ class DonationErrorParams<V> private constructor(
when (declinedError.method) {
PaymentSourceType.Stripe.CreditCard -> R.string.DeclineCode__the_expiration_year_on_your_card_is_incorrect
PaymentSourceType.Stripe.GooglePay -> R.string.DeclineCode__the_expiration_year
PaymentSourceType.Stripe.SEPADebit -> unexpectedDeclinedError(declinedError)
else -> unexpectedDeclinedError(declinedError)
}
)
@@ -210,7 +210,7 @@ class DonationErrorParams<V> private constructor(
when (declinedError.method) {
PaymentSourceType.Stripe.CreditCard -> R.string.DeclineCode__your_card_number_is_incorrect_verify_your_card_details
PaymentSourceType.Stripe.GooglePay -> R.string.DeclineCode__your_card_number_is_incorrect
PaymentSourceType.Stripe.SEPADebit -> unexpectedDeclinedError(declinedError)
else -> unexpectedDeclinedError(declinedError)
}
)

View File

@@ -69,6 +69,7 @@ object OneTimeDonationPreference {
PendingOneTimeDonation.PaymentMethodType.CARD -> context.getString(R.string.OneTimeDonationPreference__donation_processing)
PendingOneTimeDonation.PaymentMethodType.SEPA_DEBIT -> context.getString(R.string.OneTimeDonationPreference__donation_pending)
PendingOneTimeDonation.PaymentMethodType.PAYPAL -> context.getString(R.string.OneTimeDonationPreference__donation_processing)
PendingOneTimeDonation.PaymentMethodType.IDEAL -> context.getString(R.string.OneTimeDonationPreference__donation_pending)
}
}
}

View File

@@ -298,7 +298,7 @@ public class DonationReceiptRedemptionJob extends BaseJob {
}
private boolean isForOneTimeDonation() {
return Objects.equals(getParameters().getQueue(), ONE_TIME_QUEUE) && giftMessageId == NO_ID;
return Objects.requireNonNull(getParameters().getQueue()).startsWith(ONE_TIME_QUEUE) && giftMessageId == NO_ID;
}
private void enqueueDonationComplete(long receiptLevel) {

View File

@@ -118,6 +118,7 @@ public final class FeatureFlags {
private static final String CONVERSATION_ITEM_V2_TEXT = "android.conversationItemV2.text.4";
public static final String CRASH_PROMPT_CONFIG = "android.crashPromptConfig";
private static final String SEPA_DEBIT_DONATIONS = "android.sepa.debit.donations";
private static final String IDEAL_DONATIONS = "android.ideal.donations";
/**
* We will only store remote values for flags in this set. If you want a flag to be controllable
@@ -186,13 +187,14 @@ public final class FeatureFlags {
INSTANT_VIDEO_PLAYBACK,
CONVERSATION_ITEM_V2_TEXT,
CRASH_PROMPT_CONFIG,
BLOCK_SSE
BLOCK_SSE,
SEPA_DEBIT_DONATIONS,
IDEAL_DONATIONS
);
@VisibleForTesting
static final Set<String> NOT_REMOTE_CAPABLE = SetUtil.newHashSet(
PHONE_NUMBER_PRIVACY,
SEPA_DEBIT_DONATIONS
PHONE_NUMBER_PRIVACY
);
/**
@@ -690,6 +692,10 @@ public final class FeatureFlags {
return getBoolean(SEPA_DEBIT_DONATIONS, Environment.IS_STAGING);
}
public static boolean idealDonations() {
return getBoolean(IDEAL_DONATIONS, Environment.IS_STAGING);
}
/** Only for rendering debug info. */
public static synchronized @NonNull Map<String, Object> getMemoryValues() {
return new TreeMap<>(REMOTE_VALUES);