Implement underpinnings of SEPA debit transfer support for donations.
This commit is contained in:
parent
3dfd1c98ba
commit
15700b85cb
39 changed files with 1295 additions and 127 deletions
|
@ -83,7 +83,7 @@ class GiftFlowConfirmationFragment :
|
|||
|
||||
keyboardPagerViewModel.setOnlyPage(KeyboardPage.EMOJI)
|
||||
|
||||
donationCheckoutDelegate = DonationCheckoutDelegate(this, this, DonationErrorSource.GIFT)
|
||||
donationCheckoutDelegate = DonationCheckoutDelegate(this, this, viewModel.uiSessionKey, DonationErrorSource.GIFT)
|
||||
|
||||
processingDonationPaymentDialog = MaterialAlertDialogBuilder(requireContext())
|
||||
.setView(R.layout.processing_payment_dialog)
|
||||
|
@ -106,6 +106,7 @@ class GiftFlowConfirmationFragment :
|
|||
GiftFlowConfirmationFragmentDirections.actionGiftFlowConfirmationFragmentToGatewaySelectorBottomSheet(
|
||||
with(viewModel.snapshot) {
|
||||
GatewayRequest(
|
||||
uiSessionKey = viewModel.uiSessionKey,
|
||||
donateToSignalType = DonateToSignalType.GIFT,
|
||||
badge = giftBadge!!,
|
||||
label = getString(R.string.preferences__one_time),
|
||||
|
@ -262,6 +263,10 @@ class GiftFlowConfirmationFragment :
|
|||
findNavController().safeNavigate(GiftFlowConfirmationFragmentDirections.actionGiftFlowConfirmationFragmentToCreditCardFragment(gatewayRequest))
|
||||
}
|
||||
|
||||
override fun navigateToBankTransferMandate(gatewayRequest: GatewayRequest) {
|
||||
findNavController().safeNavigate(GiftFlowConfirmationFragmentDirections.actionGiftFlowConfirmationFragmentToBankTransferMandateFragment(gatewayRequest))
|
||||
}
|
||||
|
||||
override fun onPaymentComplete(gatewayRequest: GatewayRequest) {
|
||||
val mainActivityIntent = MainActivity.clearTop(requireContext())
|
||||
|
||||
|
|
|
@ -39,6 +39,7 @@ class GiftFlowViewModel(
|
|||
val state: Flowable<GiftFlowState> = store.stateFlowable
|
||||
val events: Observable<DonationEvent> = eventPublisher
|
||||
val snapshot: GiftFlowState get() = store.state
|
||||
val uiSessionKey: Long = System.currentTimeMillis()
|
||||
|
||||
init {
|
||||
refresh()
|
||||
|
|
|
@ -725,7 +725,7 @@ class InternalSettingsFragment : DSLSettingsFragment(R.string.preferences__inter
|
|||
}
|
||||
|
||||
private fun enqueueSubscriptionRedemption() {
|
||||
SubscriptionReceiptRequestResponseJob.createSubscriptionContinuationJobChain().enqueue()
|
||||
SubscriptionReceiptRequestResponseJob.createSubscriptionContinuationJobChain(-1L).enqueue()
|
||||
}
|
||||
|
||||
private fun enqueueSubscriptionKeepAlive() {
|
||||
|
|
|
@ -147,7 +147,7 @@ class MonthlyDonationRepository(private val donationsService: DonationsService)
|
|||
}
|
||||
}
|
||||
|
||||
fun setSubscriptionLevel(subscriptionLevel: String): Completable {
|
||||
fun setSubscriptionLevel(subscriptionLevel: String, uiSessionKey: Long): Completable {
|
||||
return getOrCreateLevelUpdateOperation(subscriptionLevel)
|
||||
.flatMapCompletable { levelUpdateOperation ->
|
||||
val subscriber = SignalStore.donationsValues().requireSubscriber()
|
||||
|
@ -186,7 +186,7 @@ class MonthlyDonationRepository(private val donationsService: DonationsService)
|
|||
val countDownLatch = CountDownLatch(1)
|
||||
var finalJobState: JobTracker.JobState? = null
|
||||
|
||||
SubscriptionReceiptRequestResponseJob.createSubscriptionContinuationJobChain().enqueue { _, jobState ->
|
||||
SubscriptionReceiptRequestResponseJob.createSubscriptionContinuationJobChain(uiSessionKey).enqueue { _, jobState ->
|
||||
if (jobState.isComplete) {
|
||||
finalJobState = jobState
|
||||
countDownLatch.countDown()
|
||||
|
|
|
@ -111,7 +111,8 @@ class OneTimeDonationRepository(private val donationsService: DonationsService)
|
|||
badgeRecipient: RecipientId,
|
||||
additionalMessage: String?,
|
||||
badgeLevel: Long,
|
||||
donationProcessor: DonationProcessor
|
||||
donationProcessor: DonationProcessor,
|
||||
uiSessionKey: Long
|
||||
): Completable {
|
||||
val isBoost = badgeRecipient == Recipient.self().id
|
||||
val donationErrorSource: DonationErrorSource = if (isBoost) DonationErrorSource.BOOST else DonationErrorSource.GIFT
|
||||
|
@ -131,9 +132,9 @@ class OneTimeDonationRepository(private val donationsService: DonationsService)
|
|||
val countDownLatch = CountDownLatch(1)
|
||||
var finalJobState: JobTracker.JobState? = null
|
||||
val chain = if (isBoost) {
|
||||
BoostReceiptRequestResponseJob.createJobChainForBoost(paymentIntentId, donationProcessor)
|
||||
BoostReceiptRequestResponseJob.createJobChainForBoost(paymentIntentId, donationProcessor, uiSessionKey)
|
||||
} else {
|
||||
BoostReceiptRequestResponseJob.createJobChainForGift(paymentIntentId, badgeRecipient, additionalMessage, badgeLevel, donationProcessor)
|
||||
BoostReceiptRequestResponseJob.createJobChainForGift(paymentIntentId, badgeRecipient, additionalMessage, badgeLevel, donationProcessor, uiSessionKey)
|
||||
}
|
||||
|
||||
chain.enqueue { _, jobState ->
|
||||
|
|
|
@ -18,6 +18,7 @@ import org.thoughtcrime.securesms.components.settings.app.subscription.errors.Do
|
|||
import org.thoughtcrime.securesms.database.SignalDatabase
|
||||
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
import org.thoughtcrime.securesms.net.StandardUserAgentInterceptor
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId
|
||||
import org.thoughtcrime.securesms.storage.StorageSyncHelper
|
||||
|
@ -46,7 +47,7 @@ import org.whispersystems.signalservice.internal.ServiceResponse
|
|||
class StripeRepository(activity: Activity) : StripeApi.PaymentIntentFetcher, StripeApi.SetupIntentHelper {
|
||||
|
||||
private val googlePayApi = GooglePayApi(activity, StripeApi.Gateway(Environment.Donations.STRIPE_CONFIGURATION), Environment.Donations.GOOGLE_PAY_CONFIGURATION)
|
||||
private val stripeApi = StripeApi(Environment.Donations.STRIPE_CONFIGURATION, this, this, ApplicationDependencies.getOkHttpClient())
|
||||
private val stripeApi = StripeApi(Environment.Donations.STRIPE_CONFIGURATION, this, this, ApplicationDependencies.getOkHttpClient(), StandardUserAgentInterceptor.USER_AGENT)
|
||||
private val monthlyDonationRepository = MonthlyDonationRepository(ApplicationDependencies.getDonationsService())
|
||||
|
||||
fun isGooglePayAvailable(): Completable {
|
||||
|
@ -251,6 +252,11 @@ class StripeRepository(activity: Activity) : StripeApi.PaymentIntentFetcher, Str
|
|||
}
|
||||
}
|
||||
|
||||
fun createSEPADebitPaymentSource(sepaDebitData: StripeApi.SEPADebitData): Single<StripeApi.PaymentSource> {
|
||||
Log.d(TAG, "Creating SEPA Debit payment source via Stripe api...")
|
||||
return stripeApi.createPaymentSourceFromSEPADebitData(sepaDebitData)
|
||||
}
|
||||
|
||||
data class StatusAndPaymentMethodId(
|
||||
val status: StripeIntentStatus,
|
||||
val paymentMethod: String?
|
||||
|
|
|
@ -106,7 +106,7 @@ class DonateToSignalFragment :
|
|||
}
|
||||
|
||||
override fun bindAdapter(adapter: MappingAdapter) {
|
||||
donationCheckoutDelegate = DonationCheckoutDelegate(this, this, DonationErrorSource.BOOST, DonationErrorSource.SUBSCRIPTION)
|
||||
donationCheckoutDelegate = DonationCheckoutDelegate(this, this, viewModel.uiSessionKey, DonationErrorSource.BOOST, DonationErrorSource.SUBSCRIPTION)
|
||||
|
||||
val recyclerView = this.recyclerView!!
|
||||
recyclerView.overScrollMode = RecyclerView.OVER_SCROLL_IF_CONTENT_SCROLLS
|
||||
|
@ -417,6 +417,10 @@ class DonateToSignalFragment :
|
|||
findNavController().safeNavigate(DonateToSignalFragmentDirections.actionDonateToSignalFragmentToCreditCardFragment(gatewayRequest))
|
||||
}
|
||||
|
||||
override fun navigateToBankTransferMandate(gatewayRequest: GatewayRequest) {
|
||||
findNavController().safeNavigate(DonateToSignalFragmentDirections.actionDonateToSignalFragmentToBankTransferMandateFragment(gatewayRequest))
|
||||
}
|
||||
|
||||
override fun onPaymentComplete(gatewayRequest: GatewayRequest) {
|
||||
findNavController().safeNavigate(DonateToSignalFragmentDirections.actionDonateToSignalFragmentToThanksForYourSupportBottomSheetDialog(gatewayRequest.badge))
|
||||
}
|
||||
|
|
|
@ -58,6 +58,7 @@ class DonateToSignalViewModel(
|
|||
|
||||
val state = store.stateFlowable.observeOn(AndroidSchedulers.mainThread())
|
||||
val actions: Observable<DonateToSignalAction> = _actions.observeOn(AndroidSchedulers.mainThread())
|
||||
val uiSessionKey: Long = System.currentTimeMillis()
|
||||
|
||||
init {
|
||||
initializeOneTimeDonationState(oneTimeDonationRepository)
|
||||
|
@ -178,6 +179,7 @@ class DonateToSignalViewModel(
|
|||
private fun createGatewayRequest(snapshot: DonateToSignalState): GatewayRequest {
|
||||
val amount = getAmount(snapshot)
|
||||
return GatewayRequest(
|
||||
uiSessionKey = uiSessionKey,
|
||||
donateToSignalType = snapshot.donateToSignalType,
|
||||
badge = snapshot.badge!!,
|
||||
label = snapshot.badge!!.description,
|
||||
|
|
|
@ -43,6 +43,7 @@ import java.util.Currency
|
|||
class DonationCheckoutDelegate(
|
||||
private val fragment: Fragment,
|
||||
private val callback: Callback,
|
||||
private val uiSessionKey: Long,
|
||||
errorSource: DonationErrorSource,
|
||||
vararg additionalSources: DonationErrorSource
|
||||
) : DefaultLifecycleObserver {
|
||||
|
@ -65,7 +66,7 @@ class DonationCheckoutDelegate(
|
|||
|
||||
init {
|
||||
fragment.viewLifecycleOwner.lifecycle.addObserver(this)
|
||||
ErrorHandler().attach(fragment, callback, errorSource, *additionalSources)
|
||||
ErrorHandler().attach(fragment, callback, uiSessionKey, errorSource, *additionalSources)
|
||||
}
|
||||
|
||||
override fun onCreate(owner: LifecycleOwner) {
|
||||
|
@ -100,6 +101,7 @@ 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)
|
||||
}
|
||||
} else {
|
||||
error("Unsupported combination! ${gatewayResponse.gateway} ${gatewayResponse.request.donateToSignalType}")
|
||||
|
@ -154,6 +156,10 @@ class DonationCheckoutDelegate(
|
|||
callback.navigateToCreditCardForm(gatewayResponse.request)
|
||||
}
|
||||
|
||||
private fun launchSEPADebit(gatewayResponse: GatewayResponse) {
|
||||
callback.navigateToBankTransferMandate(gatewayResponse.request)
|
||||
}
|
||||
|
||||
private fun registerGooglePayCallback() {
|
||||
disposables += donationPaymentComponent.googlePayResultPublisher.subscribeBy(
|
||||
onNext = { paymentResult ->
|
||||
|
@ -206,7 +212,7 @@ class DonationCheckoutDelegate(
|
|||
private var errorDialog: DialogInterface? = null
|
||||
private var userCancelledFlowCallback: UserCancelledFlowCallback? = null
|
||||
|
||||
fun attach(fragment: Fragment, userCancelledFlowCallback: UserCancelledFlowCallback?, errorSource: DonationErrorSource, vararg additionalSources: DonationErrorSource) {
|
||||
fun attach(fragment: Fragment, userCancelledFlowCallback: UserCancelledFlowCallback?, uiSessionKey: Long, errorSource: DonationErrorSource, vararg additionalSources: DonationErrorSource) {
|
||||
this.fragment = fragment
|
||||
this.userCancelledFlowCallback = userCancelledFlowCallback
|
||||
|
||||
|
@ -218,6 +224,8 @@ class DonationCheckoutDelegate(
|
|||
additionalSources.forEach { source ->
|
||||
disposables += registerErrorSource(source)
|
||||
}
|
||||
|
||||
disposables += registerUiSession(uiSessionKey)
|
||||
}
|
||||
|
||||
override fun onDestroy(owner: LifecycleOwner) {
|
||||
|
@ -234,6 +242,14 @@ class DonationCheckoutDelegate(
|
|||
}
|
||||
}
|
||||
|
||||
private fun registerUiSession(uiSessionKey: Long): Disposable {
|
||||
return DonationError.getErrorsForUiSessionKey(uiSessionKey)
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
.subscribe {
|
||||
showErrorDialog(it)
|
||||
}
|
||||
}
|
||||
|
||||
private fun showErrorDialog(throwable: Throwable) {
|
||||
if (errorDialog != null) {
|
||||
Log.d(TAG, "Already displaying an error dialog. Skipping.", throwable, true)
|
||||
|
@ -281,6 +297,7 @@ class DonationCheckoutDelegate(
|
|||
fun navigateToStripePaymentInProgress(gatewayRequest: GatewayRequest)
|
||||
fun navigateToPayPalPaymentInProgress(gatewayRequest: GatewayRequest)
|
||||
fun navigateToCreditCardForm(gatewayRequest: GatewayRequest)
|
||||
fun navigateToBankTransferMandate(gatewayRequest: GatewayRequest)
|
||||
fun onPaymentComplete(gatewayRequest: GatewayRequest)
|
||||
fun onProcessorActionProcessed()
|
||||
}
|
||||
|
|
|
@ -54,7 +54,7 @@ class CreditCardFragment : Fragment(R.layout.credit_card_fragment) {
|
|||
DonateToSignalType.GIFT -> DonationErrorSource.GIFT
|
||||
}
|
||||
|
||||
DonationCheckoutDelegate.ErrorHandler().attach(this, null, errorSource)
|
||||
DonationCheckoutDelegate.ErrorHandler().attach(this, null, args.request.uiSessionKey, errorSource)
|
||||
|
||||
setFragmentResultListener(StripePaymentInProgressFragment.REQUEST_KEY) { _, bundle ->
|
||||
val result: DonationProcessorActionResult = bundle.getParcelableCompat(StripePaymentInProgressFragment.REQUEST_KEY, DonationProcessorActionResult::class.java)!!
|
||||
|
|
|
@ -12,6 +12,7 @@ import java.util.Currency
|
|||
|
||||
@Parcelize
|
||||
data class GatewayRequest(
|
||||
val uiSessionKey: Long,
|
||||
val donateToSignalType: DonateToSignalType,
|
||||
val badge: Badge,
|
||||
val label: String,
|
||||
|
|
|
@ -9,13 +9,15 @@ data class GatewayResponse(val gateway: Gateway, val request: GatewayRequest) :
|
|||
enum class Gateway {
|
||||
GOOGLE_PAY,
|
||||
PAYPAL,
|
||||
CREDIT_CARD;
|
||||
CREDIT_CARD,
|
||||
SEPA_DEBIT;
|
||||
|
||||
fun toPaymentSourceType(): PaymentSourceType {
|
||||
return when (this) {
|
||||
GOOGLE_PAY -> PaymentSourceType.Stripe.GooglePay
|
||||
PAYPAL -> PaymentSourceType.PayPal
|
||||
CREDIT_CARD -> PaymentSourceType.Stripe.CreditCard
|
||||
SEPA_DEBIT -> PaymentSourceType.Stripe.SEPADebit
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -115,6 +115,20 @@ class GatewaySelectorBottomSheet : DSLSettingsBottomSheetFragment() {
|
|||
)
|
||||
}
|
||||
|
||||
if (state.isSEPADebitAvailable) {
|
||||
space(8.dp)
|
||||
|
||||
primaryButton(
|
||||
text = DSLSettingsText.from(R.string.GatewaySelectorBottomSheet__bank_transfer),
|
||||
icon = DSLSettingsIcon.from(R.drawable.credit_card, NO_TINT), // TODO [sepa] -- Final icon
|
||||
onClick = {
|
||||
findNavController().popBackStack()
|
||||
val response = GatewayResponse(GatewayResponse.Gateway.SEPA_DEBIT, args.request)
|
||||
setFragmentResult(REQUEST_KEY, bundleOf(REQUEST_KEY to response))
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
space(16.dp)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -17,6 +17,7 @@ class GatewaySelectorRepository(
|
|||
when (it) {
|
||||
"PAYPAL" -> listOf(GatewayResponse.Gateway.PAYPAL)
|
||||
"CARD" -> listOf(GatewayResponse.Gateway.CREDIT_CARD, GatewayResponse.Gateway.GOOGLE_PAY)
|
||||
"SEPA_DEBIT" -> listOf(GatewayResponse.Gateway.SEPA_DEBIT)
|
||||
else -> listOf()
|
||||
}
|
||||
}.flatten().toSet()
|
||||
|
|
|
@ -7,5 +7,6 @@ data class GatewaySelectorState(
|
|||
val badge: Badge,
|
||||
val isGooglePayAvailable: Boolean = false,
|
||||
val isPayPalAvailable: Boolean = false,
|
||||
val isCreditCardAvailable: Boolean = false
|
||||
val isCreditCardAvailable: Boolean = false,
|
||||
val isSEPADebitAvailable: Boolean = false
|
||||
)
|
||||
|
|
|
@ -24,7 +24,8 @@ class GatewaySelectorViewModel(
|
|||
badge = args.request.badge,
|
||||
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)
|
||||
isPayPalAvailable = InAppDonations.isPaymentSourceAvailable(PaymentSourceType.PayPal, args.request.donateToSignalType),
|
||||
isSEPADebitAvailable = InAppDonations.isPaymentSourceAvailable(PaymentSourceType.Stripe.SEPADebit, args.request.donateToSignalType)
|
||||
)
|
||||
)
|
||||
private val disposables = CompositeDisposable()
|
||||
|
@ -41,7 +42,8 @@ class GatewaySelectorViewModel(
|
|||
loading = false,
|
||||
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)
|
||||
isPayPalAvailable = it.isPayPalAvailable && gatewaysAvailable.contains(GatewayResponse.Gateway.PAYPAL),
|
||||
isSEPADebitAvailable = it.isSEPADebitAvailable && gatewaysAvailable.contains(GatewayResponse.Gateway.SEPA_DEBIT)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -43,7 +43,6 @@ class PayPalPaymentInProgressViewModel(
|
|||
val state: Flowable<DonationProcessorStage> = store.stateFlowable.observeOn(AndroidSchedulers.mainThread())
|
||||
|
||||
private val disposables = CompositeDisposable()
|
||||
|
||||
override fun onCleared() {
|
||||
store.dispose()
|
||||
disposables.clear()
|
||||
|
@ -82,7 +81,7 @@ class PayPalPaymentInProgressViewModel(
|
|||
Log.d(TAG, "Beginning subscription update...", true)
|
||||
|
||||
store.update { DonationProcessorStage.PAYMENT_PIPELINE }
|
||||
disposables += monthlyDonationRepository.cancelActiveSubscriptionIfNecessary().andThen(monthlyDonationRepository.setSubscriptionLevel(request.level.toString()))
|
||||
disposables += monthlyDonationRepository.cancelActiveSubscriptionIfNecessary().andThen(monthlyDonationRepository.setSubscriptionLevel(request.level.toString(), request.uiSessionKey))
|
||||
.subscribeBy(
|
||||
onComplete = {
|
||||
Log.w(TAG, "Completed subscription update", true)
|
||||
|
@ -157,7 +156,8 @@ class PayPalPaymentInProgressViewModel(
|
|||
badgeRecipient = request.recipientId,
|
||||
additionalMessage = request.additionalMessage,
|
||||
badgeLevel = request.level,
|
||||
donationProcessor = DonationProcessor.PAYPAL
|
||||
donationProcessor = DonationProcessor.PAYPAL,
|
||||
uiSessionKey = request.uiSessionKey
|
||||
)
|
||||
}
|
||||
.subscribeOn(Schedulers.io())
|
||||
|
@ -190,7 +190,7 @@ class PayPalPaymentInProgressViewModel(
|
|||
.flatMapCompletable { payPalRepository.setDefaultPaymentMethod(it.paymentId) }
|
||||
.onErrorResumeNext { Completable.error(DonationError.getPaymentSetupError(DonationErrorSource.SUBSCRIPTION, it, PaymentSourceType.PayPal)) }
|
||||
|
||||
disposables += setup.andThen(monthlyDonationRepository.setSubscriptionLevel(request.level.toString()))
|
||||
disposables += setup.andThen(monthlyDonationRepository.setSubscriptionLevel(request.level.toString(), request.uiSessionKey))
|
||||
.subscribeBy(
|
||||
onError = { throwable ->
|
||||
Log.w(TAG, "Failure in monthly payment pipeline...", throwable, true)
|
||||
|
|
|
@ -44,8 +44,7 @@ class StripePaymentInProgressViewModel(
|
|||
val state: Flowable<DonationProcessorStage> = store.stateFlowable.observeOn(AndroidSchedulers.mainThread())
|
||||
|
||||
private val disposables = CompositeDisposable()
|
||||
private var paymentData: PaymentData? = null
|
||||
private var cardData: StripeApi.CardData? = null
|
||||
private var stripePaymentData: StripePaymentData? = null
|
||||
|
||||
override fun onCleared() {
|
||||
disposables.clear()
|
||||
|
@ -87,19 +86,18 @@ class StripePaymentInProgressViewModel(
|
|||
}
|
||||
|
||||
private fun resolvePaymentSourceProvider(errorSource: DonationErrorSource): PaymentSourceProvider {
|
||||
val paymentData = this.paymentData
|
||||
val cardData = this.cardData
|
||||
|
||||
return when {
|
||||
paymentData == null && cardData == null -> error("No payment provider available.")
|
||||
paymentData != null && cardData != null -> error("Too many providers available")
|
||||
paymentData != null -> PaymentSourceProvider(
|
||||
return when (val data = stripePaymentData) {
|
||||
is StripePaymentData.GooglePay -> PaymentSourceProvider(
|
||||
PaymentSourceType.Stripe.GooglePay,
|
||||
Single.just<StripeApi.PaymentSource>(GooglePayPaymentSource(paymentData)).doAfterTerminate { clearPaymentInformation() }
|
||||
Single.just<StripeApi.PaymentSource>(GooglePayPaymentSource(data.paymentData)).doAfterTerminate { clearPaymentInformation() }
|
||||
)
|
||||
cardData != null -> PaymentSourceProvider(
|
||||
is StripePaymentData.CreditCard -> PaymentSourceProvider(
|
||||
PaymentSourceType.Stripe.CreditCard,
|
||||
stripeRepository.createCreditCardPaymentSource(errorSource, cardData).doAfterTerminate { clearPaymentInformation() }
|
||||
stripeRepository.createCreditCardPaymentSource(errorSource, data.cardData).doAfterTerminate { clearPaymentInformation() }
|
||||
)
|
||||
is StripePaymentData.SEPADebit -> PaymentSourceProvider(
|
||||
PaymentSourceType.Stripe.SEPADebit,
|
||||
stripeRepository.createSEPADebitPaymentSource(data.sepaDebitData).doAfterTerminate { clearPaymentInformation() }
|
||||
)
|
||||
else -> error("This should never happen.")
|
||||
}
|
||||
|
@ -107,23 +105,26 @@ class StripePaymentInProgressViewModel(
|
|||
|
||||
fun providePaymentData(paymentData: PaymentData) {
|
||||
requireNoPaymentInformation()
|
||||
this.paymentData = paymentData
|
||||
this.stripePaymentData = StripePaymentData.GooglePay(paymentData)
|
||||
}
|
||||
|
||||
fun provideCardData(cardData: StripeApi.CardData) {
|
||||
requireNoPaymentInformation()
|
||||
this.cardData = cardData
|
||||
this.stripePaymentData = StripePaymentData.CreditCard(cardData)
|
||||
}
|
||||
|
||||
fun provideSEPADebitData(bankData: StripeApi.SEPADebitData) {
|
||||
requireNoPaymentInformation()
|
||||
this.stripePaymentData = StripePaymentData.SEPADebit(bankData)
|
||||
}
|
||||
|
||||
private fun requireNoPaymentInformation() {
|
||||
require(paymentData == null)
|
||||
require(cardData == null)
|
||||
require(stripePaymentData == null)
|
||||
}
|
||||
|
||||
private fun clearPaymentInformation() {
|
||||
Log.d(TAG, "Cleared payment information.", true)
|
||||
paymentData = null
|
||||
cardData = null
|
||||
stripePaymentData = null
|
||||
}
|
||||
|
||||
private fun proceedMonthly(request: GatewayRequest, paymentSourceProvider: PaymentSourceProvider, nextActionHandler: (StripeApi.Secure3DSAction) -> Single<StripeIntentAccessor>) {
|
||||
|
@ -132,7 +133,7 @@ class StripePaymentInProgressViewModel(
|
|||
stripeRepository.createAndConfirmSetupIntent(it, paymentSourceProvider.paymentSourceType as PaymentSourceType.Stripe)
|
||||
}
|
||||
|
||||
val setLevel: Completable = monthlyDonationRepository.setSubscriptionLevel(request.level.toString())
|
||||
val setLevel: Completable = monthlyDonationRepository.setSubscriptionLevel(request.level.toString(), request.uiSessionKey)
|
||||
|
||||
Log.d(TAG, "Starting subscription payment pipeline...", true)
|
||||
store.update { DonationProcessorStage.PAYMENT_PIPELINE }
|
||||
|
@ -201,7 +202,8 @@ class StripePaymentInProgressViewModel(
|
|||
badgeRecipient = request.recipientId,
|
||||
additionalMessage = request.additionalMessage,
|
||||
badgeLevel = request.level,
|
||||
donationProcessor = DonationProcessor.STRIPE
|
||||
donationProcessor = DonationProcessor.STRIPE,
|
||||
uiSessionKey = request.uiSessionKey
|
||||
)
|
||||
}
|
||||
}.subscribeBy(
|
||||
|
@ -246,7 +248,7 @@ class StripePaymentInProgressViewModel(
|
|||
Log.d(TAG, "Beginning subscription update...", true)
|
||||
|
||||
store.update { DonationProcessorStage.PAYMENT_PIPELINE }
|
||||
disposables += monthlyDonationRepository.cancelActiveSubscriptionIfNecessary().andThen(monthlyDonationRepository.setSubscriptionLevel(request.level.toString()))
|
||||
disposables += monthlyDonationRepository.cancelActiveSubscriptionIfNecessary().andThen(monthlyDonationRepository.setSubscriptionLevel(request.level.toString(), request.uiSessionKey))
|
||||
.subscribeBy(
|
||||
onComplete = {
|
||||
Log.w(TAG, "Completed subscription update", true)
|
||||
|
@ -271,6 +273,12 @@ class StripePaymentInProgressViewModel(
|
|||
val paymentSource: Single<StripeApi.PaymentSource>
|
||||
)
|
||||
|
||||
private sealed interface StripePaymentData {
|
||||
class GooglePay(val paymentData: PaymentData) : StripePaymentData
|
||||
class CreditCard(val cardData: StripeApi.CardData) : StripePaymentData
|
||||
class SEPADebit(val sepaDebitData: StripeApi.SEPADebitData) : StripePaymentData
|
||||
}
|
||||
|
||||
class Factory(
|
||||
private val stripeRepository: StripeRepository,
|
||||
private val monthlyDonationRepository: MonthlyDonationRepository = MonthlyDonationRepository(ApplicationDependencies.getDonationsService()),
|
||||
|
|
|
@ -0,0 +1,302 @@
|
|||
/*
|
||||
* Copyright 2023 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.components.settings.app.subscription.donate.transfer.details
|
||||
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.defaultMinSize
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.text.KeyboardActions
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.material3.TextField
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment.Companion.Center
|
||||
import androidx.compose.ui.Alignment.Companion.CenterHorizontally
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.focus.FocusDirection
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.focus.onFocusChanged
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalFocusManager
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
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.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.ui.theme.SignalTheme
|
||||
import org.thoughtcrime.securesms.R
|
||||
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.DonationProcessorAction
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.stripe.StripePaymentInProgressViewModel
|
||||
import org.thoughtcrime.securesms.compose.ComposeFragment
|
||||
import org.thoughtcrime.securesms.payments.FiatMoneyUtil
|
||||
import org.thoughtcrime.securesms.util.CommunicationActions
|
||||
import org.thoughtcrime.securesms.util.SpanUtil
|
||||
import org.thoughtcrime.securesms.util.fragments.requireListener
|
||||
import org.thoughtcrime.securesms.util.navigation.safeNavigate
|
||||
|
||||
/**
|
||||
* Collects SEPA Debit bank transfer details from the user to proceed with donation.
|
||||
*/
|
||||
class BankTransferDetailsFragment : ComposeFragment() {
|
||||
|
||||
private val args: BankTransferDetailsFragmentArgs by navArgs()
|
||||
private val viewModel: BankTransferDetailsViewModel by viewModels()
|
||||
|
||||
private val stripePaymentViewModel: StripePaymentInProgressViewModel by navGraphViewModels(
|
||||
R.id.donate_to_signal,
|
||||
factoryProducer = {
|
||||
StripePaymentInProgressViewModel.Factory(requireListener<DonationPaymentComponent>().stripeRepository)
|
||||
}
|
||||
)
|
||||
|
||||
@Composable
|
||||
override fun FragmentContent() {
|
||||
val state: BankTransferDetailsState 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)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
BankTransferDetailsContent(
|
||||
state = state,
|
||||
onNavigationClick = this::onNavigationClick,
|
||||
onNameChanged = viewModel::onNameChanged,
|
||||
onIBANChanged = viewModel::onIBANChanged,
|
||||
onEmailChanged = viewModel::onEmailChanged,
|
||||
onFindAccountNumbersClicked = this::onFindAccountNumbersClicked,
|
||||
onDonateClick = this::onDonateClick,
|
||||
onIBANFocusChanged = viewModel::onIBANFocusChanged,
|
||||
donateLabel = donateLabel
|
||||
)
|
||||
}
|
||||
|
||||
private fun onNavigationClick() {
|
||||
findNavController().popBackStack()
|
||||
}
|
||||
|
||||
private fun onFindAccountNumbersClicked() {
|
||||
// TODO [sepa] -- FindAccountNumbersBottomSheet
|
||||
}
|
||||
|
||||
private fun onDonateClick() {
|
||||
stripePaymentViewModel.provideSEPADebitData(viewModel.state.value.asSEPADebitData())
|
||||
findNavController().safeNavigate(
|
||||
BankTransferDetailsFragmentDirections.actionBankTransferDetailsFragmentToStripePaymentInProgressFragment(
|
||||
DonationProcessorAction.PROCESS_NEW_DONATION,
|
||||
args.request
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
private fun BankTransferDetailsContentPreview() {
|
||||
SignalTheme {
|
||||
BankTransferDetailsContent(
|
||||
state = BankTransferDetailsState(
|
||||
name = "Miles Morales"
|
||||
),
|
||||
onNavigationClick = {},
|
||||
onNameChanged = {},
|
||||
onIBANChanged = {},
|
||||
onEmailChanged = {},
|
||||
onFindAccountNumbersClicked = {},
|
||||
onDonateClick = {},
|
||||
onIBANFocusChanged = {},
|
||||
donateLabel = "Donate $5/month"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun BankTransferDetailsContent(
|
||||
state: BankTransferDetailsState,
|
||||
onNavigationClick: () -> Unit,
|
||||
onNameChanged: (String) -> Unit,
|
||||
onIBANChanged: (String) -> Unit,
|
||||
onEmailChanged: (String) -> Unit,
|
||||
onFindAccountNumbersClicked: () -> Unit,
|
||||
onDonateClick: () -> Unit,
|
||||
onIBANFocusChanged: (Boolean) -> Unit,
|
||||
donateLabel: String
|
||||
) {
|
||||
Scaffolds.Settings(
|
||||
title = "Bank transfer",
|
||||
onNavigationClick = onNavigationClick,
|
||||
navigationIconPainter = painterResource(id = R.drawable.symbol_arrow_left_24)
|
||||
) {
|
||||
Column(
|
||||
horizontalAlignment = CenterHorizontally,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.fillMaxHeight()
|
||||
.padding(it)
|
||||
) {
|
||||
val focusManager = LocalFocusManager.current
|
||||
val focusRequester = remember { FocusRequester() }
|
||||
|
||||
LazyColumn(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.padding(horizontal = 24.dp)
|
||||
) {
|
||||
item {
|
||||
val learnMore = stringResource(id = R.string.BankTransferDetailsFragment__learn_more)
|
||||
val fullString = stringResource(id = R.string.BankTransferDetailsFragment__enter_your_bank_details, learnMore)
|
||||
val context = LocalContext.current
|
||||
|
||||
Texts.LinkifiedText(
|
||||
textWithUrlSpans = SpanUtil.urlSubsequence(fullString, learnMore, stringResource(id = R.string.donate_url)), // TODO [alex] -- final URL
|
||||
onUrlClick = {
|
||||
CommunicationActions.openBrowserLink(context, it)
|
||||
},
|
||||
style = MaterialTheme.typography.bodyLarge.copy(
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
),
|
||||
modifier = Modifier.padding(vertical = 12.dp)
|
||||
)
|
||||
}
|
||||
|
||||
item {
|
||||
TextField(
|
||||
value = state.iban,
|
||||
onValueChange = onIBANChanged,
|
||||
label = {
|
||||
Text(text = stringResource(id = R.string.BankTransferDetailsFragment__iban))
|
||||
},
|
||||
keyboardOptions = KeyboardOptions(
|
||||
capitalization = KeyboardCapitalization.Characters,
|
||||
imeAction = ImeAction.Next
|
||||
),
|
||||
keyboardActions = KeyboardActions(
|
||||
onNext = { focusManager.moveFocus(FocusDirection.Down) }
|
||||
),
|
||||
isError = state.ibanValidity.isError,
|
||||
supportingText = {
|
||||
if (state.ibanValidity.isError) {
|
||||
Text(
|
||||
text = when (state.ibanValidity) {
|
||||
IBANValidator.Validity.TOO_SHORT -> stringResource(id = R.string.BankTransferDetailsFragment__iban_nubmer_is_too_short)
|
||||
IBANValidator.Validity.TOO_LONG -> stringResource(id = R.string.BankTransferDetailsFragment__iban_nubmer_is_too_long)
|
||||
IBANValidator.Validity.INVALID_COUNTRY -> stringResource(id = R.string.BankTransferDetailsFragment__iban_country_code_is_not_supported)
|
||||
IBANValidator.Validity.INVALID_CHARACTERS -> stringResource(id = R.string.BankTransferDetailsFragment__invalid_iban_nubmer)
|
||||
IBANValidator.Validity.INVALID_MOD_97 -> stringResource(id = R.string.BankTransferDetailsFragment__invalid_iban_nubmer)
|
||||
else -> error("Unexpected error.")
|
||||
}
|
||||
)
|
||||
}
|
||||
},
|
||||
visualTransformation = IBANVisualTransformation,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(top = 12.dp)
|
||||
.onFocusChanged { onIBANFocusChanged(it.hasFocus) }
|
||||
.focusRequester(focusRequester)
|
||||
)
|
||||
}
|
||||
|
||||
item {
|
||||
TextField(
|
||||
value = state.name,
|
||||
onValueChange = onNameChanged,
|
||||
label = {
|
||||
Text(text = stringResource(id = R.string.BankTransferDetailsFragment__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.BankTransferDetailsFragment__email))
|
||||
},
|
||||
keyboardOptions = KeyboardOptions(
|
||||
keyboardType = KeyboardType.Email,
|
||||
imeAction = ImeAction.Done
|
||||
),
|
||||
keyboardActions = KeyboardActions(
|
||||
onDone = { onDonateClick() }
|
||||
),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(bottom = 16.dp)
|
||||
)
|
||||
}
|
||||
|
||||
item {
|
||||
Box(
|
||||
contentAlignment = Center,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
TextButton(
|
||||
onClick = onFindAccountNumbersClicked
|
||||
) {
|
||||
Text(text = stringResource(id = R.string.BankTransferDetailsFragment__find_account_numbers))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Buttons.LargeTonal(
|
||||
enabled = state.canProceed,
|
||||
onClick = onDonateClick,
|
||||
modifier = Modifier
|
||||
.defaultMinSize(minWidth = 220.dp)
|
||||
.padding(bottom = 16.dp)
|
||||
) {
|
||||
Text(text = donateLabel)
|
||||
}
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
focusRequester.requestFocus()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,25 @@
|
|||
/*
|
||||
* Copyright 2023 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.components.settings.app.subscription.donate.transfer.details
|
||||
|
||||
import org.signal.donations.StripeApi
|
||||
|
||||
data class BankTransferDetailsState(
|
||||
val name: String = "",
|
||||
val iban: String = "",
|
||||
val email: String = "",
|
||||
val ibanValidity: IBANValidator.Validity = IBANValidator.Validity.POTENTIALLY_VALID
|
||||
) {
|
||||
val canProceed = name.isNotEmpty() && email.isNotEmpty() && ibanValidity == IBANValidator.Validity.COMPLETELY_VALID
|
||||
|
||||
fun asSEPADebitData(): StripeApi.SEPADebitData {
|
||||
return StripeApi.SEPADebitData(
|
||||
iban = iban,
|
||||
name = name,
|
||||
email = email
|
||||
)
|
||||
}
|
||||
}
|
|
@ -0,0 +1,45 @@
|
|||
/*
|
||||
* Copyright 2023 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.components.settings.app.subscription.donate.transfer.details
|
||||
|
||||
import androidx.compose.runtime.State
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.lifecycle.ViewModel
|
||||
|
||||
class BankTransferDetailsViewModel : ViewModel() {
|
||||
|
||||
companion object {
|
||||
private const val IBAN_MAX_CHARACTER_COUNT = 34
|
||||
}
|
||||
|
||||
private val internalState = mutableStateOf(BankTransferDetailsState())
|
||||
val state: State<BankTransferDetailsState> = internalState
|
||||
|
||||
fun onNameChanged(name: String) {
|
||||
internalState.value = internalState.value.copy(
|
||||
name = name
|
||||
)
|
||||
}
|
||||
|
||||
fun onIBANFocusChanged(isFocused: Boolean) {
|
||||
internalState.value = internalState.value.copy(
|
||||
ibanValidity = IBANValidator.validate(internalState.value.iban, isFocused)
|
||||
)
|
||||
}
|
||||
|
||||
fun onIBANChanged(iban: String) {
|
||||
internalState.value = internalState.value.copy(
|
||||
iban = iban.take(IBAN_MAX_CHARACTER_COUNT).uppercase(),
|
||||
ibanValidity = IBANValidator.validate(internalState.value.iban, true)
|
||||
)
|
||||
}
|
||||
|
||||
fun onEmailChanged(email: String) {
|
||||
internalState.value = internalState.value.copy(
|
||||
email = email
|
||||
)
|
||||
}
|
||||
}
|
|
@ -0,0 +1,161 @@
|
|||
/*
|
||||
* Copyright 2023 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.components.settings.app.subscription.donate.transfer.details
|
||||
|
||||
import java.math.BigInteger
|
||||
|
||||
object IBANValidator {
|
||||
|
||||
private val countryCodeToLength: Map<String, Int> by lazy {
|
||||
mapOf(
|
||||
"AL" to 28,
|
||||
"AD" to 24,
|
||||
"AT" to 20,
|
||||
"AZ" to 28,
|
||||
"BH" to 22,
|
||||
"BY" to 28,
|
||||
"BE" to 16,
|
||||
"BA" to 20,
|
||||
"BR" to 29,
|
||||
"BG" to 22,
|
||||
"CR" to 22,
|
||||
"HR" to 21,
|
||||
"CY" to 28,
|
||||
"CZ" to 24,
|
||||
"DK" to 18,
|
||||
"DO" to 28,
|
||||
"TL" to 23,
|
||||
"EG" to 29,
|
||||
"SV" to 28,
|
||||
"EE" to 20,
|
||||
"FO" to 18,
|
||||
"FI" to 18,
|
||||
"FR" to 27,
|
||||
"GE" to 22,
|
||||
"DE" to 22,
|
||||
"GI" to 23,
|
||||
"GR" to 27,
|
||||
"GL" to 18,
|
||||
"GT" to 28,
|
||||
"HU" to 28,
|
||||
"IS" to 26,
|
||||
"IQ" to 23,
|
||||
"IE" to 22,
|
||||
"IL" to 23,
|
||||
"IT" to 27,
|
||||
"JO" to 30,
|
||||
"KZ" to 20,
|
||||
"XK" to 20,
|
||||
"KW" to 30,
|
||||
"LV" to 21,
|
||||
"LB" to 28,
|
||||
"LY" to 25,
|
||||
"LI" to 21,
|
||||
"LT" to 20,
|
||||
"LU" to 20,
|
||||
"MT" to 31,
|
||||
"MR" to 27,
|
||||
"MU" to 30,
|
||||
"MC" to 27,
|
||||
"MD" to 24,
|
||||
"ME" to 22,
|
||||
"NL" to 18,
|
||||
"MK" to 19,
|
||||
"NO" to 15,
|
||||
"PK" to 24,
|
||||
"PS" to 29,
|
||||
"PL" to 28,
|
||||
"PT" to 25,
|
||||
"QA" to 29,
|
||||
"RO" to 24,
|
||||
"RU" to 33,
|
||||
"LC" to 32,
|
||||
"SM" to 27,
|
||||
"ST" to 25,
|
||||
"SA" to 24,
|
||||
"RS" to 22,
|
||||
"SC" to 31,
|
||||
"SK" to 24,
|
||||
"SI" to 19,
|
||||
"ES" to 24,
|
||||
"SD" to 18,
|
||||
"SE" to 24,
|
||||
"CH" to 21,
|
||||
"TN" to 24,
|
||||
"TR" to 26,
|
||||
"UA" to 29,
|
||||
"AE" to 23,
|
||||
"GB" to 22,
|
||||
"VA" to 22,
|
||||
"VG" to 24
|
||||
)
|
||||
}
|
||||
|
||||
fun validate(iban: String, isIBANFieldFocused: Boolean): Validity {
|
||||
if (iban.isEmpty()) {
|
||||
return Validity.POTENTIALLY_VALID
|
||||
}
|
||||
|
||||
val lengthValidity = validateLength(iban, isIBANFieldFocused)
|
||||
if (lengthValidity != Validity.COMPLETELY_VALID) {
|
||||
return lengthValidity
|
||||
}
|
||||
|
||||
val countryAndCheck = iban.take(4)
|
||||
val rearranged = iban.drop(4) + countryAndCheck
|
||||
val expanded = rearranged.map {
|
||||
if (it.isLetter()) {
|
||||
(it - 'A') + 10
|
||||
} else if (it.isDigit()) {
|
||||
it.digitToInt()
|
||||
} else {
|
||||
return Validity.INVALID_CHARACTERS
|
||||
}
|
||||
}.joinToString("")
|
||||
val bigInteger = BigInteger(expanded)
|
||||
if (bigInteger.mod(BigInteger.valueOf(97L)) == BigInteger.ONE) {
|
||||
return Validity.COMPLETELY_VALID
|
||||
}
|
||||
|
||||
return Validity.INVALID_MOD_97
|
||||
}
|
||||
|
||||
private fun validateLength(iban: String, isIBANFieldFocused: Boolean): Validity {
|
||||
if (iban.length < 2) {
|
||||
return if (isIBANFieldFocused) {
|
||||
Validity.POTENTIALLY_VALID
|
||||
} else {
|
||||
Validity.TOO_SHORT
|
||||
}
|
||||
}
|
||||
|
||||
val countryCode = iban.take(2)
|
||||
val requiredLength = countryCodeToLength[countryCode] ?: -1
|
||||
if (requiredLength == -1) {
|
||||
return Validity.INVALID_COUNTRY
|
||||
}
|
||||
|
||||
if (requiredLength > iban.length) {
|
||||
return if (isIBANFieldFocused) Validity.POTENTIALLY_VALID else Validity.TOO_SHORT
|
||||
}
|
||||
|
||||
if (requiredLength < iban.length) {
|
||||
return Validity.TOO_LONG
|
||||
}
|
||||
|
||||
return Validity.COMPLETELY_VALID
|
||||
}
|
||||
|
||||
enum class Validity(val isError: Boolean) {
|
||||
TOO_SHORT(true),
|
||||
TOO_LONG(true),
|
||||
INVALID_COUNTRY(true),
|
||||
INVALID_CHARACTERS(true),
|
||||
INVALID_MOD_97(true),
|
||||
POTENTIALLY_VALID(false),
|
||||
COMPLETELY_VALID(false)
|
||||
}
|
||||
}
|
|
@ -0,0 +1,43 @@
|
|||
/*
|
||||
* Copyright 2023 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.components.settings.app.subscription.donate.transfer.details
|
||||
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
import androidx.compose.ui.text.input.OffsetMapping
|
||||
import androidx.compose.ui.text.input.TransformedText
|
||||
import androidx.compose.ui.text.input.VisualTransformation
|
||||
|
||||
/**
|
||||
* Transforms the given input string to an IBAN representative format:
|
||||
*
|
||||
* AB1234567890 becomes AB12 3456 7890
|
||||
*/
|
||||
object IBANVisualTransformation : VisualTransformation {
|
||||
override fun filter(text: AnnotatedString): TransformedText {
|
||||
var output = ""
|
||||
for (i in text.take(34).indices) {
|
||||
output += text[i]
|
||||
if (i % 4 == 3) {
|
||||
output += " "
|
||||
}
|
||||
}
|
||||
|
||||
return TransformedText(
|
||||
text = AnnotatedString(output),
|
||||
offsetMapping = IBANOffsetMapping
|
||||
)
|
||||
}
|
||||
|
||||
private object IBANOffsetMapping : OffsetMapping {
|
||||
override fun originalToTransformed(offset: Int): Int {
|
||||
return offset + (offset / 4)
|
||||
}
|
||||
|
||||
override fun transformedToOriginal(offset: Int): Int {
|
||||
return offset - (offset / 4)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,175 @@
|
|||
/*
|
||||
* Copyright 2023 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.components.settings.app.subscription.donate.transfer.mandate
|
||||
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.defaultMinSize
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
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.shape.CircleShape
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Alignment.Companion.CenterHorizontally
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.graphics.vector.rememberVectorPainter
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.res.vectorResource
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.fragment.app.viewModels
|
||||
import androidx.navigation.fragment.findNavController
|
||||
import androidx.navigation.fragment.navArgs
|
||||
import org.signal.core.ui.Buttons
|
||||
import org.signal.core.ui.Dividers
|
||||
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.compose.ComposeFragment
|
||||
import org.thoughtcrime.securesms.util.CommunicationActions
|
||||
import org.thoughtcrime.securesms.util.SpanUtil
|
||||
import org.thoughtcrime.securesms.util.navigation.safeNavigate
|
||||
|
||||
/**
|
||||
* Displays Bank Transfer legal mandate users must agree to to move forward.
|
||||
*/
|
||||
class BankTransferMandateFragment : ComposeFragment() {
|
||||
|
||||
private val args: BankTransferMandateFragmentArgs by navArgs()
|
||||
private val viewModel: BankTransferMandateViewModel by viewModels()
|
||||
|
||||
@Composable
|
||||
override fun FragmentContent() {
|
||||
val mandate by viewModel.mandate
|
||||
|
||||
BankTransferScreen(
|
||||
bankMandate = mandate,
|
||||
onNavigationClick = this::onNavigationClick,
|
||||
onContinueClick = this::onContinueClick
|
||||
)
|
||||
}
|
||||
|
||||
private fun onNavigationClick() {
|
||||
findNavController().popBackStack()
|
||||
}
|
||||
|
||||
private fun onContinueClick() {
|
||||
findNavController().safeNavigate(
|
||||
BankTransferMandateFragmentDirections.actionBankTransferMandateFragmentToBankTransferDetailsFragment(args.request)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
fun BankTransferScreenPreview() {
|
||||
SignalTheme {
|
||||
BankTransferScreen(
|
||||
bankMandate = "Test ".repeat(500),
|
||||
onNavigationClick = {},
|
||||
onContinueClick = {}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun BankTransferScreen(
|
||||
bankMandate: String,
|
||||
onNavigationClick: () -> Unit,
|
||||
onContinueClick: () -> Unit
|
||||
) {
|
||||
Scaffolds.Settings(
|
||||
title = "",
|
||||
onNavigationClick = onNavigationClick,
|
||||
navigationIconPainter = rememberVectorPainter(ImageVector.vectorResource(id = R.drawable.symbol_arrow_left_24))
|
||||
) {
|
||||
Column(
|
||||
horizontalAlignment = CenterHorizontally,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.fillMaxHeight()
|
||||
) {
|
||||
LazyColumn(
|
||||
horizontalAlignment = CenterHorizontally,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.weight(1f)
|
||||
.padding(top = 64.dp)
|
||||
) {
|
||||
item {
|
||||
Image(
|
||||
painter = painterResource(id = R.drawable.credit_card), // TODO [alex] -- final asset
|
||||
contentScale = ContentScale.Inside,
|
||||
contentDescription = null,
|
||||
modifier = Modifier
|
||||
.size(72.dp)
|
||||
.background(
|
||||
SignalTheme.colors.colorSurface2,
|
||||
CircleShape
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
item {
|
||||
Text(
|
||||
text = stringResource(id = R.string.BankTransferMandateFragment__bank_transfer),
|
||||
style = MaterialTheme.typography.headlineMedium,
|
||||
modifier = Modifier.padding(top = 12.dp, bottom = 15.dp)
|
||||
)
|
||||
}
|
||||
|
||||
item {
|
||||
val learnMore = stringResource(id = R.string.BankTransferMandateFragment__learn_more)
|
||||
val fullString = stringResource(id = R.string.BankTransferMandateFragment__stripe_processes_donations, learnMore)
|
||||
val context = LocalContext.current
|
||||
|
||||
Texts.LinkifiedText(
|
||||
textWithUrlSpans = SpanUtil.urlSubsequence(fullString, learnMore, stringResource(id = R.string.donate_url)), // TODO [alex] -- final URL
|
||||
onUrlClick = {
|
||||
CommunicationActions.openBrowserLink(context, it)
|
||||
},
|
||||
style = MaterialTheme.typography.bodyLarge.copy(
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
),
|
||||
modifier = Modifier.padding(bottom = 12.dp, start = 32.dp, end = 32.dp)
|
||||
)
|
||||
}
|
||||
|
||||
item {
|
||||
Dividers.Default()
|
||||
}
|
||||
|
||||
item {
|
||||
Text(
|
||||
text = bankMandate,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(horizontal = 32.dp, vertical = 16.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Buttons.LargeTonal(
|
||||
onClick = onContinueClick,
|
||||
modifier = Modifier
|
||||
.padding(top = 16.dp, bottom = 46.dp)
|
||||
.defaultMinSize(minWidth = 220.dp)
|
||||
) {
|
||||
Text(text = stringResource(id = R.string.BankTransferMandateFragment__continue))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,22 @@
|
|||
/*
|
||||
* Copyright 2023 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.components.settings.app.subscription.donate.transfer.mandate
|
||||
|
||||
import io.reactivex.rxjava3.core.Single
|
||||
import io.reactivex.rxjava3.schedulers.Schedulers
|
||||
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
|
||||
import java.util.Locale
|
||||
|
||||
class BankTransferMandateRepository {
|
||||
|
||||
fun getMandate(): Single<String> {
|
||||
return Single
|
||||
.fromCallable { ApplicationDependencies.getDonationsService().getBankMandate(Locale.getDefault()) }
|
||||
.flatMap { it.flattenResult() }
|
||||
.map { it.mandate }
|
||||
.subscribeOn(Schedulers.io())
|
||||
}
|
||||
}
|
|
@ -0,0 +1,32 @@
|
|||
/*
|
||||
* Copyright 2023 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.components.settings.app.subscription.donate.transfer.mandate
|
||||
|
||||
import androidx.compose.runtime.State
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.lifecycle.ViewModel
|
||||
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
|
||||
import io.reactivex.rxjava3.disposables.CompositeDisposable
|
||||
import io.reactivex.rxjava3.kotlin.plusAssign
|
||||
import io.reactivex.rxjava3.kotlin.subscribeBy
|
||||
|
||||
class BankTransferMandateViewModel(
|
||||
repository: BankTransferMandateRepository = BankTransferMandateRepository()
|
||||
) : ViewModel() {
|
||||
|
||||
private val disposables = CompositeDisposable()
|
||||
private val internalMandate = mutableStateOf("")
|
||||
val mandate: State<String> = internalMandate
|
||||
|
||||
init {
|
||||
disposables += repository.getMandate()
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
.subscribeBy(
|
||||
onSuccess = { internalMandate.value = it },
|
||||
onError = { internalMandate.value = "Failed to load mandate." }
|
||||
)
|
||||
}
|
||||
}
|
|
@ -113,11 +113,40 @@ sealed class DonationError(val source: DonationErrorSource, cause: Throwable) :
|
|||
source to PublishSubject.create()
|
||||
}
|
||||
|
||||
private val donationErrorsSubjectUiSessionMap: MutableMap<Long, Subject<DonationError>> = mutableMapOf()
|
||||
|
||||
@JvmStatic
|
||||
fun getErrorsForSource(donationErrorSource: DonationErrorSource): Observable<DonationError> {
|
||||
return donationErrorSubjectSourceMap[donationErrorSource]!!
|
||||
}
|
||||
|
||||
fun getErrorsForUiSessionKey(uiSessionKey: Long): Observable<DonationError> {
|
||||
val subject: Subject<DonationError> = donationErrorsSubjectUiSessionMap[uiSessionKey] ?: PublishSubject.create()
|
||||
donationErrorsSubjectUiSessionMap[uiSessionKey] = subject
|
||||
|
||||
return subject
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
fun routeBackgroundError(context: Context, uiSessionKey: Long, error: DonationError) {
|
||||
if (error.source == DonationErrorSource.GIFT_REDEMPTION) {
|
||||
routeDonationError(context, error)
|
||||
return
|
||||
}
|
||||
|
||||
val subject: Subject<DonationError>? = donationErrorsSubjectUiSessionMap[uiSessionKey]
|
||||
when {
|
||||
subject != null && subject.hasObservers() -> {
|
||||
Log.i(TAG, "Routing background donation error to uiSessionKey $uiSessionKey dialog", error)
|
||||
subject.onNext(error)
|
||||
}
|
||||
else -> {
|
||||
Log.i(TAG, "Routing background donation error to uiSessionKey $uiSessionKey notification", error)
|
||||
DonationErrorNotifications.displayErrorNotification(context, error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Route a given donation error, which will either pipe it out to an appropriate subject
|
||||
* or, if the subject has no observers, post it as a notification.
|
||||
|
|
|
@ -49,6 +49,7 @@ public class BoostReceiptRequestResponseJob extends BaseJob {
|
|||
private static final String DATA_ERROR_SOURCE = "data.error.source";
|
||||
private static final String DATA_BADGE_LEVEL = "data.badge.level";
|
||||
private static final String DATA_DONATION_PROCESSOR = "data.donation.processor";
|
||||
private static final String DATA_UI_SESSION_KEY = "data.ui.session.key";
|
||||
|
||||
private ReceiptCredentialRequestContext requestContext;
|
||||
|
||||
|
@ -56,8 +57,9 @@ public class BoostReceiptRequestResponseJob extends BaseJob {
|
|||
private final String paymentIntentId;
|
||||
private final long badgeLevel;
|
||||
private final DonationProcessor donationProcessor;
|
||||
private final long uiSessionKey;
|
||||
|
||||
private static BoostReceiptRequestResponseJob createJob(String paymentIntentId, DonationErrorSource donationErrorSource, long badgeLevel, DonationProcessor donationProcessor) {
|
||||
private static BoostReceiptRequestResponseJob createJob(String paymentIntentId, DonationErrorSource donationErrorSource, long badgeLevel, DonationProcessor donationProcessor, long uiSessionKey) {
|
||||
return new BoostReceiptRequestResponseJob(
|
||||
new Parameters
|
||||
.Builder()
|
||||
|
@ -70,13 +72,17 @@ public class BoostReceiptRequestResponseJob extends BaseJob {
|
|||
paymentIntentId,
|
||||
donationErrorSource,
|
||||
badgeLevel,
|
||||
donationProcessor
|
||||
donationProcessor,
|
||||
uiSessionKey
|
||||
);
|
||||
}
|
||||
|
||||
public static JobManager.Chain createJobChainForBoost(@NonNull String paymentIntentId, @NonNull DonationProcessor donationProcessor) {
|
||||
BoostReceiptRequestResponseJob requestReceiptJob = createJob(paymentIntentId, DonationErrorSource.BOOST, Long.parseLong(SubscriptionLevels.BOOST_LEVEL), donationProcessor);
|
||||
DonationReceiptRedemptionJob redeemReceiptJob = DonationReceiptRedemptionJob.createJobForBoost();
|
||||
public static JobManager.Chain createJobChainForBoost(@NonNull String paymentIntentId,
|
||||
@NonNull DonationProcessor donationProcessor,
|
||||
long uiSessionKey)
|
||||
{
|
||||
BoostReceiptRequestResponseJob requestReceiptJob = createJob(paymentIntentId, DonationErrorSource.BOOST, Long.parseLong(SubscriptionLevels.BOOST_LEVEL), donationProcessor, uiSessionKey);
|
||||
DonationReceiptRedemptionJob redeemReceiptJob = DonationReceiptRedemptionJob.createJobForBoost(uiSessionKey);
|
||||
RefreshOwnProfileJob refreshOwnProfileJob = RefreshOwnProfileJob.forBoost();
|
||||
MultiDeviceProfileContentUpdateJob multiDeviceProfileContentUpdateJob = new MultiDeviceProfileContentUpdateJob();
|
||||
|
||||
|
@ -91,9 +97,10 @@ public class BoostReceiptRequestResponseJob extends BaseJob {
|
|||
@NonNull RecipientId recipientId,
|
||||
@Nullable String additionalMessage,
|
||||
long badgeLevel,
|
||||
@NonNull DonationProcessor donationProcessor)
|
||||
@NonNull DonationProcessor donationProcessor,
|
||||
long uiSessionKey)
|
||||
{
|
||||
BoostReceiptRequestResponseJob requestReceiptJob = createJob(paymentIntentId, DonationErrorSource.GIFT, badgeLevel, donationProcessor);
|
||||
BoostReceiptRequestResponseJob requestReceiptJob = createJob(paymentIntentId, DonationErrorSource.GIFT, badgeLevel, donationProcessor, uiSessionKey);
|
||||
GiftSendJob giftSendJob = new GiftSendJob(recipientId, additionalMessage);
|
||||
|
||||
|
||||
|
@ -107,7 +114,8 @@ public class BoostReceiptRequestResponseJob extends BaseJob {
|
|||
@NonNull String paymentIntentId,
|
||||
@NonNull DonationErrorSource donationErrorSource,
|
||||
long badgeLevel,
|
||||
@NonNull DonationProcessor donationProcessor)
|
||||
@NonNull DonationProcessor donationProcessor,
|
||||
long uiSessionKey)
|
||||
{
|
||||
super(parameters);
|
||||
this.requestContext = requestContext;
|
||||
|
@ -115,6 +123,7 @@ public class BoostReceiptRequestResponseJob extends BaseJob {
|
|||
this.donationErrorSource = donationErrorSource;
|
||||
this.badgeLevel = badgeLevel;
|
||||
this.donationProcessor = donationProcessor;
|
||||
this.uiSessionKey = uiSessionKey;
|
||||
}
|
||||
|
||||
@Override
|
||||
|
@ -122,7 +131,8 @@ public class BoostReceiptRequestResponseJob extends BaseJob {
|
|||
JsonJobData.Builder builder = new JsonJobData.Builder().putString(DATA_PAYMENT_INTENT_ID, paymentIntentId)
|
||||
.putString(DATA_ERROR_SOURCE, donationErrorSource.serialize())
|
||||
.putLong(DATA_BADGE_LEVEL, badgeLevel)
|
||||
.putString(DATA_DONATION_PROCESSOR, donationProcessor.getCode());
|
||||
.putString(DATA_DONATION_PROCESSOR, donationProcessor.getCode())
|
||||
.putLong(DATA_UI_SESSION_KEY, uiSessionKey);
|
||||
|
||||
if (requestContext != null) {
|
||||
builder.putBlobAsString(DATA_REQUEST_BYTES, requestContext.serialize());
|
||||
|
@ -168,7 +178,7 @@ public class BoostReceiptRequestResponseJob extends BaseJob {
|
|||
ReceiptCredential receiptCredential = getReceiptCredential(response.getResult().get());
|
||||
|
||||
if (!isCredentialValid(receiptCredential)) {
|
||||
DonationError.routeDonationError(context, DonationError.badgeCredentialVerificationFailure(donationErrorSource));
|
||||
DonationError.routeBackgroundError(context, uiSessionKey, DonationError.badgeCredentialVerificationFailure(donationErrorSource));
|
||||
throw new IOException("Could not validate receipt credential");
|
||||
}
|
||||
|
||||
|
@ -183,7 +193,7 @@ public class BoostReceiptRequestResponseJob extends BaseJob {
|
|||
}
|
||||
}
|
||||
|
||||
private static void handleApplicationError(Context context, ServiceResponse<ReceiptCredentialResponse> response, @NonNull DonationErrorSource donationErrorSource) throws Exception {
|
||||
private void handleApplicationError(Context context, ServiceResponse<ReceiptCredentialResponse> response, @NonNull DonationErrorSource donationErrorSource) throws Exception {
|
||||
Throwable applicationException = response.getApplicationError().get();
|
||||
switch (response.getStatus()) {
|
||||
case 204:
|
||||
|
@ -191,15 +201,15 @@ public class BoostReceiptRequestResponseJob extends BaseJob {
|
|||
throw new RetryableException();
|
||||
case 400:
|
||||
Log.w(TAG, "Receipt credential request failed to validate.", applicationException, true);
|
||||
DonationError.routeDonationError(context, DonationError.genericBadgeRedemptionFailure(donationErrorSource));
|
||||
DonationError.routeBackgroundError(context, uiSessionKey, DonationError.genericBadgeRedemptionFailure(donationErrorSource));
|
||||
throw new Exception(applicationException);
|
||||
case 402:
|
||||
Log.w(TAG, "User payment failed.", applicationException, true);
|
||||
DonationError.routeDonationError(context, DonationError.genericPaymentFailure(donationErrorSource));
|
||||
DonationError.routeBackgroundError(context, uiSessionKey, DonationError.genericPaymentFailure(donationErrorSource));
|
||||
throw new Exception(applicationException);
|
||||
case 409:
|
||||
Log.w(TAG, "Receipt already redeemed with a different request credential.", response.getApplicationError().get(), true);
|
||||
DonationError.routeDonationError(context, DonationError.genericBadgeRedemptionFailure(donationErrorSource));
|
||||
DonationError.routeBackgroundError(context, uiSessionKey, DonationError.genericBadgeRedemptionFailure(donationErrorSource));
|
||||
throw new Exception(applicationException);
|
||||
default:
|
||||
Log.w(TAG, "Encountered a server failure: " + response.getStatus(), applicationException, true);
|
||||
|
@ -272,15 +282,16 @@ public class BoostReceiptRequestResponseJob extends BaseJob {
|
|||
long badgeLevel = data.getLongOrDefault(DATA_BADGE_LEVEL, Long.parseLong(SubscriptionLevels.BOOST_LEVEL));
|
||||
String rawDonationProcessor = data.getStringOrDefault(DATA_DONATION_PROCESSOR, DonationProcessor.STRIPE.getCode());
|
||||
DonationProcessor donationProcessor = DonationProcessor.fromCode(rawDonationProcessor);
|
||||
long uiSessionKey = data.getLongOrDefault(DATA_UI_SESSION_KEY, -1L);
|
||||
|
||||
try {
|
||||
if (data.hasString(DATA_REQUEST_BYTES)) {
|
||||
byte[] blob = data.getStringAsBlob(DATA_REQUEST_BYTES);
|
||||
ReceiptCredentialRequestContext requestContext = new ReceiptCredentialRequestContext(blob);
|
||||
|
||||
return new BoostReceiptRequestResponseJob(parameters, requestContext, paymentIntentId, donationErrorSource, badgeLevel, donationProcessor);
|
||||
return new BoostReceiptRequestResponseJob(parameters, requestContext, paymentIntentId, donationErrorSource, badgeLevel, donationProcessor, uiSessionKey);
|
||||
} else {
|
||||
return new BoostReceiptRequestResponseJob(parameters, null, paymentIntentId, donationErrorSource, badgeLevel, donationProcessor);
|
||||
return new BoostReceiptRequestResponseJob(parameters, null, paymentIntentId, donationErrorSource, badgeLevel, donationProcessor, uiSessionKey);
|
||||
}
|
||||
} catch (InvalidInputException e) {
|
||||
throw new IllegalStateException(e);
|
||||
|
|
|
@ -43,16 +43,19 @@ public class DonationReceiptRedemptionJob extends BaseJob {
|
|||
public static final String DATA_ERROR_SOURCE = "data.error.source";
|
||||
public static final String DATA_GIFT_MESSAGE_ID = "data.gift.message.id";
|
||||
public static final String DATA_PRIMARY = "data.primary";
|
||||
public static final String DATA_UI_SESSION_KEY = "data.ui.session.key";
|
||||
|
||||
private final long giftMessageId;
|
||||
private final boolean makePrimary;
|
||||
private final DonationErrorSource errorSource;
|
||||
private final long uiSessionKey;
|
||||
|
||||
public static DonationReceiptRedemptionJob createJobForSubscription(@NonNull DonationErrorSource errorSource) {
|
||||
public static DonationReceiptRedemptionJob createJobForSubscription(@NonNull DonationErrorSource errorSource, long uiSessionKey) {
|
||||
return new DonationReceiptRedemptionJob(
|
||||
NO_ID,
|
||||
false,
|
||||
errorSource,
|
||||
uiSessionKey,
|
||||
new Job.Parameters
|
||||
.Builder()
|
||||
.addConstraint(NetworkConstraint.KEY)
|
||||
|
@ -63,11 +66,12 @@ public class DonationReceiptRedemptionJob extends BaseJob {
|
|||
.build());
|
||||
}
|
||||
|
||||
public static DonationReceiptRedemptionJob createJobForBoost() {
|
||||
public static DonationReceiptRedemptionJob createJobForBoost(long uiSessionKey) {
|
||||
return new DonationReceiptRedemptionJob(
|
||||
NO_ID,
|
||||
false,
|
||||
DonationErrorSource.BOOST,
|
||||
uiSessionKey,
|
||||
new Job.Parameters
|
||||
.Builder()
|
||||
.addConstraint(NetworkConstraint.KEY)
|
||||
|
@ -78,7 +82,7 @@ public class DonationReceiptRedemptionJob extends BaseJob {
|
|||
}
|
||||
|
||||
public static JobManager.Chain createJobChainForKeepAlive() {
|
||||
DonationReceiptRedemptionJob redemptionJob = createJobForSubscription(DonationErrorSource.KEEP_ALIVE);
|
||||
DonationReceiptRedemptionJob redemptionJob = createJobForSubscription(DonationErrorSource.KEEP_ALIVE, -1L);
|
||||
RefreshOwnProfileJob refreshOwnProfileJob = new RefreshOwnProfileJob();
|
||||
MultiDeviceProfileContentUpdateJob multiDeviceProfileContentUpdateJob = new MultiDeviceProfileContentUpdateJob();
|
||||
|
||||
|
@ -93,6 +97,7 @@ public class DonationReceiptRedemptionJob extends BaseJob {
|
|||
messageId,
|
||||
primary,
|
||||
DonationErrorSource.GIFT_REDEMPTION,
|
||||
-1L,
|
||||
new Job.Parameters
|
||||
.Builder()
|
||||
.addConstraint(NetworkConstraint.KEY)
|
||||
|
@ -110,11 +115,12 @@ public class DonationReceiptRedemptionJob extends BaseJob {
|
|||
.then(multiDeviceProfileContentUpdateJob);
|
||||
}
|
||||
|
||||
private DonationReceiptRedemptionJob(long giftMessageId, boolean primary, @NonNull DonationErrorSource errorSource, @NonNull Job.Parameters parameters) {
|
||||
private DonationReceiptRedemptionJob(long giftMessageId, boolean primary, @NonNull DonationErrorSource errorSource, long uiSessionKey, @NonNull Job.Parameters parameters) {
|
||||
super(parameters);
|
||||
this.giftMessageId = giftMessageId;
|
||||
this.makePrimary = primary;
|
||||
this.errorSource = errorSource;
|
||||
this.uiSessionKey = uiSessionKey;
|
||||
}
|
||||
|
||||
@Override
|
||||
|
@ -123,6 +129,7 @@ public class DonationReceiptRedemptionJob extends BaseJob {
|
|||
.putString(DATA_ERROR_SOURCE, errorSource.serialize())
|
||||
.putLong(DATA_GIFT_MESSAGE_ID, giftMessageId)
|
||||
.putBoolean(DATA_PRIMARY, makePrimary)
|
||||
.putLong(DATA_UI_SESSION_KEY, uiSessionKey)
|
||||
.serialize();
|
||||
}
|
||||
|
||||
|
@ -185,7 +192,7 @@ public class DonationReceiptRedemptionJob extends BaseJob {
|
|||
throw new RetryableException();
|
||||
} else {
|
||||
Log.w(TAG, "Encountered a non-recoverable exception " + response.getStatus(), response.getApplicationError().get(), true);
|
||||
DonationError.routeDonationError(context, DonationError.genericBadgeRedemptionFailure(errorSource));
|
||||
DonationError.routeBackgroundError(context, uiSessionKey, DonationError.genericBadgeRedemptionFailure(errorSource));
|
||||
throw new IOException(response.getApplicationError().get());
|
||||
}
|
||||
} else if (response.getExecutionError().isPresent()) {
|
||||
|
@ -288,8 +295,9 @@ public class DonationReceiptRedemptionJob extends BaseJob {
|
|||
long messageId = data.getLongOrDefault(DATA_GIFT_MESSAGE_ID, NO_ID);
|
||||
boolean primary = data.getBooleanOrDefault(DATA_PRIMARY, false);
|
||||
DonationErrorSource errorSource = DonationErrorSource.deserialize(serializedErrorSource);
|
||||
long uiSessionKey = data.getLongOrDefault(DATA_UI_SESSION_KEY, -1L);
|
||||
|
||||
return new DonationReceiptRedemptionJob(messageId, primary, errorSource, parameters);
|
||||
return new DonationReceiptRedemptionJob(messageId, primary, errorSource, uiSessionKey, parameters);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -136,7 +136,7 @@ public class SubscriptionKeepAliveJob extends BaseJob {
|
|||
Log.i(TAG, "Subscription end of period is after the conversion end of period. Storing it, generating a credential, and enqueuing the continuation job chain.", true);
|
||||
SignalStore.donationsValues().setSubscriptionEndOfPeriodConversionStarted(endOfCurrentPeriod);
|
||||
SignalStore.donationsValues().refreshSubscriptionRequestCredential();
|
||||
SubscriptionReceiptRequestResponseJob.createSubscriptionContinuationJobChain(true).enqueue();
|
||||
SubscriptionReceiptRequestResponseJob.createSubscriptionContinuationJobChain(true, -1L).enqueue();
|
||||
} else if (endOfCurrentPeriod > SignalStore.donationsValues().getSubscriptionEndOfPeriodRedemptionStarted()) {
|
||||
if (SignalStore.donationsValues().getSubscriptionRequestCredential() == null) {
|
||||
Log.i(TAG, "We have not started a redemption, but do not have a request credential. Possible that the subscription changed.", true);
|
||||
|
@ -144,7 +144,7 @@ public class SubscriptionKeepAliveJob extends BaseJob {
|
|||
}
|
||||
|
||||
Log.i(TAG, "We have a request credential and have not yet turned it into a redeemable token.", true);
|
||||
SubscriptionReceiptRequestResponseJob.createSubscriptionContinuationJobChain(true).enqueue();
|
||||
SubscriptionReceiptRequestResponseJob.createSubscriptionContinuationJobChain(true, -1L).enqueue();
|
||||
} else if (endOfCurrentPeriod > SignalStore.donationsValues().getSubscriptionEndOfPeriodRedeemed()) {
|
||||
if (SignalStore.donationsValues().getSubscriptionReceiptCredential() == null) {
|
||||
Log.i(TAG, "We have successfully started redemption but have no stored token. Possible that the subscription changed.", true);
|
||||
|
|
|
@ -47,13 +47,15 @@ public class SubscriptionReceiptRequestResponseJob extends BaseJob {
|
|||
private static final String DATA_REQUEST_BYTES = "data.request.bytes";
|
||||
private static final String DATA_SUBSCRIBER_ID = "data.subscriber.id";
|
||||
private static final String DATA_IS_FOR_KEEP_ALIVE = "data.is.for.keep.alive";
|
||||
private static final String DATA_UI_SESSION_KEY = "data.ui.session.key";
|
||||
|
||||
public static final Object MUTEX = new Object();
|
||||
|
||||
private final SubscriberId subscriberId;
|
||||
private final boolean isForKeepAlive;
|
||||
private final long uiSessionKey;
|
||||
|
||||
private static SubscriptionReceiptRequestResponseJob createJob(SubscriberId subscriberId, boolean isForKeepAlive) {
|
||||
private static SubscriptionReceiptRequestResponseJob createJob(SubscriberId subscriberId, boolean isForKeepAlive, long uiSessionKey) {
|
||||
return new SubscriptionReceiptRequestResponseJob(
|
||||
new Parameters
|
||||
.Builder()
|
||||
|
@ -64,18 +66,19 @@ public class SubscriptionReceiptRequestResponseJob extends BaseJob {
|
|||
.setMaxAttempts(Parameters.UNLIMITED)
|
||||
.build(),
|
||||
subscriberId,
|
||||
isForKeepAlive
|
||||
isForKeepAlive,
|
||||
uiSessionKey
|
||||
);
|
||||
}
|
||||
|
||||
public static JobManager.Chain createSubscriptionContinuationJobChain() {
|
||||
return createSubscriptionContinuationJobChain(false);
|
||||
public static JobManager.Chain createSubscriptionContinuationJobChain(long uiSessionKey) {
|
||||
return createSubscriptionContinuationJobChain(false, uiSessionKey);
|
||||
}
|
||||
|
||||
public static JobManager.Chain createSubscriptionContinuationJobChain(boolean isForKeepAlive) {
|
||||
public static JobManager.Chain createSubscriptionContinuationJobChain(boolean isForKeepAlive, long uiSessionKey) {
|
||||
Subscriber subscriber = SignalStore.donationsValues().requireSubscriber();
|
||||
SubscriptionReceiptRequestResponseJob requestReceiptJob = createJob(subscriber.getSubscriberId(), isForKeepAlive);
|
||||
DonationReceiptRedemptionJob redeemReceiptJob = DonationReceiptRedemptionJob.createJobForSubscription(requestReceiptJob.getErrorSource());
|
||||
SubscriptionReceiptRequestResponseJob requestReceiptJob = createJob(subscriber.getSubscriberId(), isForKeepAlive, uiSessionKey);
|
||||
DonationReceiptRedemptionJob redeemReceiptJob = DonationReceiptRedemptionJob.createJobForSubscription(requestReceiptJob.getErrorSource(), uiSessionKey);
|
||||
RefreshOwnProfileJob refreshOwnProfileJob = RefreshOwnProfileJob.forSubscription();
|
||||
MultiDeviceProfileContentUpdateJob multiDeviceProfileContentUpdateJob = new MultiDeviceProfileContentUpdateJob();
|
||||
|
||||
|
@ -88,17 +91,20 @@ public class SubscriptionReceiptRequestResponseJob extends BaseJob {
|
|||
|
||||
private SubscriptionReceiptRequestResponseJob(@NonNull Parameters parameters,
|
||||
@NonNull SubscriberId subscriberId,
|
||||
boolean isForKeepAlive)
|
||||
boolean isForKeepAlive,
|
||||
long uiSessionKey)
|
||||
{
|
||||
super(parameters);
|
||||
this.subscriberId = subscriberId;
|
||||
this.isForKeepAlive = isForKeepAlive;
|
||||
this.uiSessionKey = uiSessionKey;
|
||||
}
|
||||
|
||||
@Override
|
||||
public @Nullable byte[] serialize() {
|
||||
JsonJobData.Builder builder = new JsonJobData.Builder().putBlobAsString(DATA_SUBSCRIBER_ID, subscriberId.getBytes())
|
||||
.putBoolean(DATA_IS_FOR_KEEP_ALIVE, isForKeepAlive);
|
||||
.putBoolean(DATA_IS_FOR_KEEP_ALIVE, isForKeepAlive)
|
||||
.putLong(DATA_UI_SESSION_KEY, uiSessionKey);
|
||||
|
||||
return builder.serialize();
|
||||
}
|
||||
|
@ -189,7 +195,7 @@ public class SubscriptionReceiptRequestResponseJob extends BaseJob {
|
|||
ReceiptCredential receiptCredential = getReceiptCredential(requestContext, response.getResult().get());
|
||||
|
||||
if (!isCredentialValid(subscription, receiptCredential)) {
|
||||
DonationError.routeDonationError(context, DonationError.genericBadgeRedemptionFailure(getErrorSource()));
|
||||
DonationError.routeBackgroundError(context, uiSessionKey, DonationError.genericBadgeRedemptionFailure(getErrorSource()));
|
||||
throw new IOException("Could not validate receipt credential");
|
||||
}
|
||||
|
||||
|
@ -215,7 +221,7 @@ public class SubscriptionReceiptRequestResponseJob extends BaseJob {
|
|||
return activeSubscription.getResult().get();
|
||||
} else if (activeSubscription.getApplicationError().isPresent()) {
|
||||
Log.w(TAG, "Unrecoverable error getting the user's current subscription. Failing.", activeSubscription.getApplicationError().get(), true);
|
||||
DonationError.routeDonationError(context, DonationError.genericBadgeRedemptionFailure(getErrorSource()));
|
||||
DonationError.routeBackgroundError(context, uiSessionKey, DonationError.genericBadgeRedemptionFailure(getErrorSource()));
|
||||
throw new IOException(activeSubscription.getApplicationError().get());
|
||||
} else {
|
||||
throw new RetryableException();
|
||||
|
@ -252,18 +258,18 @@ public class SubscriptionReceiptRequestResponseJob extends BaseJob {
|
|||
throw new RetryableException();
|
||||
case 400:
|
||||
Log.w(TAG, "Receipt credential request failed to validate.", response.getApplicationError().get(), true);
|
||||
DonationError.routeDonationError(context, DonationError.genericBadgeRedemptionFailure(getErrorSource()));
|
||||
DonationError.routeBackgroundError(context, uiSessionKey, DonationError.genericBadgeRedemptionFailure(getErrorSource()));
|
||||
throw new Exception(response.getApplicationError().get());
|
||||
case 402:
|
||||
Log.w(TAG, "Payment looks like a failure but may be retried.", response.getApplicationError().get(), true);
|
||||
throw new RetryableException();
|
||||
case 403:
|
||||
Log.w(TAG, "SubscriberId password mismatch or account auth was present.", response.getApplicationError().get(), true);
|
||||
DonationError.routeDonationError(context, DonationError.genericBadgeRedemptionFailure(getErrorSource()));
|
||||
DonationError.routeBackgroundError(context, uiSessionKey, DonationError.genericBadgeRedemptionFailure(getErrorSource()));
|
||||
throw new Exception(response.getApplicationError().get());
|
||||
case 404:
|
||||
Log.w(TAG, "SubscriberId not found or misformed.", response.getApplicationError().get(), true);
|
||||
DonationError.routeDonationError(context, DonationError.genericBadgeRedemptionFailure(getErrorSource()));
|
||||
DonationError.routeBackgroundError(context, uiSessionKey, DonationError.genericBadgeRedemptionFailure(getErrorSource()));
|
||||
throw new Exception(response.getApplicationError().get());
|
||||
case 409:
|
||||
onAlreadyRedeemed(response);
|
||||
|
@ -321,7 +327,7 @@ public class SubscriptionReceiptRequestResponseJob extends BaseJob {
|
|||
}
|
||||
|
||||
Log.w(TAG, "Not for a keep-alive and we have a charge failure. Routing a payment setup error...", true);
|
||||
DonationError.routeDonationError(context, paymentSetupError);
|
||||
DonationError.routeBackgroundError(context, uiSessionKey, paymentSetupError);
|
||||
} else if (chargeFailure != null && processor == ActiveSubscription.Processor.BRAINTREE) {
|
||||
Log.d(TAG, "PayPal charge failure detected: " + chargeFailure, true);
|
||||
|
||||
|
@ -359,10 +365,10 @@ public class SubscriptionReceiptRequestResponseJob extends BaseJob {
|
|||
}
|
||||
|
||||
Log.w(TAG, "Not for a keep-alive and we have a charge failure. Routing a payment setup error...", true);
|
||||
DonationError.routeDonationError(context, paymentSetupError);
|
||||
DonationError.routeBackgroundError(context, uiSessionKey, paymentSetupError);
|
||||
} else {
|
||||
Log.d(TAG, "Not for a keep-alive and we have a failure status. Routing a payment setup error...", true);
|
||||
DonationError.routeDonationError(context, new DonationError.PaymentSetupError.GenericError(
|
||||
DonationError.routeBackgroundError(context, uiSessionKey, new DonationError.PaymentSetupError.GenericError(
|
||||
getErrorSource(),
|
||||
new Exception("Got a failure status from the subscription object.")
|
||||
));
|
||||
|
@ -378,7 +384,7 @@ public class SubscriptionReceiptRequestResponseJob extends BaseJob {
|
|||
setOutputData(new JsonJobData.Builder().putBoolean(DonationReceiptRedemptionJob.INPUT_KEEP_ALIVE_409, true).serialize());
|
||||
} else {
|
||||
Log.w(TAG, "Latest paid receipt on subscription already redeemed with a different request credential.", response.getApplicationError().get(), true);
|
||||
DonationError.routeDonationError(context, DonationError.genericBadgeRedemptionFailure(getErrorSource()));
|
||||
DonationError.routeBackgroundError(context, uiSessionKey, DonationError.genericBadgeRedemptionFailure(getErrorSource()));
|
||||
throw new Exception(response.getApplicationError().get());
|
||||
}
|
||||
}
|
||||
|
@ -429,6 +435,7 @@ public class SubscriptionReceiptRequestResponseJob extends BaseJob {
|
|||
boolean isForKeepAlive = data.getBooleanOrDefault(DATA_IS_FOR_KEEP_ALIVE, false);
|
||||
String requestString = data.getStringOrDefault(DATA_REQUEST_BYTES, null);
|
||||
byte[] requestContextBytes = requestString != null ? Base64.decodeOrThrow(requestString) : null;
|
||||
long uiSessionKey = data.getLongOrDefault(DATA_UI_SESSION_KEY, -1L);
|
||||
|
||||
ReceiptCredentialRequestContext requestContext;
|
||||
if (requestContextBytes != null && SignalStore.donationsValues().getSubscriptionRequestCredential() == null) {
|
||||
|
@ -441,7 +448,7 @@ public class SubscriptionReceiptRequestResponseJob extends BaseJob {
|
|||
}
|
||||
}
|
||||
|
||||
return new SubscriptionReceiptRequestResponseJob(parameters, subscriberId, isForKeepAlive);
|
||||
return new SubscriptionReceiptRequestResponseJob(parameters, subscriberId, isForKeepAlive, uiSessionKey);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -42,6 +42,9 @@
|
|||
<action
|
||||
android:id="@+id/action_donateToSignalFragment_to_paypalPaymentInProgressFragment"
|
||||
app:destination="@id/paypalPaymentInProgressFragment" />
|
||||
<action
|
||||
android:id="@+id/action_donateToSignalFragment_to_bankTransferMandateFragment"
|
||||
app:destination="@id/bankTransferMandateFragment" />
|
||||
|
||||
</fragment>
|
||||
|
||||
|
@ -206,4 +209,32 @@
|
|||
app:nullable="false" />
|
||||
</dialog>
|
||||
|
||||
<fragment
|
||||
android:id="@+id/bankTransferMandateFragment"
|
||||
android:name="org.thoughtcrime.securesms.components.settings.app.subscription.donate.transfer.mandate.BankTransferMandateFragment"
|
||||
android:label="bank_transfer_mandate_fragment">
|
||||
|
||||
<argument
|
||||
android:name="request"
|
||||
app:argType="org.thoughtcrime.securesms.components.settings.app.subscription.donate.gateway.GatewayRequest"
|
||||
app:nullable="false" />
|
||||
<action
|
||||
android:id="@+id/action_bankTransferMandateFragment_to_bankTransferDetailsFragment"
|
||||
app:destination="@id/bankTransferDetailsFragment" />
|
||||
</fragment>
|
||||
|
||||
<fragment
|
||||
android:id="@+id/bankTransferDetailsFragment"
|
||||
android:name="org.thoughtcrime.securesms.components.settings.app.subscription.donate.transfer.details.BankTransferDetailsFragment"
|
||||
android:label="bank_transfer_details_fragment">
|
||||
|
||||
<argument
|
||||
android:name="request"
|
||||
app:argType="org.thoughtcrime.securesms.components.settings.app.subscription.donate.gateway.GatewayRequest"
|
||||
app:nullable="false" />
|
||||
<action
|
||||
android:id="@+id/action_bankTransferDetailsFragment_to_stripePaymentInProgressFragment"
|
||||
app:destination="@id/stripePaymentInProgressFragment" />
|
||||
</fragment>
|
||||
|
||||
</navigation>
|
|
@ -62,6 +62,9 @@
|
|||
<action
|
||||
android:id="@+id/action_giftFlowConfirmationFragment_to_paypalPaymentInProgressFragment"
|
||||
app:destination="@id/paypalPaymentInProgressFragment" />
|
||||
<action
|
||||
android:id="@+id/action_giftFlowConfirmationFragment_to_bankTransferMandateFragment"
|
||||
app:destination="@id/bankTransferMandateFragment" />
|
||||
</fragment>
|
||||
|
||||
<dialog
|
||||
|
@ -186,4 +189,29 @@
|
|||
app:argType="org.thoughtcrime.securesms.components.settings.app.subscription.donate.gateway.GatewayRequest"
|
||||
app:nullable="false" />
|
||||
</dialog>
|
||||
|
||||
<fragment
|
||||
android:id="@+id/bankTransferMandateFragment"
|
||||
android:name="org.thoughtcrime.securesms.components.settings.app.subscription.donate.transfer.mandate.BankTransferMandateFragment"
|
||||
android:label="bank_transfer_mandate_fragment">
|
||||
|
||||
<argument
|
||||
android:name="request"
|
||||
app:argType="org.thoughtcrime.securesms.components.settings.app.subscription.donate.gateway.GatewayRequest"
|
||||
app:nullable="false" />
|
||||
<action
|
||||
android:id="@+id/action_bankTransferMandateFragment_to_bankTransferDetailsFragment"
|
||||
app:destination="@id/bankTransferDetailsFragment" />
|
||||
</fragment>
|
||||
|
||||
<fragment
|
||||
android:id="@+id/bankTransferDetailsFragment"
|
||||
android:name="org.thoughtcrime.securesms.components.settings.app.subscription.donate.transfer.details.BankTransferDetailsFragment"
|
||||
android:label="bank_transfer_details_fragment">
|
||||
|
||||
<argument
|
||||
android:name="request"
|
||||
app:argType="org.thoughtcrime.securesms.components.settings.app.subscription.donate.gateway.GatewayRequest"
|
||||
app:nullable="false" />
|
||||
</fragment>
|
||||
</navigation>
|
|
@ -5808,11 +5808,49 @@
|
|||
<item quantity="one">Get a %1$s badge for %2$d day</item>
|
||||
<item quantity="other">Get a %1$s badge for %2$d days</item>
|
||||
</plurals>
|
||||
<!-- Button label for paying with a bank transfer -->
|
||||
<string name="GatewaySelectorBottomSheet__bank_transfer">Bank transfer</string>
|
||||
<!-- Button label for paying with a credit card -->
|
||||
<string name="GatewaySelectorBottomSheet__credit_or_debit_card">Credit or debit card</string>
|
||||
<!-- Sheet summary when giving donating for a friend -->
|
||||
<string name="GatewaySelectorBottomSheet__donate_for_a_friend">Donate for a friend</string>
|
||||
|
||||
<!-- BankTransferMandateFragment -->
|
||||
<!-- Title of screen displaying the bank transfer mandate -->
|
||||
<string name="BankTransferMandateFragment__bank_transfer">Bank Transfer</string>
|
||||
<!-- Subtitle of screen displaying the bank transfer mandate, placeholder is 'Learn more' -->
|
||||
<string name="BankTransferMandateFragment__stripe_processes_donations">Stripe processes donations made to Signal. Signal does not collect or store your personal information. %1$s</string>
|
||||
<!-- Subtitle learn more of screen displaying bank transfer mandate -->
|
||||
<string name="BankTransferMandateFragment__learn_more">Learn more</string>
|
||||
<!-- Button label to continue with transfer -->
|
||||
<string name="BankTransferMandateFragment__continue">Continue</string>
|
||||
|
||||
<!-- BankTransferDetailsFragment -->
|
||||
<!-- Subtext explaining how email is used. Placeholder is 'Learn more' -->
|
||||
<string name="BankTransferDetailsFragment__enter_your_bank_details">Enter your bank details and email address. Your email is used by Stripe to send you updates about your donation. %1$s</string>
|
||||
<!-- Subtext learn more link text -->
|
||||
<string name="BankTransferDetailsFragment__learn_more">Learn more</string>
|
||||
<!-- Text field label for name on bank account -->
|
||||
<string name="BankTransferDetailsFragment__name_on_bank_account">Name on bank account</string>
|
||||
<!-- Text field label for IBAN -->
|
||||
<string name="BankTransferDetailsFragment__iban">IBAN</string>
|
||||
<!-- Text field label for email -->
|
||||
<string name="BankTransferDetailsFragment__email">Email</string>
|
||||
<!-- Text label for button to show user how to find their IBAN number -->
|
||||
<string name="BankTransferDetailsFragment__find_account_numbers">Find account numbers</string>
|
||||
<!-- Donate button label for monthly subscription -->
|
||||
<string name="BankTransferDetailsFragment__donate_s_month">Donate %1$s/month</string>
|
||||
<!-- Donate button label for one-time -->
|
||||
<string name="BankTransferDetailsFragment__donate_s">Donate %1$s</string>
|
||||
<!-- Error label for IBAN field when number is too short -->
|
||||
<string name="BankTransferDetailsFragment__iban_nubmer_is_too_short">IBAN number is too short</string>
|
||||
<!-- Error label for IBAN field when number is too long -->
|
||||
<string name="BankTransferDetailsFragment__iban_nubmer_is_too_long">IBAN number is too long</string>
|
||||
<!-- Error label for IBAN field when country is not supported -->
|
||||
<string name="BankTransferDetailsFragment__iban_country_code_is_not_supported">IBAN country code is not supported</string>
|
||||
<!-- Error label for IBAN field when number is invalid -->
|
||||
<string name="BankTransferDetailsFragment__invalid_iban_nubmer">Invalid IBAN number</string>
|
||||
|
||||
<!-- StripePaymentInProgressFragment -->
|
||||
<string name="StripePaymentInProgressFragment__cancelling">Cancelling…</string>
|
||||
|
||||
|
|
|
@ -0,0 +1,66 @@
|
|||
/*
|
||||
* Copyright 2023 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.components.settings.app.subscription.donate.transfer.details
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Test
|
||||
|
||||
class IBANValidatorTest {
|
||||
companion object {
|
||||
private const val VALID_IBAN = "GB82WEST12345698765432"
|
||||
private const val INVALID_IBAN = "GB82WEST12335698765432"
|
||||
private const val INVALID_COUNTRY = "US82WEST12335698765432"
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Given a blank IBAN, when I validate, then I expect POTENTIALLY_VALID`() {
|
||||
val actual = IBANValidator.validate("", false)
|
||||
|
||||
assertEquals(IBANValidator.Validity.POTENTIALLY_VALID, actual)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Given a valid IBAN, when I validate, then I expect COMPLETELY_VALID`() {
|
||||
val actual = IBANValidator.validate(VALID_IBAN, false)
|
||||
|
||||
assertEquals(IBANValidator.Validity.COMPLETELY_VALID, actual)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Given an invalid IBAN, when I validate, then I expect INVALID_MOD_97`() {
|
||||
val actual = IBANValidator.validate(INVALID_IBAN, false)
|
||||
|
||||
assertEquals(IBANValidator.Validity.INVALID_MOD_97, actual)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Given an invalid country, when I validate, then I expect INVALID_COUNTRY`() {
|
||||
val actual = IBANValidator.validate(INVALID_COUNTRY, false)
|
||||
|
||||
assertEquals(IBANValidator.Validity.INVALID_COUNTRY, actual)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Given too short and not focused, when I validate, then I expect TOO_SHORT`() {
|
||||
val actual = IBANValidator.validate(VALID_IBAN.dropLast(5), false)
|
||||
|
||||
assertEquals(IBANValidator.Validity.TOO_SHORT, actual)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Given too short and focused, when I validate, then I expect POTENTIALLY_VALID`() {
|
||||
val actual = IBANValidator.validate(VALID_IBAN.dropLast(5), true)
|
||||
|
||||
assertEquals(IBANValidator.Validity.POTENTIALLY_VALID, actual)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Given too long, when I validate, then I expect TOO_LONG`() {
|
||||
val actual = IBANValidator.validate(VALID_IBAN + "A", false)
|
||||
|
||||
assertEquals(IBANValidator.Validity.TOO_LONG, actual)
|
||||
}
|
||||
}
|
|
@ -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 SEPADebitPaymentSource(
|
||||
val sepaDebitData: StripeApi.SEPADebitData
|
||||
) : StripeApi.PaymentSource {
|
||||
override val type: PaymentSourceType = PaymentSourceType.Stripe.SEPADebit
|
||||
|
||||
override fun parameterize(): JSONObject = error("SEPA Debit does not support tokenization")
|
||||
|
||||
override fun getTokenId(): String = error("SEPA Debit does not support tokenization")
|
||||
override fun email(): String? = null
|
||||
}
|
|
@ -27,7 +27,8 @@ class StripeApi(
|
|||
private val configuration: Configuration,
|
||||
private val paymentIntentFetcher: PaymentIntentFetcher,
|
||||
private val setupIntentHelper: SetupIntentHelper,
|
||||
private val okHttpClient: OkHttpClient
|
||||
private val okHttpClient: OkHttpClient,
|
||||
private val userAgent: String
|
||||
) {
|
||||
|
||||
private val objectMapper = jsonMapper {
|
||||
|
@ -119,6 +120,12 @@ class StripeApi(
|
|||
"return_url" to RETURN_URL_3DS
|
||||
)
|
||||
|
||||
if (paymentSource.type == PaymentSourceType.Stripe.SEPADebit) {
|
||||
parameters["mandate_data[customer_acceptance][type]"] = "online"
|
||||
parameters["mandate_data[customer_acceptance][online][ip_address]"] = "0.0.0.0"
|
||||
parameters["mandate_data[customer_acceptance][online][user_agent]"] = userAgent
|
||||
}
|
||||
|
||||
val (nextActionUri, returnUri) = postForm("payment_intents/${paymentIntent.intentId}/confirm", parameters).use { response ->
|
||||
getNextAction(response)
|
||||
}
|
||||
|
@ -198,6 +205,10 @@ class StripeApi(
|
|||
}.subscribeOn(Schedulers.io())
|
||||
}
|
||||
|
||||
fun createPaymentSourceFromSEPADebitData(sepaDebitData: SEPADebitData): Single<PaymentSource> {
|
||||
return Single.just(SEPADebitPaymentSource(sepaDebitData))
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
private fun createPaymentSourceFromCardDataSync(cardData: CardData): PaymentSource {
|
||||
val parameters: Map<String, String> = mutableMapOf(
|
||||
|
@ -218,7 +229,13 @@ class StripeApi(
|
|||
}
|
||||
|
||||
private fun createPaymentMethodAndParseId(paymentSource: PaymentSource): String {
|
||||
return createPaymentMethod(paymentSource).use { response ->
|
||||
val paymentMethodResponse = if (paymentSource is SEPADebitPaymentSource) {
|
||||
createPaymentMethodForSEPADebit(paymentSource)
|
||||
} else {
|
||||
createPaymentMethodForToken(paymentSource)
|
||||
}
|
||||
|
||||
return paymentMethodResponse.use { response ->
|
||||
val body = response.body()
|
||||
if (body != null) {
|
||||
val paymentMethodObject = body.string().replace("\n", "").let { JSONObject(it) }
|
||||
|
@ -229,7 +246,18 @@ class StripeApi(
|
|||
}
|
||||
}
|
||||
|
||||
private fun createPaymentMethod(paymentSource: PaymentSource): Response {
|
||||
private fun createPaymentMethodForSEPADebit(paymentSource: SEPADebitPaymentSource): Response {
|
||||
val parameters = mutableMapOf(
|
||||
"type" to "sepa_debit",
|
||||
"sepa_debit[iban]" to paymentSource.sepaDebitData.iban,
|
||||
"billing_details[email]" to paymentSource.sepaDebitData.email,
|
||||
"billing_details[name]" to paymentSource.sepaDebitData.name
|
||||
)
|
||||
|
||||
return postForm("payment_methods", parameters)
|
||||
}
|
||||
|
||||
private fun createPaymentMethodForToken(paymentSource: PaymentSource): Response {
|
||||
val tokenId = paymentSource.getTokenId()
|
||||
val parameters = mutableMapOf(
|
||||
"card[token]" to tokenId,
|
||||
|
@ -532,6 +560,13 @@ class StripeApi(
|
|||
val cvc: Int
|
||||
) : Parcelable
|
||||
|
||||
@Parcelize
|
||||
data class SEPADebitData(
|
||||
val iban: String,
|
||||
val name: String,
|
||||
val email: String
|
||||
) : Parcelable
|
||||
|
||||
interface PaymentSource {
|
||||
val type: PaymentSourceType
|
||||
fun parameterize(): JSONObject
|
||||
|
|
|
@ -17,6 +17,7 @@ import org.whispersystems.signalservice.api.util.CredentialsProvider;
|
|||
import org.whispersystems.signalservice.internal.EmptyResponse;
|
||||
import org.whispersystems.signalservice.internal.ServiceResponse;
|
||||
import org.whispersystems.signalservice.internal.configuration.SignalServiceConfiguration;
|
||||
import org.whispersystems.signalservice.internal.push.BankMandate;
|
||||
import org.whispersystems.signalservice.internal.push.DonationProcessor;
|
||||
import org.whispersystems.signalservice.internal.push.DonationsConfiguration;
|
||||
import org.whispersystems.signalservice.internal.push.PushServiceSocket;
|
||||
|
@ -38,17 +39,18 @@ public class DonationsService {
|
|||
|
||||
private final PushServiceSocket pushServiceSocket;
|
||||
|
||||
private final AtomicReference<CacheEntry> donationsConfigurationCache = new AtomicReference<>(null);
|
||||
private final AtomicReference<CacheEntry<DonationsConfiguration>> donationsConfigurationCache = new AtomicReference<>(null);
|
||||
private final AtomicReference<CacheEntry<BankMandate>> sepaBankMandateCache = new AtomicReference<>(null);
|
||||
|
||||
private static class CacheEntry {
|
||||
private final DonationsConfiguration donationsConfiguration;
|
||||
private final long expiresAt;
|
||||
private final Locale locale;
|
||||
private static class CacheEntry<T> {
|
||||
private final T cachedValue;
|
||||
private final long expiresAt;
|
||||
private final Locale locale;
|
||||
|
||||
private CacheEntry(DonationsConfiguration donationsConfiguration, long expiresAt, Locale locale) {
|
||||
this.donationsConfiguration = donationsConfiguration;
|
||||
this.expiresAt = expiresAt;
|
||||
this.locale = locale;
|
||||
private CacheEntry(T cachedValue, long expiresAt, Locale locale) {
|
||||
this.cachedValue = cachedValue;
|
||||
this.expiresAt = expiresAt;
|
||||
this.locale = locale;
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -107,22 +109,42 @@ public class DonationsService {
|
|||
}
|
||||
|
||||
public ServiceResponse<DonationsConfiguration> getDonationsConfiguration(Locale locale) {
|
||||
CacheEntry cacheEntryOutsideLock = donationsConfigurationCache.get();
|
||||
return getCachedValue(
|
||||
locale,
|
||||
donationsConfigurationCache,
|
||||
pushServiceSocket::getDonationsConfiguration
|
||||
);
|
||||
}
|
||||
|
||||
public ServiceResponse<BankMandate> getBankMandate(Locale locale) {
|
||||
return getCachedValue(
|
||||
locale,
|
||||
sepaBankMandateCache,
|
||||
l -> pushServiceSocket.getBankMandate(l, "SEPA_DEBIT")
|
||||
);
|
||||
}
|
||||
|
||||
private <T> ServiceResponse<T> getCachedValue(Locale locale,
|
||||
AtomicReference<CacheEntry<T>> cachedValueReference,
|
||||
CacheEntryValueProducer<T> cacheEntryValueProducer
|
||||
)
|
||||
{
|
||||
CacheEntry<T> cacheEntryOutsideLock = cachedValueReference.get();
|
||||
if (isNewCacheEntryRequired(cacheEntryOutsideLock, locale)) {
|
||||
synchronized (this) {
|
||||
CacheEntry cacheEntryInLock = donationsConfigurationCache.get();
|
||||
CacheEntry<T> cacheEntryInLock = cachedValueReference.get();
|
||||
if (isNewCacheEntryRequired(cacheEntryInLock, locale)) {
|
||||
return wrapInServiceResponse(() -> {
|
||||
DonationsConfiguration donationsConfiguration = pushServiceSocket.getDonationsConfiguration(locale);
|
||||
donationsConfigurationCache.set(new CacheEntry(donationsConfiguration, System.currentTimeMillis() + TimeUnit.DAYS.toMillis(1), locale));
|
||||
return new Pair<>(donationsConfiguration, 200);
|
||||
T value = cacheEntryValueProducer.produce(locale);
|
||||
cachedValueReference.set(new CacheEntry<>(value, System.currentTimeMillis() + TimeUnit.DAYS.toMillis(1), locale));
|
||||
return new Pair<>(value, 200);
|
||||
});
|
||||
} else {
|
||||
return wrapInServiceResponse(() -> new Pair<>(cacheEntryInLock.donationsConfiguration, 200));
|
||||
return wrapInServiceResponse(() -> new Pair<>(cacheEntryInLock.cachedValue, 200));
|
||||
}
|
||||
}
|
||||
} else {
|
||||
return wrapInServiceResponse(() -> new Pair<>(cacheEntryOutsideLock.donationsConfiguration, 200));
|
||||
return wrapInServiceResponse(() -> new Pair<>(cacheEntryOutsideLock.cachedValue, 200));
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -219,13 +241,13 @@ public class DonationsService {
|
|||
* 400 - request error
|
||||
* 409 - level requires a valid currency/amount combination that does not match
|
||||
*
|
||||
* @param locale User locale for proper language presentation
|
||||
* @param currencyCode 3 letter currency code of the desired currency
|
||||
* @param amount Stringified minimum precision amount
|
||||
* @param level The badge level to purchase
|
||||
* @param returnUrl The 'return' url after a successful login and confirmation
|
||||
* @param cancelUrl The 'cancel' url for a cancelled confirmation
|
||||
* @return Wrapped response with either an error code or a payment id and approval URL
|
||||
* @param locale User locale for proper language presentation
|
||||
* @param currencyCode 3 letter currency code of the desired currency
|
||||
* @param amount Stringified minimum precision amount
|
||||
* @param level The badge level to purchase
|
||||
* @param returnUrl The 'return' url after a successful login and confirmation
|
||||
* @param cancelUrl The 'cancel' url for a cancelled confirmation
|
||||
* @return Wrapped response with either an error code or a payment id and approval URL
|
||||
*/
|
||||
public ServiceResponse<PayPalCreatePaymentIntentResponse> createPayPalOneTimePaymentIntent(Locale locale,
|
||||
String currencyCode,
|
||||
|
@ -254,13 +276,13 @@ public class DonationsService {
|
|||
* 400 - request error
|
||||
* 409 - level requires a valid currency/amount combination that does not match
|
||||
*
|
||||
* @param currency 3 letter currency code of the desired currency
|
||||
* @param amount Stringified minimum precision amount
|
||||
* @param level The badge level to purchase
|
||||
* @param payerId Passed as a URL parameter back to returnUrl
|
||||
* @param paymentId Passed as a URL parameter back to returnUrl
|
||||
* @param paymentToken Passed as a URL parameter back to returnUrl
|
||||
* @return Wrapped response with either an error code or a payment id
|
||||
* @param currency 3 letter currency code of the desired currency
|
||||
* @param amount Stringified minimum precision amount
|
||||
* @param level The badge level to purchase
|
||||
* @param payerId Passed as a URL parameter back to returnUrl
|
||||
* @param paymentId Passed as a URL parameter back to returnUrl
|
||||
* @param paymentToken Passed as a URL parameter back to returnUrl
|
||||
* @return Wrapped response with either an error code or a payment id
|
||||
*/
|
||||
public ServiceResponse<PayPalConfirmPaymentIntentResponse> confirmPayPalOneTimePaymentIntent(String currency,
|
||||
String amount,
|
||||
|
@ -277,22 +299,23 @@ public class DonationsService {
|
|||
|
||||
/**
|
||||
* Sets up a payment method via PayPal for recurring charges.
|
||||
*
|
||||
* <p>
|
||||
* Response Codes
|
||||
* 200 - success
|
||||
* 403 - subscriberId password mismatches OR account authentication is present
|
||||
* 404 - subscriberId is not found or malformed
|
||||
*
|
||||
* @param locale User locale
|
||||
* @param subscriberId User subscriber id
|
||||
* @param returnUrl A success URL
|
||||
* @param cancelUrl A cancel URL
|
||||
* @return A response with an approval url and token
|
||||
* @param locale User locale
|
||||
* @param subscriberId User subscriber id
|
||||
* @param returnUrl A success URL
|
||||
* @param cancelUrl A cancel URL
|
||||
* @return A response with an approval url and token
|
||||
*/
|
||||
public ServiceResponse<PayPalCreatePaymentMethodResponse> createPayPalPaymentMethod(Locale locale,
|
||||
SubscriberId subscriberId,
|
||||
String returnUrl,
|
||||
String cancelUrl) {
|
||||
String cancelUrl)
|
||||
{
|
||||
return wrapInServiceResponse(() -> {
|
||||
PayPalCreatePaymentMethodResponse response = pushServiceSocket.createPayPalPaymentMethod(locale, subscriberId.serialize(), returnUrl, cancelUrl);
|
||||
return new Pair<>(response, 200);
|
||||
|
@ -301,7 +324,7 @@ public class DonationsService {
|
|||
|
||||
/**
|
||||
* Sets the given payment method as the default in PayPal
|
||||
*
|
||||
* <p>
|
||||
* Response Codes
|
||||
* 200 - success
|
||||
* 403 - subscriberId password mismatches OR account authentication is present
|
||||
|
@ -338,11 +361,15 @@ public class DonationsService {
|
|||
}
|
||||
}
|
||||
|
||||
private boolean isNewCacheEntryRequired(CacheEntry cacheEntry, Locale locale) {
|
||||
private <T> boolean isNewCacheEntryRequired(CacheEntry<T> cacheEntry, Locale locale) {
|
||||
return cacheEntry == null || cacheEntry.expiresAt < System.currentTimeMillis() || !Objects.equals(locale, cacheEntry.locale);
|
||||
}
|
||||
|
||||
private interface Producer<T> {
|
||||
Pair<T, Integer> produce() throws IOException;
|
||||
}
|
||||
|
||||
interface CacheEntryValueProducer<T> {
|
||||
T produce(Locale locale) throws IOException;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -11,4 +11,4 @@ import com.fasterxml.jackson.annotation.JsonProperty
|
|||
/**
|
||||
* Localized bank transfer mandate.
|
||||
*/
|
||||
class BankMandate @JsonCreator constructor(@JsonProperty("mandate") mandate: String)
|
||||
class BankMandate @JsonCreator constructor(@JsonProperty("mandate") val mandate: String)
|
||||
|
|
Loading…
Add table
Reference in a new issue