diff --git a/app/src/main/java/org/thoughtcrime/securesms/badges/gifts/flow/GiftFlowConfirmationFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/badges/gifts/flow/GiftFlowConfirmationFragment.kt index e70c37a79a..9deebba694 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/badges/gifts/flow/GiftFlowConfirmationFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/badges/gifts/flow/GiftFlowConfirmationFragment.kt @@ -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") } diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/InAppDonations.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/InAppDonations.kt index c0aaccc618..58af7d7203 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/InAppDonations.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/InAppDonations.kt @@ -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() + } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/PendingOneTimeDonationSerializer.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/PendingOneTimeDonationSerializer.kt index cdc7c7e959..7e8ab07b63 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/PendingOneTimeDonationSerializer.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/PendingOneTimeDonationSerializer.kt @@ -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() diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/StripeRepository.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/StripeRepository.kt index 56481fa1c8..6ce9b36186 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/StripeRepository.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/StripeRepository.kt @@ -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 { + fun getStatusAndPaymentMethodId(stripeIntentAccessor: StripeIntentAccessor, paymentSourceType: PaymentSourceType): Single { 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 { + Log.d(TAG, "Creating iDEAL payment source via Stripe api...") + return stripeApi.createPaymentSourceFromIDEALData(idealData) + } + data class StatusAndPaymentMethodId( val status: StripeIntentStatus, val paymentMethod: String? diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/DonateToSignalFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/DonateToSignalFragment.kt index b1a13b53b9..4f2db4a49a 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/DonateToSignalFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/DonateToSignalFragment.kt @@ -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) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/DonationCheckoutDelegate.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/DonationCheckoutDelegate.kt index 5b642d67d3..8c599705d3 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/DonationCheckoutDelegate.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/DonationCheckoutDelegate.kt @@ -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() } diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/gateway/GatewayOrderStrategy.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/gateway/GatewayOrderStrategy.kt index d1e37f2cf0..bf97e9e4d4 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/gateway/GatewayOrderStrategy.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/gateway/GatewayOrderStrategy.kt @@ -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 ) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/gateway/GatewayResponse.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/gateway/GatewayResponse.kt index 55b4e39d03..881a4ae4cd 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/gateway/GatewayResponse.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/gateway/GatewayResponse.kt @@ -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 } } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/gateway/GatewaySelectorBottomSheet.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/gateway/GatewaySelectorBottomSheet.kt index 0b83bfc3c8..b9b71cc549 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/gateway/GatewaySelectorBottomSheet.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/gateway/GatewaySelectorBottomSheet.kt @@ -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" diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/gateway/GatewaySelectorRepository.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/gateway/GatewaySelectorRepository.kt index d1415c0bfc..64eeece9f2 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/gateway/GatewaySelectorRepository.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/gateway/GatewaySelectorRepository.kt @@ -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() diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/gateway/GatewaySelectorState.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/gateway/GatewaySelectorState.kt index ea05aceaad..6e05c2a333 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/gateway/GatewaySelectorState.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/gateway/GatewaySelectorState.kt @@ -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 ) diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/gateway/GatewaySelectorViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/gateway/GatewaySelectorViewModel.kt index dc88aeba80..422b40be76 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/gateway/GatewaySelectorViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/gateway/GatewaySelectorViewModel.kt @@ -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) ) } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/stripe/StripePaymentInProgressViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/stripe/StripePaymentInProgressViewModel.kt index ace903856b..bdd971fbce 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/stripe/StripePaymentInProgressViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/stripe/StripePaymentInProgressViewModel.kt @@ -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( diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/transfer/BankTransferRequestKeys.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/transfer/BankTransferRequestKeys.kt new file mode 100644 index 0000000000..8338a1cb95 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/transfer/BankTransferRequestKeys.kt @@ -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" +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/transfer/details/BankTransferDetailsFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/transfer/details/BankTransferDetailsFragment.kt index 80357730df..6d80074883 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/transfer/details/BankTransferDetailsFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/transfer/details/BankTransferDetailsFragment.kt @@ -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() }, diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/transfer/ideal/IdealBank.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/transfer/ideal/IdealBank.kt new file mode 100644 index 0000000000..ceeb99ac31 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/transfer/ideal/IdealBank.kt @@ -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 by lazy { + EnumMap(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 + ) +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/transfer/ideal/IdealTransferDetailsBankSelectionDialogFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/transfer/ideal/IdealTransferDetailsBankSelectionDialogFragment.kt new file mode 100644 index 0000000000..e17cea02ed --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/transfer/ideal/IdealTransferDetailsBankSelectionDialogFragment.kt @@ -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) + ) + } + } + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/transfer/ideal/IdealTransferDetailsFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/transfer/ideal/IdealTransferDetailsFragment.kt new file mode 100644 index 0000000000..d04a28cb96 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/transfer/ideal/IdealTransferDetailsFragment.kt @@ -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().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 + ) + ) +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/transfer/ideal/IdealTransferDetailsState.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/transfer/ideal/IdealTransferDetailsState.kt new file mode 100644 index 0000000000..e56e4c4531 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/transfer/ideal/IdealTransferDetailsState.kt @@ -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() + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/transfer/ideal/IdealTransferDetailsViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/transfer/ideal/IdealTransferDetailsViewModel.kt new file mode 100644 index 0000000000..96c27cd477 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/transfer/ideal/IdealTransferDetailsViewModel.kt @@ -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 = 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 + ) + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/transfer/mandate/BankTransferMandateFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/transfer/mandate/BankTransferMandateFragment.kt index dfb7f4cdd0..f68c3d86e5 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/transfer/mandate/BankTransferMandateFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/transfer/mandate/BankTransferMandateFragment.kt @@ -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) + ) + } } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/errors/DonationErrorParams.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/errors/DonationErrorParams.kt index 0d21767290..2391f1c7a5 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/errors/DonationErrorParams.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/errors/DonationErrorParams.kt @@ -117,7 +117,7 @@ class DonationErrorParams private constructor( val getStripeDeclineCodePositiveActionParams: (Context, Callback, Int) -> DonationErrorParams = 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 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 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 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 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 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 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 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 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 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) } ) diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/manage/OneTimeDonationPreference.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/manage/OneTimeDonationPreference.kt index ce599627d0..1f1104bb11 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/manage/OneTimeDonationPreference.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/manage/OneTimeDonationPreference.kt @@ -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) } } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/DonationReceiptRedemptionJob.java b/app/src/main/java/org/thoughtcrime/securesms/jobs/DonationReceiptRedemptionJob.java index 9f096ac296..5447bd84d2 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/jobs/DonationReceiptRedemptionJob.java +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/DonationReceiptRedemptionJob.java @@ -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) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/FeatureFlags.java b/app/src/main/java/org/thoughtcrime/securesms/util/FeatureFlags.java index 6df76f3ef1..1fe9f2049b 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/util/FeatureFlags.java +++ b/app/src/main/java/org/thoughtcrime/securesms/util/FeatureFlags.java @@ -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 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 getMemoryValues() { return new TreeMap<>(REMOTE_VALUES); diff --git a/app/src/main/protowire/Database.proto b/app/src/main/protowire/Database.proto index b32c599e0a..532c038b01 100644 --- a/app/src/main/protowire/Database.proto +++ b/app/src/main/protowire/Database.proto @@ -290,6 +290,7 @@ message PendingOneTimeDonation { CARD = 0; SEPA_DEBIT = 1; PAYPAL = 2; + IDEAL = 3; } PaymentMethodType paymentMethodType = 1; diff --git a/app/src/main/res/navigation/donate_to_signal.xml b/app/src/main/res/navigation/donate_to_signal.xml index 213509859c..273f63557a 100644 --- a/app/src/main/res/navigation/donate_to_signal.xml +++ b/app/src/main/res/navigation/donate_to_signal.xml @@ -224,8 +224,8 @@ android:label="bank_transfer_mandate_fragment"> + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/navigation/gift_flow.xml b/app/src/main/res/navigation/gift_flow.xml index 3751c1e7d2..a0f8cbc3c4 100644 --- a/app/src/main/res/navigation/gift_flow.xml +++ b/app/src/main/res/navigation/gift_flow.xml @@ -62,12 +62,6 @@ - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 54a47af326..00a851f453 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -5843,6 +5843,36 @@ Credit or debit card Donate for a friend + + iDEAL + + + + ABN AMRO + + ASN Bank + + bunq + + ING + + Knab + + N26 + + Rabobank + + RegioBank + + Revolut + + SNS Bank + + Triodos Bank + + Van Lanchot Kempen + + Yoursafe @@ -5880,6 +5910,24 @@ Invalid IBAN number + + + iDEAL + + Enter your bank, name and email. Stripe uses this email to send you updates about your donation. %1$s + + Learn more + + Name on bank account + + Email + + Choose your bank + + + + Choose your bank + Find your account information diff --git a/donations/lib/src/main/java/org/signal/donations/IDEALPaymentSource.kt b/donations/lib/src/main/java/org/signal/donations/IDEALPaymentSource.kt new file mode 100644 index 0000000000..15596e0a76 --- /dev/null +++ b/donations/lib/src/main/java/org/signal/donations/IDEALPaymentSource.kt @@ -0,0 +1,18 @@ +/* + * Copyright 2023 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ +package org.signal.donations + +import org.json.JSONObject + +class IDEALPaymentSource( + val idealData: StripeApi.IDEALData +) : StripeApi.PaymentSource { + override val type: PaymentSourceType = PaymentSourceType.Stripe.IDEAL + + override fun parameterize(): JSONObject = error("iDEAL does not support tokenization") + + override fun getTokenId(): String = error("iDEAL does not support tokenization") + override fun email(): String? = null +} diff --git a/donations/lib/src/main/java/org/signal/donations/PaymentSourceType.kt b/donations/lib/src/main/java/org/signal/donations/PaymentSourceType.kt index 7ec2a968c3..f851b93b91 100644 --- a/donations/lib/src/main/java/org/signal/donations/PaymentSourceType.kt +++ b/donations/lib/src/main/java/org/signal/donations/PaymentSourceType.kt @@ -2,7 +2,7 @@ package org.signal.donations sealed class PaymentSourceType { abstract val code: String - open val isLongRunning: Boolean = false + open val isBankTransfer: Boolean = false object Unknown : PaymentSourceType() { override val code: String = Codes.UNKNOWN.code @@ -12,13 +12,34 @@ sealed class PaymentSourceType { override val code: String = Codes.PAY_PAL.code } - sealed class Stripe(override val code: String, val paymentMethod: String, override val isLongRunning: Boolean) : PaymentSourceType() { + sealed class Stripe( + override val code: String, + val paymentMethod: String, + override val isBankTransfer: Boolean + ) : PaymentSourceType() { + /** + * Credit card should happen instantaneously but can take up to 1 day to process. + */ object CreditCard : Stripe(Codes.CREDIT_CARD.code, "CARD", false) + + /** + * Google Pay should happen instantaneously but can take up to 1 day to process. + */ object GooglePay : Stripe(Codes.GOOGLE_PAY.code, "CARD", false) + + /** + * SEPA Debits can take up to 14 days to process. + */ object SEPADebit : Stripe(Codes.SEPA_DEBIT.code, "SEPA_DEBIT", true) - fun hasDeclineCodeSupport(): Boolean = this !is SEPADebit - fun hasFailureCodeSupport(): Boolean = this is SEPADebit + /** + * iDEAL Bank transfers happen instantaneously for 1:1 transactions, but do not do so for subscriptions, as Stripe + * will utilize SEPA under the hood. + */ + object IDEAL : Stripe(Codes.IDEAL.code, "IDEAL", true) + + fun hasDeclineCodeSupport(): Boolean = !this.isBankTransfer + fun hasFailureCodeSupport(): Boolean = this.isBankTransfer } private enum class Codes(val code: String) { @@ -26,7 +47,8 @@ sealed class PaymentSourceType { PAY_PAL("paypal"), CREDIT_CARD("credit_card"), GOOGLE_PAY("google_pay"), - SEPA_DEBIT("sepa_debit") + SEPA_DEBIT("sepa_debit"), + IDEAL("ideal") } companion object { @@ -37,6 +59,7 @@ sealed class PaymentSourceType { Codes.CREDIT_CARD -> Stripe.CreditCard Codes.GOOGLE_PAY -> Stripe.GooglePay Codes.SEPA_DEBIT -> Stripe.SEPADebit + Codes.IDEAL -> Stripe.IDEAL } } } diff --git a/donations/lib/src/main/java/org/signal/donations/StripeApi.kt b/donations/lib/src/main/java/org/signal/donations/StripeApi.kt index fe06bae03c..44a29f4a2e 100644 --- a/donations/lib/src/main/java/org/signal/donations/StripeApi.kt +++ b/donations/lib/src/main/java/org/signal/donations/StripeApi.kt @@ -77,7 +77,7 @@ class StripeApi( "return_url" to RETURN_URL_3DS ) - if (paymentSource.type == PaymentSourceType.Stripe.SEPADebit) { + if (paymentSource.type.isBankTransfer) { parameters["mandate_data[customer_acceptance][type]"] = "online" parameters["mandate_data[customer_acceptance][online][infer_from_client]"] = "true" } @@ -125,7 +125,7 @@ class StripeApi( "return_url" to RETURN_URL_3DS ) - if (paymentSource.type == PaymentSourceType.Stripe.SEPADebit) { + if (paymentSource.type.isBankTransfer) { parameters["mandate_data[customer_acceptance][type]"] = "online" parameters["mandate_data[customer_acceptance][online][infer_from_client]"] = "true" } @@ -143,7 +143,7 @@ class StripeApi( */ fun getSetupIntent(stripeIntentAccessor: StripeIntentAccessor): StripeSetupIntent { return when (stripeIntentAccessor.objectType) { - StripeIntentAccessor.ObjectType.SETUP_INTENT -> get("setup_intents/${stripeIntentAccessor.intentId}?client_secret=${stripeIntentAccessor.intentClientSecret}").use { + StripeIntentAccessor.ObjectType.SETUP_INTENT -> get("setup_intents/${stripeIntentAccessor.intentId}?client_secret=${stripeIntentAccessor.intentClientSecret}&expand[0]=latest_attempt").use { val body = it.body()?.string() try { objectMapper.readValue(body!!) @@ -168,6 +168,7 @@ class StripeApi( StripeIntentAccessor.ObjectType.PAYMENT_INTENT -> get("payment_intents/${stripeIntentAccessor.intentId}?client_secret=${stripeIntentAccessor.intentClientSecret}").use { val body = it.body()?.string() try { + Log.d(TAG, "Reading StripePaymentIntent from JSON") objectMapper.readValue(body!!) } catch (e: InvalidDefinitionException) { Log.w(TAG, "Failed to parse JSON for StripePaymentIntent.") @@ -213,6 +214,10 @@ class StripeApi( return Single.just(SEPADebitPaymentSource(sepaDebitData)) } + fun createPaymentSourceFromIDEALData(idealData: IDEALData): Single { + return Single.just(IDEALPaymentSource(idealData)) + } + @WorkerThread private fun createPaymentSourceFromCardDataSync(cardData: CardData): PaymentSource { val parameters: Map = mutableMapOf( @@ -233,10 +238,10 @@ class StripeApi( } private fun createPaymentMethodAndParseId(paymentSource: PaymentSource): String { - val paymentMethodResponse = if (paymentSource is SEPADebitPaymentSource) { - createPaymentMethodForSEPADebit(paymentSource) - } else { - createPaymentMethodForToken(paymentSource) + val paymentMethodResponse = when (paymentSource) { + is SEPADebitPaymentSource -> createPaymentMethodForSEPADebit(paymentSource) + is IDEALPaymentSource -> createPaymentMethodForIDEAL(paymentSource) + else -> createPaymentMethodForToken(paymentSource) } return paymentMethodResponse.use { response -> @@ -261,6 +266,17 @@ class StripeApi( return postForm("payment_methods", parameters) } + private fun createPaymentMethodForIDEAL(paymentSource: IDEALPaymentSource): Response { + val parameters = mutableMapOf( + "type" to "ideal", + "ideal[bank]" to paymentSource.idealData.bank, + "billing_details[email]" to paymentSource.idealData.email, + "billing_details[name]" to paymentSource.idealData.name + ) + + return postForm("payment_methods", parameters) + } + private fun createPaymentMethodForToken(paymentSource: PaymentSource): Response { val tokenId = paymentSource.getTokenId() val parameters = mutableMapOf( @@ -303,6 +319,7 @@ class StripeApi( return response } else { val body = response.body()?.string() + val errorCode = parseErrorCode(body) val declineCode = parseDeclineCode(body) ?: StripeDeclineCode.getFromCode(errorCode) val failureCode = parseFailureCode(body) ?: StripeFailureCode.getFromCode(errorCode) @@ -340,7 +357,7 @@ class StripeApi( return try { StripeDeclineCode.getFromCode(JSONObject(body).getJSONObject("error").getString("decline_code")) } catch (e: Exception) { - Log.d(TAG, "parseDeclineCode: Failed to parse decline code.", e, true) + Log.d(TAG, "parseDeclineCode: Failed to parse decline code.", null, true) null } } @@ -354,7 +371,7 @@ class StripeApi( return try { StripeFailureCode.getFromCode(JSONObject(body).getJSONObject("error").getString("failure_code")) } catch (e: Exception) { - Log.d(TAG, "parseFailureCode: Failed to parse failure code.", e, true) + Log.d(TAG, "parseFailureCode: Failed to parse failure code.", null, true) null } } @@ -588,6 +605,13 @@ class StripeApi( val email: String ) : Parcelable + @Parcelize + data class IDEALData( + val bank: String, + val name: String, + val email: String + ) : Parcelable + interface PaymentSource { val type: PaymentSourceType fun parameterize(): JSONObject diff --git a/donations/lib/src/main/java/org/signal/donations/json/StripeSetupIntent.kt b/donations/lib/src/main/java/org/signal/donations/json/StripeSetupIntent.kt index 799d360dcc..0047adcc08 100644 --- a/donations/lib/src/main/java/org/signal/donations/json/StripeSetupIntent.kt +++ b/donations/lib/src/main/java/org/signal/donations/json/StripeSetupIntent.kt @@ -15,5 +15,24 @@ data class StripeSetupIntent @JsonCreator constructor( @JsonProperty("client_secret") val clientSecret: String, @JsonProperty("status") val status: StripeIntentStatus, @JsonProperty("payment_method") val paymentMethod: String?, - @JsonProperty("customer") val customer: String? -) + @JsonProperty("customer") val customer: String?, + @JsonProperty("latest_attempt") val latestAttempt: LatestAttempt? +) { + + fun requireGeneratedSepaDebit(): String = latestAttempt!!.paymentMethodDetails!!.ideal!!.generatedSepaDebit!! + + @JsonIgnoreProperties + data class LatestAttempt @JsonCreator constructor( + @JsonProperty("payment_method_details") val paymentMethodDetails: PaymentMethodDetails? + ) + + @JsonIgnoreProperties + data class PaymentMethodDetails @JsonCreator constructor( + @JsonProperty("ideal") val ideal: Ideal? + ) + + @JsonIgnoreProperties + data class Ideal @JsonCreator constructor( + @JsonProperty("generated_sepa_debit") val generatedSepaDebit: String? + ) +} diff --git a/donations/lib/src/test/java/org/signal/donations/StripePaymentIntentTest.kt b/donations/lib/src/test/java/org/signal/donations/StripePaymentIntentTest.kt new file mode 100644 index 0000000000..40b052cb2a --- /dev/null +++ b/donations/lib/src/test/java/org/signal/donations/StripePaymentIntentTest.kt @@ -0,0 +1,75 @@ +package org.signal.donations + +import android.app.Application +import com.fasterxml.jackson.module.kotlin.jsonMapper +import com.fasterxml.jackson.module.kotlin.kotlinModule +import com.fasterxml.jackson.module.kotlin.readValue +import org.junit.Assert.assertEquals +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import org.signal.donations.json.StripeIntentStatus +import org.signal.donations.json.StripePaymentIntent + +@RunWith(RobolectricTestRunner::class) +@Config(application = Application::class, manifest = Config.NONE) +class StripePaymentIntentTest { + companion object { + private const val TEST_JSON = """ + { + "id": "pi_A", + "object": "payment_intent", + "amount": 1000, + "amount_details": { + "tip": {} + }, + "automatic_payment_methods": { + "allow_redirects": "always", + "enabled": true + }, + "canceled_at": null, + "cancellation_reason": null, + "capture_method": "automatic", + "client_secret": "pi_client_secret", + "confirmation_method": "automatic", + "created": 1697568512, + "currency": "eur", + "description": "Thank you for supporting Signal. Your contribution helps fuel the mission of developing open source privacy technology that protects free expression and enables secure global communication for millions around the world. If you’re a resident of the United States, please retain this receipt for your tax records. Signal Technology Foundation is a tax-exempt nonprofit organization in the United States under section 501c3 of the Internal Revenue Code. Our Federal Tax ID is 82-4506840.", + "last_payment_error": null, + "livemode": false, + "next_action": null, + "payment_method": "pm_A", + "payment_method_configuration_details": { + "id": "pmc_A", + "parent": null + }, + "payment_method_types": [ + "card", + "ideal", + "sepa_debit" + ], + "processing": null, + "receipt_email": null, + "setup_future_usage": null, + "shipping": null, + "source": null, + "status": "succeeded" + } + """ + } + + @Test + fun `Given TEST_DATA, when I readValue, then I expect properly set fields`() { + val mapper = jsonMapper { + addModule(kotlinModule()) + } + + val intent = mapper.readValue(TEST_JSON) + + assertEquals(intent.id, "pi_A") + assertEquals(intent.clientSecret, "pi_client_secret") + assertEquals(intent.paymentMethod, "pm_A") + assertEquals(intent.status, StripeIntentStatus.SUCCEEDED) + } +} diff --git a/libsignal-service/src/main/java/org/whispersystems/signalservice/internal/push/DonationsConfiguration.java b/libsignal-service/src/main/java/org/whispersystems/signalservice/internal/push/DonationsConfiguration.java index 2d5da71f16..0516631d4a 100644 --- a/libsignal-service/src/main/java/org/whispersystems/signalservice/internal/push/DonationsConfiguration.java +++ b/libsignal-service/src/main/java/org/whispersystems/signalservice/internal/push/DonationsConfiguration.java @@ -16,6 +16,11 @@ import java.util.Set; */ public class DonationsConfiguration { + public static final String PAYPAL = "PAYPAL"; + public static final String CARD = "CARD"; + public static final String SEPA_DEBIT = "SEPA_DEBIT"; + public static final String IDEAL = "IDEAL"; + public static final int BOOST_LEVEL = 1; public static final int GIFT_LEVEL = 100; public static final HashSet SUBSCRIPTION_LEVELS = new HashSet<>(Arrays.asList(500, 1000, 2000));