Add initial PayPal implementation behind a feature flag.

This commit is contained in:
Alex Hart
2022-11-30 12:43:46 -04:00
committed by Cody Henthorne
parent b70b4fac91
commit 979f87db78
47 changed files with 1382 additions and 144 deletions

View File

@@ -282,6 +282,10 @@ class GiftFlowConfirmationFragment :
findNavController().safeNavigate(GiftFlowConfirmationFragmentDirections.actionGiftFlowConfirmationFragmentToStripePaymentInProgressFragment(DonationProcessorAction.PROCESS_NEW_DONATION, gatewayRequest))
}
override fun navigateToPayPalPaymentInProgress(gatewayRequest: GatewayRequest) {
findNavController().safeNavigate(GiftFlowConfirmationFragmentDirections.actionGiftFlowConfirmationFragmentToPaypalPaymentInProgressFragment(DonationProcessorAction.PROCESS_NEW_DONATION, gatewayRequest))
}
override fun navigateToCreditCardForm(gatewayRequest: GatewayRequest) {
findNavController().safeNavigate(GiftFlowConfirmationFragmentDirections.actionGiftFlowConfirmationFragmentToCreditCardFragment(gatewayRequest))
}

View File

@@ -32,7 +32,7 @@ object InAppDonations {
* Whether the user is in a region that supports PayPal, based off local phone number.
*/
fun isPayPalAvailable(): Boolean {
return false
return FeatureFlags.paypalDonations() && !LocaleFeatureFlags.isPayPalDisabled()
}
/**

View File

@@ -5,6 +5,7 @@ import io.reactivex.rxjava3.core.Single
import io.reactivex.rxjava3.schedulers.Schedulers
import org.signal.core.util.logging.Log
import org.signal.core.util.money.FiatMoney
import org.signal.donations.PaymentSourceType
import org.thoughtcrime.securesms.badges.Badges
import org.thoughtcrime.securesms.badges.models.Badge
import org.thoughtcrime.securesms.components.settings.app.subscription.boost.Boost
@@ -21,6 +22,7 @@ import org.thoughtcrime.securesms.util.PlatformCurrencyUtil
import org.whispersystems.signalservice.api.profiles.SignalServiceProfile
import org.whispersystems.signalservice.api.services.DonationsService
import org.whispersystems.signalservice.internal.ServiceResponse
import org.whispersystems.signalservice.internal.push.DonationProcessor
import java.math.BigDecimal
import java.util.Currency
import java.util.Locale
@@ -31,6 +33,16 @@ class OneTimeDonationRepository(private val donationsService: DonationsService)
companion object {
private val TAG = Log.tag(OneTimeDonationRepository::class.java)
fun <T> handleCreatePaymentIntentError(throwable: Throwable, badgeRecipient: RecipientId, paymentSourceType: PaymentSourceType): Single<T> {
return if (throwable is DonationError) {
Single.error(throwable)
} else {
val recipient = Recipient.resolved(badgeRecipient)
val errorSource = if (recipient.isSelf) DonationErrorSource.BOOST else DonationErrorSource.GIFT
Single.error(DonationError.getPaymentSetupError(errorSource, throwable, paymentSourceType))
}
}
}
fun getBoosts(): Single<Map<Currency, List<Boost>>> {
@@ -62,6 +74,7 @@ class OneTimeDonationRepository(private val donationsService: DonationsService)
badgeRecipient: RecipientId,
additionalMessage: String?,
badgeLevel: Long,
donationProcessor: DonationProcessor
): Completable {
val isBoost = badgeRecipient == Recipient.self().id
val donationErrorSource: DonationErrorSource = if (isBoost) DonationErrorSource.BOOST else DonationErrorSource.GIFT
@@ -81,9 +94,9 @@ class OneTimeDonationRepository(private val donationsService: DonationsService)
val countDownLatch = CountDownLatch(1)
var finalJobState: JobTracker.JobState? = null
val chain = if (isBoost) {
BoostReceiptRequestResponseJob.createJobChainForBoost(paymentIntentId)
BoostReceiptRequestResponseJob.createJobChainForBoost(paymentIntentId, donationProcessor)
} else {
BoostReceiptRequestResponseJob.createJobChainForGift(paymentIntentId, badgeRecipient, additionalMessage, badgeLevel)
BoostReceiptRequestResponseJob.createJobChainForGift(paymentIntentId, badgeRecipient, additionalMessage, badgeLevel, donationProcessor)
}
chain.enqueue { _, jobState ->

View File

@@ -0,0 +1,89 @@
package org.thoughtcrime.securesms.components.settings.app.subscription
import io.reactivex.rxjava3.core.Completable
import io.reactivex.rxjava3.core.Single
import io.reactivex.rxjava3.schedulers.Schedulers
import org.signal.core.util.money.FiatMoney
import org.signal.donations.PaymentSourceType
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.paypal.PayPalConfirmationResult
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.recipients.RecipientId
import org.whispersystems.signalservice.api.services.DonationsService
import org.whispersystems.signalservice.api.subscriptions.PayPalConfirmPaymentIntentResponse
import org.whispersystems.signalservice.api.subscriptions.PayPalCreatePaymentIntentResponse
import org.whispersystems.signalservice.api.subscriptions.PayPalCreatePaymentMethodResponse
import java.util.Locale
/**
* Repository that deals directly with PayPal API calls. Since we don't interact with the PayPal APIs directly (yet)
* we can do everything here in one place.
*/
class PayPalRepository(private val donationsService: DonationsService) {
companion object {
const val ONE_TIME_RETURN_URL = "https://signaldonations.org/return/onetime"
const val MONTHLY_RETURN_URL = "https://signaldonations.org/return/monthly"
const val CANCEL_URL = "https://signaldonations.org/cancel"
}
fun createOneTimePaymentIntent(
amount: FiatMoney,
badgeRecipient: RecipientId,
badgeLevel: Long
): Single<PayPalCreatePaymentIntentResponse> {
return Single.fromCallable {
donationsService
.createPayPalOneTimePaymentIntent(
Locale.getDefault(),
amount.currency.currencyCode,
amount.minimumUnitPrecisionString,
badgeLevel,
ONE_TIME_RETURN_URL,
CANCEL_URL
)
}
.flatMap { it.flattenResult() }
.onErrorResumeNext { OneTimeDonationRepository.handleCreatePaymentIntentError(it, badgeRecipient, PaymentSourceType.PayPal) }
.subscribeOn(Schedulers.io())
}
fun confirmOneTimePaymentIntent(
amount: FiatMoney,
badgeLevel: Long,
paypalConfirmationResult: PayPalConfirmationResult
): Single<PayPalConfirmPaymentIntentResponse> {
return Single.fromCallable {
donationsService
.confirmPayPalOneTimePaymentIntent(
amount.currency.currencyCode,
amount.minimumUnitPrecisionString,
badgeLevel,
paypalConfirmationResult.payerId,
paypalConfirmationResult.paymentId,
paypalConfirmationResult.paymentToken
)
}.flatMap { it.flattenResult() }.subscribeOn(Schedulers.io())
}
fun createPaymentMethod(): Single<PayPalCreatePaymentMethodResponse> {
return Single.fromCallable {
donationsService.createPayPalPaymentMethod(
Locale.getDefault(),
SignalStore.donationsValues().requireSubscriber().subscriberId,
MONTHLY_RETURN_URL,
CANCEL_URL
)
}.flatMap { it.flattenResult() }.subscribeOn(Schedulers.io())
}
fun setDefaultPaymentMethod(paymentMethodId: String): Completable {
return Single.fromCallable {
donationsService.setDefaultPayPalPaymentMethod(
SignalStore.donationsValues().requireSubscriber().subscriberId,
paymentMethodId
)
}.flatMap { it.flattenResult() }.ignoreElement().andThen {
SignalStore.donationsValues().setSubscriptionPaymentSourceType(PaymentSourceType.PayPal)
}.subscribeOn(Schedulers.io())
}
}

View File

@@ -9,9 +9,9 @@ import org.signal.core.util.concurrent.SignalExecutors
import org.signal.core.util.logging.Log
import org.signal.core.util.money.FiatMoney
import org.signal.donations.GooglePayApi
import org.signal.donations.PaymentSourceType
import org.signal.donations.StripeApi
import org.signal.donations.StripeIntentAccessor
import org.signal.donations.StripePaymentSourceType
import org.signal.donations.json.StripeIntentStatus
import org.thoughtcrime.securesms.components.settings.app.subscription.errors.DonationError
import org.thoughtcrime.securesms.components.settings.app.subscription.errors.DonationErrorSource
@@ -87,13 +87,13 @@ class StripeRepository(activity: Activity) : StripeApi.PaymentIntentFetcher, Str
price: FiatMoney,
badgeRecipient: RecipientId,
badgeLevel: Long,
paymentSourceType: StripePaymentSourceType
paymentSourceType: PaymentSourceType
): Single<StripeIntentAccessor> {
Log.d(TAG, "Creating payment intent for $price...", true)
return stripeApi.createPaymentIntent(price, badgeLevel)
.onErrorResumeNext {
handleCreatePaymentIntentError(it, badgeRecipient, paymentSourceType)
OneTimeDonationRepository.handleCreatePaymentIntentError(it, badgeRecipient, paymentSourceType)
}
.flatMap { result ->
val recipient = Recipient.resolved(badgeRecipient)
@@ -200,7 +200,7 @@ class StripeRepository(activity: Activity) : StripeApi.PaymentIntentFetcher, Str
fun setDefaultPaymentMethod(
paymentMethodId: String,
paymentSourceType: StripePaymentSourceType
paymentSourceType: PaymentSourceType
): Completable {
return Single.fromCallable {
Log.d(TAG, "Getting the subscriber...")
@@ -223,7 +223,7 @@ class StripeRepository(activity: Activity) : StripeApi.PaymentIntentFetcher, Str
Log.d(TAG, "Creating credit card payment source via Stripe api...")
return stripeApi.createPaymentSourceFromCardData(cardData).map {
when (it) {
is StripeApi.CreatePaymentSourceFromCardDataResult.Failure -> throw DonationError.getPaymentSetupError(donationErrorSource, it.reason, StripePaymentSourceType.CREDIT_CARD)
is StripeApi.CreatePaymentSourceFromCardDataResult.Failure -> throw DonationError.getPaymentSetupError(donationErrorSource, it.reason, PaymentSourceType.Stripe.CreditCard)
is StripeApi.CreatePaymentSourceFromCardDataResult.Success -> it.paymentSource
}
}
@@ -236,15 +236,5 @@ class StripeRepository(activity: Activity) : StripeApi.PaymentIntentFetcher, Str
companion object {
private val TAG = Log.tag(StripeRepository::class.java)
private fun <T> handleCreatePaymentIntentError(throwable: Throwable, badgeRecipient: RecipientId, paymentSourceType: StripePaymentSourceType): Single<T> {
return if (throwable is DonationError) {
Single.error(throwable)
} else {
val recipient = Recipient.resolved(badgeRecipient)
val errorSource = if (recipient.isSelf) DonationErrorSource.BOOST else DonationErrorSource.GIFT
Single.error(DonationError.getPaymentSetupError(errorSource, throwable, paymentSourceType))
}
}
}
}

View File

@@ -452,6 +452,15 @@ class DonateToSignalFragment :
findNavController().safeNavigate(DonateToSignalFragmentDirections.actionDonateToSignalFragmentToStripePaymentInProgressFragment(DonationProcessorAction.PROCESS_NEW_DONATION, gatewayRequest))
}
override fun navigateToPayPalPaymentInProgress(gatewayRequest: GatewayRequest) {
findNavController().safeNavigate(
DonateToSignalFragmentDirections.actionDonateToSignalFragmentToPaypalPaymentInProgressFragment(
DonationProcessorAction.PROCESS_NEW_DONATION,
gatewayRequest
)
)
}
override fun navigateToCreditCardForm(gatewayRequest: GatewayRequest) {
findNavController().safeNavigate(DonateToSignalFragmentDirections.actionDonateToSignalFragmentToCreditCardFragment(gatewayRequest))
}

View File

@@ -22,6 +22,7 @@ import org.thoughtcrime.securesms.components.settings.app.subscription.donate.ca
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.gateway.GatewayRequest
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.gateway.GatewayResponse
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.gateway.GatewaySelectorBottomSheet
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.paypal.PayPalPaymentInProgressFragment
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.stripe.StripePaymentInProgressFragment
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.stripe.StripePaymentInProgressViewModel
import org.thoughtcrime.securesms.components.settings.app.subscription.errors.DonationError
@@ -77,12 +78,17 @@ class DonationCheckoutDelegate(
val result: CreditCardResult = bundle.getParcelable(CreditCardFragment.REQUEST_KEY)!!
handleCreditCardResult(result)
}
fragment.setFragmentResultListener(PayPalPaymentInProgressFragment.REQUEST_KEY) { _, bundle ->
val result: DonationProcessorActionResult = bundle.getParcelable(PayPalPaymentInProgressFragment.REQUEST_KEY)!!
handleDonationProcessorActionResult(result)
}
}
private fun handleGatewaySelectionResponse(gatewayResponse: GatewayResponse) {
when (gatewayResponse.gateway) {
GatewayResponse.Gateway.GOOGLE_PAY -> launchGooglePay(gatewayResponse)
GatewayResponse.Gateway.PAYPAL -> error("PayPal is not currently supported.")
GatewayResponse.Gateway.PAYPAL -> launchPayPal(gatewayResponse)
GatewayResponse.Gateway.CREDIT_CARD -> launchCreditCard(gatewayResponse)
}
}
@@ -124,6 +130,14 @@ class DonationCheckoutDelegate(
}
}
private fun launchPayPal(gatewayResponse: GatewayResponse) {
if (InAppDonations.isPayPalAvailable()) {
callback.navigateToPayPalPaymentInProgress(gatewayResponse.request)
} else {
error("PayPal is not currently enabled.")
}
}
private fun launchGooglePay(gatewayResponse: GatewayResponse) {
viewModel.provideGatewayRequestForGooglePay(gatewayResponse.request)
donationPaymentComponent.stripeRepository.requestTokenFromGooglePay(
@@ -186,6 +200,7 @@ class DonationCheckoutDelegate(
interface Callback {
fun navigateToStripePaymentInProgress(gatewayRequest: GatewayRequest)
fun navigateToPayPalPaymentInProgress(gatewayRequest: GatewayRequest)
fun navigateToCreditCardForm(gatewayRequest: GatewayRequest)
fun onPaymentComplete(gatewayRequest: GatewayRequest)
fun onProcessorActionProcessed()

View File

@@ -19,6 +19,7 @@ import org.thoughtcrime.securesms.components.settings.app.subscription.DonationP
import org.thoughtcrime.securesms.components.settings.app.subscription.InAppDonations
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.DonateToSignalType
import org.thoughtcrime.securesms.components.settings.app.subscription.models.GooglePayButton
import org.thoughtcrime.securesms.components.settings.app.subscription.models.PayPalButton
import org.thoughtcrime.securesms.components.settings.configure
import org.thoughtcrime.securesms.payments.FiatMoneyUtil
import org.thoughtcrime.securesms.util.LifecycleDisposable
@@ -40,6 +41,7 @@ class GatewaySelectorBottomSheet : DSLSettingsBottomSheetFragment() {
override fun bindAdapter(adapter: DSLSettingsAdapter) {
BadgeDisplay112.register(adapter)
GooglePayButton.register(adapter)
PayPalButton.register(adapter)
lifecycleDisposable.bindTo(viewLifecycleOwner)
@@ -80,11 +82,23 @@ class GatewaySelectorBottomSheet : DSLSettingsBottomSheetFragment() {
)
}
// PayPal
if (InAppDonations.isPayPalAvailable()) {
space(8.dp)
customPref(
PayPalButton.Model(
onClick = {
findNavController().popBackStack()
val response = GatewayResponse(GatewayResponse.Gateway.PAYPAL, args.request)
setFragmentResult(REQUEST_KEY, bundleOf(REQUEST_KEY to response))
},
isEnabled = true
)
)
}
// Credit Card
if (InAppDonations.isCreditCardAvailable()) {
space(12.dp)
space(8.dp)
primaryButton(
text = DSLSettingsText.from(R.string.GatewaySelectorBottomSheet__credit_or_debit_card),

View File

@@ -0,0 +1,101 @@
package org.thoughtcrime.securesms.components.settings.app.subscription.donate.paypal
import android.annotation.SuppressLint
import android.content.DialogInterface
import android.graphics.Bitmap
import android.os.Bundle
import android.view.View
import android.webkit.WebSettings
import android.webkit.WebView
import android.webkit.WebViewClient
import androidx.core.os.bundleOf
import androidx.fragment.app.DialogFragment
import androidx.fragment.app.setFragmentResult
import androidx.navigation.fragment.navArgs
import org.signal.core.util.logging.Log
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.components.ViewBinderDelegate
import org.thoughtcrime.securesms.components.settings.app.subscription.PayPalRepository
import org.thoughtcrime.securesms.databinding.DonationWebviewFragmentBinding
import org.thoughtcrime.securesms.util.visible
/**
* Full-screen dialog for displaying PayPal confirmation.
*/
class PayPalConfirmationDialogFragment : DialogFragment(R.layout.donation_webview_fragment) {
companion object {
private val TAG = Log.tag(PayPalConfirmationDialogFragment::class.java)
const val REQUEST_KEY = "paypal_confirmation_dialog_fragment"
}
private val binding by ViewBinderDelegate(DonationWebviewFragmentBinding::bind) {
it.webView.clearCache(true)
it.webView.clearHistory()
}
private val args: PayPalConfirmationDialogFragmentArgs by navArgs()
private var result: Bundle? = null
private var isFinished = false
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setStyle(STYLE_NO_FRAME, R.style.Signal_DayNight_Dialog_FullScreen)
}
@SuppressLint("SetJavaScriptEnabled")
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
binding.webView.webViewClient = PayPalWebClient()
binding.webView.settings.javaScriptEnabled = true
binding.webView.settings.cacheMode = WebSettings.LOAD_NO_CACHE
binding.webView.loadUrl(args.uri.toString())
}
override fun onDismiss(dialog: DialogInterface) {
val result = this.result
this.result = null
setFragmentResult(REQUEST_KEY, result ?: Bundle())
}
private inner class PayPalWebClient : WebViewClient() {
override fun onPageStarted(view: WebView?, url: String?, favicon: Bitmap?) {
if (!isFinished) {
binding.progress.visible = true
}
}
override fun onPageCommitVisible(view: WebView?, url: String?) {
if (!isFinished) {
binding.progress.visible = false
}
}
override fun onPageFinished(view: WebView?, url: String?) {
if (url?.startsWith(PayPalRepository.ONE_TIME_RETURN_URL) == true) {
val confirmationResult = PayPalConfirmationResult.fromUrl(url)
if (confirmationResult != null) {
Log.d(TAG, "Setting confirmation result on request key...")
result = bundleOf(REQUEST_KEY to confirmationResult)
} else {
Log.w(TAG, "One-Time return URL was missing a required parameter.", false)
result = null
}
isFinished = true
dismissAllowingStateLoss()
} else if (url?.startsWith(PayPalRepository.CANCEL_URL) == true) {
Log.d(TAG, "User cancelled.")
result = null
isFinished = true
dismissAllowingStateLoss()
} else if (url?.startsWith(PayPalRepository.MONTHLY_RETURN_URL) == true) {
Log.d(TAG, "User confirmed monthly subscription.")
result = bundleOf(REQUEST_KEY to true)
isFinished = true
dismissAllowingStateLoss()
}
}
}
}

View File

@@ -0,0 +1,27 @@
package org.thoughtcrime.securesms.components.settings.app.subscription.donate.paypal
import android.net.Uri
import android.os.Parcelable
import kotlinx.parcelize.Parcelize
@Parcelize
data class PayPalConfirmationResult(
val payerId: String,
val paymentId: String,
val paymentToken: String
) : Parcelable {
companion object {
private const val KEY_PAYER_ID = "PayerID"
private const val KEY_PAYMENT_ID = "paymentId"
private const val KEY_PAYMENT_TOKEN = "token"
fun fromUrl(url: String): PayPalConfirmationResult? {
val uri = Uri.parse(url)
return PayPalConfirmationResult(
payerId = uri.getQueryParameter(KEY_PAYER_ID) ?: return null,
paymentId = uri.getQueryParameter(KEY_PAYMENT_ID) ?: return null,
paymentToken = uri.getQueryParameter(KEY_PAYMENT_TOKEN) ?: return null
)
}
}
}

View File

@@ -0,0 +1,162 @@
package org.thoughtcrime.securesms.components.settings.app.subscription.donate.paypal
import android.app.Dialog
import android.graphics.Color
import android.graphics.drawable.ColorDrawable
import android.net.Uri
import android.os.Bundle
import android.view.View
import androidx.core.os.bundleOf
import androidx.fragment.app.DialogFragment
import androidx.fragment.app.FragmentResultListener
import androidx.fragment.app.setFragmentResult
import androidx.navigation.fragment.findNavController
import androidx.navigation.fragment.navArgs
import androidx.navigation.navGraphViewModels
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
import io.reactivex.rxjava3.core.Single
import io.reactivex.rxjava3.kotlin.subscribeBy
import io.reactivex.rxjava3.schedulers.Schedulers
import org.signal.core.util.logging.Log
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.components.ViewBinderDelegate
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.DonationProcessorAction
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.DonationProcessorActionResult
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.DonationProcessorStage
import org.thoughtcrime.securesms.databinding.DonationInProgressFragmentBinding
import org.thoughtcrime.securesms.util.LifecycleDisposable
import org.thoughtcrime.securesms.util.navigation.safeNavigate
import org.whispersystems.signalservice.api.subscriptions.PayPalCreatePaymentIntentResponse
import org.whispersystems.signalservice.api.subscriptions.PayPalCreatePaymentMethodResponse
class PayPalPaymentInProgressFragment : DialogFragment(R.layout.donation_in_progress_fragment) {
companion object {
private val TAG = Log.tag(PayPalPaymentInProgressFragment::class.java)
const val REQUEST_KEY = "REQUEST_KEY"
}
private val disposables = LifecycleDisposable()
private val binding by ViewBinderDelegate(DonationInProgressFragmentBinding::bind)
private val args: PayPalPaymentInProgressFragmentArgs by navArgs()
private val viewModel: PayPalPaymentInProgressViewModel by navGraphViewModels(R.id.donate_to_signal, factoryProducer = {
PayPalPaymentInProgressViewModel.Factory()
})
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
isCancelable = false
return super.onCreateDialog(savedInstanceState).apply {
window!!.setBackgroundDrawable(ColorDrawable(Color.TRANSPARENT))
}
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
if (savedInstanceState == null) {
viewModel.onBeginNewAction()
when (args.action) {
DonationProcessorAction.PROCESS_NEW_DONATION -> {
viewModel.processNewDonation(args.request, this::routeToOneTimeConfirmation, this::routeToMonthlyConfirmation)
}
DonationProcessorAction.UPDATE_SUBSCRIPTION -> {
viewModel.updateSubscription(args.request)
}
DonationProcessorAction.CANCEL_SUBSCRIPTION -> {
viewModel.cancelSubscription()
}
}
}
disposables.bindTo(viewLifecycleOwner)
disposables += viewModel.state.subscribeBy { stage ->
presentUiState(stage)
}
}
private fun presentUiState(stage: DonationProcessorStage) {
when (stage) {
DonationProcessorStage.INIT -> binding.progressCardStatus.setText(R.string.SubscribeFragment__processing_payment)
DonationProcessorStage.PAYMENT_PIPELINE -> binding.progressCardStatus.setText(R.string.SubscribeFragment__processing_payment)
DonationProcessorStage.FAILED -> {
viewModel.onEndAction()
findNavController().popBackStack()
setFragmentResult(
REQUEST_KEY,
bundleOf(
REQUEST_KEY to DonationProcessorActionResult(
action = args.action,
request = args.request,
status = DonationProcessorActionResult.Status.FAILURE
)
)
)
}
DonationProcessorStage.COMPLETE -> {
viewModel.onEndAction()
findNavController().popBackStack()
setFragmentResult(
REQUEST_KEY,
bundleOf(
REQUEST_KEY to DonationProcessorActionResult(
action = args.action,
request = args.request,
status = DonationProcessorActionResult.Status.SUCCESS
)
)
)
}
DonationProcessorStage.CANCELLING -> binding.progressCardStatus.setText(R.string.StripePaymentInProgressFragment__cancelling)
}
}
private fun routeToOneTimeConfirmation(createPaymentIntentResponse: PayPalCreatePaymentIntentResponse): Single<PayPalConfirmationResult> {
return Single.create<PayPalConfirmationResult> { emitter ->
val listener = FragmentResultListener { _, bundle ->
val result: PayPalConfirmationResult? = bundle.getParcelable(PayPalConfirmationDialogFragment.REQUEST_KEY)
if (result != null) {
emitter.onSuccess(result)
} else {
emitter.onError(Exception("User did not complete paypal confirmation."))
}
}
parentFragmentManager.setFragmentResultListener(PayPalConfirmationDialogFragment.REQUEST_KEY, this, listener)
findNavController().safeNavigate(
PayPalPaymentInProgressFragmentDirections.actionPaypalPaymentInProgressFragmentToPaypalConfirmationFragment(
Uri.parse(createPaymentIntentResponse.approvalUrl)
)
)
emitter.setCancellable {
parentFragmentManager.clearFragmentResultListener(PayPalConfirmationDialogFragment.REQUEST_KEY)
}
}.subscribeOn(AndroidSchedulers.mainThread()).observeOn(Schedulers.io())
}
private fun routeToMonthlyConfirmation(createPaymentIntentResponse: PayPalCreatePaymentMethodResponse): Single<PayPalPaymentMethodId> {
return Single.create<PayPalPaymentMethodId> { emitter ->
val listener = FragmentResultListener { _, bundle ->
val result: Boolean = bundle.getBoolean(REQUEST_KEY)
if (result) {
emitter.onSuccess(PayPalPaymentMethodId(createPaymentIntentResponse.token))
} else {
emitter.onError(Exception("User did not confirm paypal setup."))
}
}
parentFragmentManager.setFragmentResultListener(PayPalConfirmationDialogFragment.REQUEST_KEY, this, listener)
findNavController().safeNavigate(
PayPalPaymentInProgressFragmentDirections.actionPaypalPaymentInProgressFragmentToPaypalConfirmationFragment(
Uri.parse(createPaymentIntentResponse.approvalUrl)
)
)
emitter.setCancellable {
parentFragmentManager.clearFragmentResultListener(PayPalConfirmationDialogFragment.REQUEST_KEY)
}
}.subscribeOn(AndroidSchedulers.mainThread()).observeOn(Schedulers.io())
}
}

View File

@@ -0,0 +1,214 @@
package org.thoughtcrime.securesms.components.settings.app.subscription.donate.paypal
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
import io.reactivex.rxjava3.core.Completable
import io.reactivex.rxjava3.core.Flowable
import io.reactivex.rxjava3.core.Single
import io.reactivex.rxjava3.disposables.CompositeDisposable
import io.reactivex.rxjava3.kotlin.plusAssign
import io.reactivex.rxjava3.kotlin.subscribeBy
import io.reactivex.rxjava3.schedulers.Schedulers
import org.signal.core.util.logging.Log
import org.signal.donations.PaymentSourceType
import org.thoughtcrime.securesms.components.settings.app.subscription.MonthlyDonationRepository
import org.thoughtcrime.securesms.components.settings.app.subscription.OneTimeDonationRepository
import org.thoughtcrime.securesms.components.settings.app.subscription.PayPalRepository
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.DonateToSignalType
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.DonationProcessorStage
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.gateway.GatewayRequest
import org.thoughtcrime.securesms.components.settings.app.subscription.errors.DonationError
import org.thoughtcrime.securesms.components.settings.app.subscription.errors.DonationErrorSource
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
import org.thoughtcrime.securesms.jobs.MultiDeviceSubscriptionSyncRequestJob
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.util.rx.RxStore
import org.whispersystems.signalservice.api.subscriptions.PayPalCreatePaymentIntentResponse
import org.whispersystems.signalservice.api.subscriptions.PayPalCreatePaymentMethodResponse
import org.whispersystems.signalservice.api.util.Preconditions
import org.whispersystems.signalservice.internal.push.DonationProcessor
class PayPalPaymentInProgressViewModel(
private val payPalRepository: PayPalRepository,
private val monthlyDonationRepository: MonthlyDonationRepository,
private val oneTimeDonationRepository: OneTimeDonationRepository
) : ViewModel() {
companion object {
private val TAG = Log.tag(PayPalPaymentInProgressViewModel::class.java)
}
private val store = RxStore(DonationProcessorStage.INIT)
val state: Flowable<DonationProcessorStage> = store.stateFlowable.observeOn(AndroidSchedulers.mainThread())
private val disposables = CompositeDisposable()
override fun onCleared() {
store.dispose()
disposables.clear()
}
fun onBeginNewAction() {
Preconditions.checkState(!store.state.isInProgress)
Log.d(TAG, "Beginning a new action. Ensuring cleared state.", true)
disposables.clear()
}
fun onEndAction() {
Preconditions.checkState(store.state.isTerminal)
Log.d(TAG, "Ending current state. Clearing state and setting stage to INIT", true)
store.update { DonationProcessorStage.INIT }
disposables.clear()
}
fun processNewDonation(
request: GatewayRequest,
routeToOneTimeConfirmation: (PayPalCreatePaymentIntentResponse) -> Single<PayPalConfirmationResult>,
routeToMonthlyConfirmation: (PayPalCreatePaymentMethodResponse) -> Single<PayPalPaymentMethodId>
) {
Log.d(TAG, "Proceeding with donation...", true)
return when (request.donateToSignalType) {
DonateToSignalType.ONE_TIME -> proceedOneTime(request, routeToOneTimeConfirmation)
DonateToSignalType.MONTHLY -> proceedMonthly(request, routeToMonthlyConfirmation)
DonateToSignalType.GIFT -> proceedOneTime(request, routeToOneTimeConfirmation)
}
}
fun updateSubscription(request: GatewayRequest) {
Log.d(TAG, "Beginning subscription update...", true)
store.update { DonationProcessorStage.PAYMENT_PIPELINE }
disposables += monthlyDonationRepository.cancelActiveSubscriptionIfNecessary().andThen(monthlyDonationRepository.setSubscriptionLevel(request.level.toString()))
.subscribeBy(
onComplete = {
Log.w(TAG, "Completed subscription update", true)
store.update { DonationProcessorStage.COMPLETE }
},
onError = { throwable ->
Log.w(TAG, "Failed to update subscription", throwable, true)
val donationError: DonationError = if (throwable is DonationError) {
throwable
} else {
DonationError.genericBadgeRedemptionFailure(DonationErrorSource.SUBSCRIPTION)
}
DonationError.routeDonationError(ApplicationDependencies.getApplication(), donationError)
store.update { DonationProcessorStage.FAILED }
}
)
}
fun cancelSubscription() {
Log.d(TAG, "Beginning cancellation...", true)
store.update { DonationProcessorStage.CANCELLING }
disposables += monthlyDonationRepository.cancelActiveSubscription().subscribeBy(
onComplete = {
Log.d(TAG, "Cancellation succeeded", true)
SignalStore.donationsValues().updateLocalStateForManualCancellation()
MultiDeviceSubscriptionSyncRequestJob.enqueue()
monthlyDonationRepository.syncAccountRecord().subscribe()
store.update { DonationProcessorStage.COMPLETE }
},
onError = { throwable ->
Log.w(TAG, "Cancellation failed", throwable, true)
store.update { DonationProcessorStage.FAILED }
}
)
}
private fun proceedOneTime(
request: GatewayRequest,
routeToPaypalConfirmation: (PayPalCreatePaymentIntentResponse) -> Single<PayPalConfirmationResult>
) {
Log.d(TAG, "Proceeding with one-time payment pipeline...", true)
store.update { DonationProcessorStage.PAYMENT_PIPELINE }
disposables += payPalRepository
.createOneTimePaymentIntent(
amount = request.fiat,
badgeRecipient = request.recipientId,
badgeLevel = request.level
)
.flatMap(routeToPaypalConfirmation)
.flatMap { result ->
payPalRepository.confirmOneTimePaymentIntent(
amount = request.fiat,
badgeLevel = request.level,
paypalConfirmationResult = result
)
}
.flatMapCompletable { response ->
oneTimeDonationRepository.waitForOneTimeRedemption(
price = request.fiat,
paymentIntentId = response.paymentId,
badgeRecipient = request.recipientId,
additionalMessage = request.additionalMessage,
badgeLevel = request.level,
donationProcessor = DonationProcessor.PAYPAL
)
}
.subscribeOn(Schedulers.io())
.subscribeBy(
onError = { throwable ->
Log.w(TAG, "Failure in one-time payment pipeline...", throwable, true)
store.update { DonationProcessorStage.FAILED }
val donationError: DonationError = if (throwable is DonationError) {
throwable
} else {
DonationError.genericBadgeRedemptionFailure(DonationErrorSource.BOOST)
}
DonationError.routeDonationError(ApplicationDependencies.getApplication(), donationError)
},
onComplete = {
Log.d(TAG, "Finished one-time payment pipeline...", true)
store.update { DonationProcessorStage.COMPLETE }
}
)
}
private fun proceedMonthly(request: GatewayRequest, routeToPaypalConfirmation: (PayPalCreatePaymentMethodResponse) -> Single<PayPalPaymentMethodId>) {
Log.d(TAG, "Proceeding with monthly payment pipeline...")
val setup = monthlyDonationRepository.ensureSubscriberId()
.andThen(monthlyDonationRepository.cancelActiveSubscriptionIfNecessary())
.andThen(payPalRepository.createPaymentMethod())
.flatMap(routeToPaypalConfirmation)
.flatMapCompletable { payPalRepository.setDefaultPaymentMethod(it.paymentId) }
.onErrorResumeNext { Completable.error(DonationError.getPaymentSetupError(DonationErrorSource.SUBSCRIPTION, it, PaymentSourceType.PayPal)) }
disposables += setup.andThen(monthlyDonationRepository.setSubscriptionLevel(request.level.toString()))
.subscribeBy(
onError = { throwable ->
Log.w(TAG, "Failure in monthly payment pipeline...", throwable, true)
store.update { DonationProcessorStage.FAILED }
val donationError: DonationError = if (throwable is DonationError) {
throwable
} else {
DonationError.genericBadgeRedemptionFailure(DonationErrorSource.SUBSCRIPTION)
}
DonationError.routeDonationError(ApplicationDependencies.getApplication(), donationError)
},
onComplete = {
Log.d(TAG, "Finished subscription payment pipeline...", true)
store.update { DonationProcessorStage.COMPLETE }
}
)
}
class Factory(
private val payPalRepository: PayPalRepository = PayPalRepository(ApplicationDependencies.getDonationsService()),
private val monthlyDonationRepository: MonthlyDonationRepository = MonthlyDonationRepository(ApplicationDependencies.getDonationsService()),
private val oneTimeDonationRepository: OneTimeDonationRepository = OneTimeDonationRepository(ApplicationDependencies.getDonationsService())
) : ViewModelProvider.Factory {
override fun <T : ViewModel> create(modelClass: Class<T>): T {
return modelClass.cast(PayPalPaymentInProgressViewModel(payPalRepository, monthlyDonationRepository, oneTimeDonationRepository)) as T
}
}
}

View File

@@ -0,0 +1,8 @@
package org.thoughtcrime.securesms.components.settings.app.subscription.donate.paypal
import android.os.Parcelable
import kotlinx.parcelize.Parcelize
@Parcelize
@JvmInline
value class PayPalPaymentMethodId(val paymentId: String) : Parcelable

View File

@@ -15,19 +15,19 @@ import androidx.navigation.fragment.navArgs
import org.signal.donations.StripeIntentAccessor
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.components.ViewBinderDelegate
import org.thoughtcrime.securesms.databinding.Stripe3dsDialogFragmentBinding
import org.thoughtcrime.securesms.databinding.DonationWebviewFragmentBinding
import org.thoughtcrime.securesms.util.visible
/**
* Full-screen dialog for displaying Stripe 3DS confirmation.
*/
class Stripe3DSDialogFragment : DialogFragment(R.layout.stripe_3ds_dialog_fragment) {
class Stripe3DSDialogFragment : DialogFragment(R.layout.donation_webview_fragment) {
companion object {
const val REQUEST_KEY = "stripe_3ds_dialog_fragment"
}
val binding by ViewBinderDelegate(Stripe3dsDialogFragmentBinding::bind) {
val binding by ViewBinderDelegate(DonationWebviewFragmentBinding::bind) {
it.webView.clearCache(true)
it.webView.clearHistory()
}

View File

@@ -25,12 +25,12 @@ import org.thoughtcrime.securesms.components.settings.app.subscription.DonationP
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.DonationProcessorAction
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.DonationProcessorActionResult
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.DonationProcessorStage
import org.thoughtcrime.securesms.databinding.StripePaymentInProgressFragmentBinding
import org.thoughtcrime.securesms.databinding.DonationInProgressFragmentBinding
import org.thoughtcrime.securesms.util.LifecycleDisposable
import org.thoughtcrime.securesms.util.fragments.requireListener
import org.thoughtcrime.securesms.util.navigation.safeNavigate
class StripePaymentInProgressFragment : DialogFragment(R.layout.stripe_payment_in_progress_fragment) {
class StripePaymentInProgressFragment : DialogFragment(R.layout.donation_in_progress_fragment) {
companion object {
private val TAG = Log.tag(StripePaymentInProgressFragment::class.java)
@@ -38,7 +38,7 @@ class StripePaymentInProgressFragment : DialogFragment(R.layout.stripe_payment_i
const val REQUEST_KEY = "REQUEST_KEY"
}
private val binding by ViewBinderDelegate(StripePaymentInProgressFragmentBinding::bind)
private val binding by ViewBinderDelegate(DonationInProgressFragmentBinding::bind)
private val args: StripePaymentInProgressFragmentArgs by navArgs()
private val disposables = LifecycleDisposable()

View File

@@ -12,9 +12,9 @@ import io.reactivex.rxjava3.kotlin.plusAssign
import io.reactivex.rxjava3.kotlin.subscribeBy
import org.signal.core.util.logging.Log
import org.signal.donations.GooglePayPaymentSource
import org.signal.donations.PaymentSourceType
import org.signal.donations.StripeApi
import org.signal.donations.StripeIntentAccessor
import org.signal.donations.StripePaymentSourceType
import org.thoughtcrime.securesms.components.settings.app.subscription.MonthlyDonationRepository
import org.thoughtcrime.securesms.components.settings.app.subscription.OneTimeDonationRepository
import org.thoughtcrime.securesms.components.settings.app.subscription.StripeRepository
@@ -28,6 +28,7 @@ import org.thoughtcrime.securesms.jobs.MultiDeviceSubscriptionSyncRequestJob
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.util.rx.RxStore
import org.whispersystems.signalservice.api.util.Preconditions
import org.whispersystems.signalservice.internal.push.DonationProcessor
class StripePaymentInProgressViewModel(
private val stripeRepository: StripeRepository,
@@ -93,11 +94,11 @@ class StripePaymentInProgressViewModel(
paymentData == null && cardData == null -> error("No payment provider available.")
paymentData != null && cardData != null -> error("Too many providers available")
paymentData != null -> PaymentSourceProvider(
StripePaymentSourceType.GOOGLE_PAY,
PaymentSourceType.Stripe.GooglePay,
Single.just<StripeApi.PaymentSource>(GooglePayPaymentSource(paymentData)).doAfterTerminate { clearPaymentInformation() }
)
cardData != null -> PaymentSourceProvider(
StripePaymentSourceType.CREDIT_CARD,
PaymentSourceType.Stripe.CreditCard,
stripeRepository.createCreditCardPaymentSource(errorSource, cardData).doAfterTerminate { clearPaymentInformation() }
)
else -> error("This should never happen.")
@@ -187,11 +188,12 @@ class StripePaymentInProgressViewModel(
.flatMap { stripeRepository.getStatusAndPaymentMethodId(it) }
.flatMapCompletable {
oneTimeDonationRepository.waitForOneTimeRedemption(
amount,
paymentIntent.intentId,
request.recipientId,
request.additionalMessage,
request.level
price = amount,
paymentIntentId = paymentIntent.intentId,
badgeRecipient = request.recipientId,
additionalMessage = request.additionalMessage,
badgeLevel = request.level,
donationProcessor = DonationProcessor.STRIPE
)
}
}.subscribeBy(
@@ -257,7 +259,7 @@ class StripePaymentInProgressViewModel(
}
private data class PaymentSourceProvider(
val paymentSourceType: StripePaymentSourceType,
val paymentSourceType: PaymentSourceType,
val paymentSource: Single<StripeApi.PaymentSource>
)

View File

@@ -5,9 +5,9 @@ import io.reactivex.rxjava3.core.Observable
import io.reactivex.rxjava3.subjects.PublishSubject
import io.reactivex.rxjava3.subjects.Subject
import org.signal.core.util.logging.Log
import org.signal.donations.PaymentSourceType
import org.signal.donations.StripeDeclineCode
import org.signal.donations.StripeError
import org.signal.donations.StripePaymentSourceType
sealed class DonationError(val source: DonationErrorSource, cause: Throwable) : Exception(cause) {
@@ -51,12 +51,12 @@ sealed class DonationError(val source: DonationErrorSource, cause: Throwable) :
/**
* Payment setup failed in some way, which we are told about by Stripe.
*/
class CodedError(source: DonationErrorSource, cause: Throwable, val errorCode: String) : PaymentSetupError(source, cause)
class StripeCodedError(source: DonationErrorSource, cause: Throwable, val errorCode: String) : PaymentSetupError(source, cause)
/**
* Payment failed by the credit card processor, with a specific reason told to us by Stripe.
*/
class DeclinedError(source: DonationErrorSource, cause: Throwable, val declineCode: StripeDeclineCode, val method: StripePaymentSourceType) : PaymentSetupError(source, cause)
class StripeDeclinedError(source: DonationErrorSource, cause: Throwable, val declineCode: StripeDeclineCode, val method: PaymentSourceType.Stripe) : PaymentSetupError(source, cause)
}
/**
@@ -129,18 +129,18 @@ sealed class DonationError(val source: DonationErrorSource, cause: Throwable) :
/**
* Converts a throwable into a payment setup error. This should only be used when
* handling errors handed back via the Stripe API, when we know for sure that no
* handling errors handed back via the Stripe API or via PayPal, when we know for sure that no
* charge has occurred.
*/
@JvmStatic
fun getPaymentSetupError(source: DonationErrorSource, throwable: Throwable, method: StripePaymentSourceType): DonationError {
fun getPaymentSetupError(source: DonationErrorSource, throwable: Throwable, method: PaymentSourceType): DonationError {
return if (throwable is StripeError.PostError) {
val declineCode: StripeDeclineCode? = throwable.declineCode
val errorCode: String? = throwable.errorCode
when {
declineCode != null -> PaymentSetupError.DeclinedError(source, throwable, declineCode, method)
errorCode != null -> PaymentSetupError.CodedError(source, throwable, errorCode)
declineCode != null && method is PaymentSourceType.Stripe -> PaymentSetupError.StripeDeclinedError(source, throwable, declineCode, method)
errorCode != null && method is PaymentSourceType.Stripe -> PaymentSetupError.StripeCodedError(source, throwable, errorCode)
else -> PaymentSetupError.GenericError(source, throwable)
}
} else {

View File

@@ -2,8 +2,8 @@ package org.thoughtcrime.securesms.components.settings.app.subscription.errors
import android.content.Context
import androidx.annotation.StringRes
import org.signal.donations.PaymentSourceType
import org.signal.donations.StripeDeclineCode
import org.signal.donations.StripePaymentSourceType
import org.thoughtcrime.securesms.R
class DonationErrorParams<V> private constructor(
@@ -25,7 +25,7 @@ class DonationErrorParams<V> private constructor(
): DonationErrorParams<V> {
return when (throwable) {
is DonationError.GiftRecipientVerificationError -> getVerificationErrorParams(context, throwable, callback)
is DonationError.PaymentSetupError.DeclinedError -> getDeclinedErrorParams(context, throwable, callback)
is DonationError.PaymentSetupError.StripeDeclinedError -> getDeclinedErrorParams(context, throwable, callback)
is DonationError.PaymentSetupError -> DonationErrorParams(
title = R.string.DonationsErrors__error_processing_payment,
message = R.string.DonationsErrors__your_payment,
@@ -88,10 +88,10 @@ class DonationErrorParams<V> private constructor(
}
}
private fun <V> getDeclinedErrorParams(context: Context, declinedError: DonationError.PaymentSetupError.DeclinedError, callback: Callback<V>): DonationErrorParams<V> {
private fun <V> getDeclinedErrorParams(context: Context, declinedError: DonationError.PaymentSetupError.StripeDeclinedError, callback: Callback<V>): DonationErrorParams<V> {
val getStripeDeclineCodePositiveActionParams: (Context, Callback<V>, Int) -> DonationErrorParams<V> = when (declinedError.method) {
StripePaymentSourceType.CREDIT_CARD -> this::getTryCreditCardAgainParams
StripePaymentSourceType.GOOGLE_PAY -> this::getGoToGooglePayParams
PaymentSourceType.Stripe.GooglePay -> this::getTryCreditCardAgainParams
PaymentSourceType.Stripe.CreditCard -> this::getGoToGooglePayParams
}
return when (declinedError.declineCode) {
@@ -99,66 +99,66 @@ class DonationErrorParams<V> private constructor(
StripeDeclineCode.Code.APPROVE_WITH_ID -> getStripeDeclineCodePositiveActionParams(
context, callback,
when (declinedError.method) {
StripePaymentSourceType.CREDIT_CARD -> R.string.DeclineCode__verify_your_payment_method_is_up_to_date_in_google_pay_and_try_again
StripePaymentSourceType.GOOGLE_PAY -> R.string.DeclineCode__verify_your_card_details_are_correct_and_try_again
PaymentSourceType.Stripe.CreditCard -> R.string.DeclineCode__verify_your_payment_method_is_up_to_date_in_google_pay_and_try_again
PaymentSourceType.Stripe.GooglePay -> R.string.DeclineCode__verify_your_card_details_are_correct_and_try_again
}
)
StripeDeclineCode.Code.CALL_ISSUER -> getStripeDeclineCodePositiveActionParams(
context, callback,
when (declinedError.method) {
StripePaymentSourceType.CREDIT_CARD -> R.string.DeclineCode__verify_your_card_details_are_correct_and_try_again_if_the_problem_continues
StripePaymentSourceType.GOOGLE_PAY -> R.string.DeclineCode__verify_your_payment_method_is_up_to_date_in_google_pay_and_try_again_if_the_problem
PaymentSourceType.Stripe.CreditCard -> R.string.DeclineCode__verify_your_card_details_are_correct_and_try_again_if_the_problem_continues
PaymentSourceType.Stripe.GooglePay -> R.string.DeclineCode__verify_your_payment_method_is_up_to_date_in_google_pay_and_try_again_if_the_problem
}
)
StripeDeclineCode.Code.CARD_NOT_SUPPORTED -> getLearnMoreParams(context, callback, R.string.DeclineCode__your_card_does_not_support_this_type_of_purchase)
StripeDeclineCode.Code.EXPIRED_CARD -> getStripeDeclineCodePositiveActionParams(
context, callback,
when (declinedError.method) {
StripePaymentSourceType.CREDIT_CARD -> R.string.DeclineCode__your_card_has_expired_verify_your_card_details
StripePaymentSourceType.GOOGLE_PAY -> R.string.DeclineCode__your_card_has_expired
PaymentSourceType.Stripe.CreditCard -> R.string.DeclineCode__your_card_has_expired_verify_your_card_details
PaymentSourceType.Stripe.GooglePay -> R.string.DeclineCode__your_card_has_expired
}
)
StripeDeclineCode.Code.INCORRECT_NUMBER -> getStripeDeclineCodePositiveActionParams(
context, callback,
when (declinedError.method) {
StripePaymentSourceType.CREDIT_CARD -> R.string.DeclineCode__your_card_number_is_incorrect_verify_your_card_details
StripePaymentSourceType.GOOGLE_PAY -> R.string.DeclineCode__your_card_number_is_incorrect
PaymentSourceType.Stripe.CreditCard -> R.string.DeclineCode__your_card_number_is_incorrect_verify_your_card_details
PaymentSourceType.Stripe.GooglePay -> R.string.DeclineCode__your_card_number_is_incorrect
}
)
StripeDeclineCode.Code.INCORRECT_CVC -> getStripeDeclineCodePositiveActionParams(
context, callback,
when (declinedError.method) {
StripePaymentSourceType.CREDIT_CARD -> R.string.DeclineCode__your_cards_cvc_number_is_incorrect_verify_your_card_details
StripePaymentSourceType.GOOGLE_PAY -> R.string.DeclineCode__your_cards_cvc_number_is_incorrect
PaymentSourceType.Stripe.CreditCard -> R.string.DeclineCode__your_cards_cvc_number_is_incorrect_verify_your_card_details
PaymentSourceType.Stripe.GooglePay -> R.string.DeclineCode__your_cards_cvc_number_is_incorrect
}
)
StripeDeclineCode.Code.INSUFFICIENT_FUNDS -> getLearnMoreParams(context, callback, R.string.DeclineCode__your_card_does_not_have_sufficient_funds)
StripeDeclineCode.Code.INVALID_CVC -> getStripeDeclineCodePositiveActionParams(
context, callback,
when (declinedError.method) {
StripePaymentSourceType.CREDIT_CARD -> R.string.DeclineCode__your_cards_cvc_number_is_incorrect_verify_your_card_details
StripePaymentSourceType.GOOGLE_PAY -> R.string.DeclineCode__your_cards_cvc_number_is_incorrect
PaymentSourceType.Stripe.CreditCard -> R.string.DeclineCode__your_cards_cvc_number_is_incorrect_verify_your_card_details
PaymentSourceType.Stripe.GooglePay -> R.string.DeclineCode__your_cards_cvc_number_is_incorrect
}
)
StripeDeclineCode.Code.INVALID_EXPIRY_MONTH -> getStripeDeclineCodePositiveActionParams(
context, callback,
when (declinedError.method) {
StripePaymentSourceType.CREDIT_CARD -> R.string.DeclineCode__the_expiration_month_on_your_card_is_incorrect
StripePaymentSourceType.GOOGLE_PAY -> R.string.DeclineCode__the_expiration_month
PaymentSourceType.Stripe.CreditCard -> R.string.DeclineCode__the_expiration_month_on_your_card_is_incorrect
PaymentSourceType.Stripe.GooglePay -> R.string.DeclineCode__the_expiration_month
}
)
StripeDeclineCode.Code.INVALID_EXPIRY_YEAR -> getStripeDeclineCodePositiveActionParams(
context, callback,
when (declinedError.method) {
StripePaymentSourceType.CREDIT_CARD -> R.string.DeclineCode__the_expiration_year_on_your_card_is_incorrect
StripePaymentSourceType.GOOGLE_PAY -> R.string.DeclineCode__the_expiration_year
PaymentSourceType.Stripe.CreditCard -> R.string.DeclineCode__the_expiration_year_on_your_card_is_incorrect
PaymentSourceType.Stripe.GooglePay -> R.string.DeclineCode__the_expiration_year
}
)
StripeDeclineCode.Code.INVALID_NUMBER -> getStripeDeclineCodePositiveActionParams(
context, callback,
when (declinedError.method) {
StripePaymentSourceType.CREDIT_CARD -> R.string.DeclineCode__your_card_number_is_incorrect_verify_your_card_details
StripePaymentSourceType.GOOGLE_PAY -> R.string.DeclineCode__your_card_number_is_incorrect
PaymentSourceType.Stripe.CreditCard -> R.string.DeclineCode__your_card_number_is_incorrect_verify_your_card_details
PaymentSourceType.Stripe.GooglePay -> R.string.DeclineCode__your_card_number_is_incorrect
}
)
StripeDeclineCode.Code.ISSUER_NOT_AVAILABLE -> getLearnMoreParams(context, callback, R.string.DeclineCode__try_completing_the_payment_again)

View File

@@ -0,0 +1,25 @@
package org.thoughtcrime.securesms.components.settings.app.subscription.models
import org.thoughtcrime.securesms.databinding.PaypalButtonBinding
import org.thoughtcrime.securesms.util.adapter.mapping.BindingFactory
import org.thoughtcrime.securesms.util.adapter.mapping.BindingViewHolder
import org.thoughtcrime.securesms.util.adapter.mapping.MappingAdapter
import org.thoughtcrime.securesms.util.adapter.mapping.MappingModel
object PayPalButton {
fun register(mappingAdapter: MappingAdapter) {
mappingAdapter.registerFactory(Model::class.java, BindingFactory(::ViewHolder, PaypalButtonBinding::inflate))
}
class Model(val onClick: () -> Unit, val isEnabled: Boolean) : MappingModel<Model> {
override fun areItemsTheSame(newItem: Model): Boolean = true
override fun areContentsTheSame(newItem: Model): Boolean = isEnabled == newItem.isEnabled
}
class ViewHolder(binding: PaypalButtonBinding) : BindingViewHolder<Model, PaypalButtonBinding>(binding) {
override fun bind(model: Model) {
binding.paypalButton.isEnabled = model.isEnabled
binding.paypalButton.setOnClickListener { model.onClick() }
}
}
}

View File

@@ -26,6 +26,7 @@ import org.thoughtcrime.securesms.jobmanager.impl.NetworkConstraint;
import org.thoughtcrime.securesms.recipients.RecipientId;
import org.whispersystems.signalservice.api.subscriptions.SubscriptionLevels;
import org.whispersystems.signalservice.internal.ServiceResponse;
import org.whispersystems.signalservice.internal.push.DonationProcessor;
import java.io.IOException;
import java.security.SecureRandom;
@@ -44,18 +45,20 @@ public class BoostReceiptRequestResponseJob extends BaseJob {
private static final String BOOST_QUEUE = "BoostReceiptRedemption";
private static final String GIFT_QUEUE = "GiftReceiptRedemption";
private static final String DATA_REQUEST_BYTES = "data.request.bytes";
private static final String DATA_PAYMENT_INTENT_ID = "data.payment.intent.id";
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_REQUEST_BYTES = "data.request.bytes";
private static final String DATA_PAYMENT_INTENT_ID = "data.payment.intent.id";
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 ReceiptCredentialRequestContext requestContext;
private final DonationErrorSource donationErrorSource;
private final String paymentIntentId;
private final long badgeLevel;
private final DonationProcessor donationProcessor;
private static BoostReceiptRequestResponseJob createJob(String paymentIntentId, DonationErrorSource donationErrorSource, long badgeLevel) {
private static BoostReceiptRequestResponseJob createJob(String paymentIntentId, DonationErrorSource donationErrorSource, long badgeLevel, DonationProcessor donationProcessor) {
return new BoostReceiptRequestResponseJob(
new Parameters
.Builder()
@@ -67,12 +70,13 @@ public class BoostReceiptRequestResponseJob extends BaseJob {
null,
paymentIntentId,
donationErrorSource,
badgeLevel
badgeLevel,
donationProcessor
);
}
public static JobManager.Chain createJobChainForBoost(@NonNull String paymentIntentId) {
BoostReceiptRequestResponseJob requestReceiptJob = createJob(paymentIntentId, DonationErrorSource.BOOST, Long.parseLong(SubscriptionLevels.BOOST_LEVEL));
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();
RefreshOwnProfileJob refreshOwnProfileJob = RefreshOwnProfileJob.forBoost();
MultiDeviceProfileContentUpdateJob multiDeviceProfileContentUpdateJob = new MultiDeviceProfileContentUpdateJob();
@@ -87,9 +91,10 @@ public class BoostReceiptRequestResponseJob extends BaseJob {
public static JobManager.Chain createJobChainForGift(@NonNull String paymentIntentId,
@NonNull RecipientId recipientId,
@Nullable String additionalMessage,
long badgeLevel)
long badgeLevel,
@NonNull DonationProcessor donationProcessor)
{
BoostReceiptRequestResponseJob requestReceiptJob = createJob(paymentIntentId, DonationErrorSource.GIFT, badgeLevel);
BoostReceiptRequestResponseJob requestReceiptJob = createJob(paymentIntentId, DonationErrorSource.GIFT, badgeLevel, donationProcessor);
GiftSendJob giftSendJob = new GiftSendJob(recipientId, additionalMessage);
@@ -102,20 +107,23 @@ public class BoostReceiptRequestResponseJob extends BaseJob {
@Nullable ReceiptCredentialRequestContext requestContext,
@NonNull String paymentIntentId,
@NonNull DonationErrorSource donationErrorSource,
long badgeLevel)
long badgeLevel,
@NonNull DonationProcessor donationProcessor)
{
super(parameters);
this.requestContext = requestContext;
this.paymentIntentId = paymentIntentId;
this.donationErrorSource = donationErrorSource;
this.badgeLevel = badgeLevel;
this.donationProcessor = donationProcessor;
}
@Override
public @NonNull Data serialize() {
Data.Builder builder = new Data.Builder().putString(DATA_PAYMENT_INTENT_ID, paymentIntentId)
.putString(DATA_ERROR_SOURCE, donationErrorSource.serialize())
.putLong(DATA_BADGE_LEVEL, badgeLevel);
.putLong(DATA_BADGE_LEVEL, badgeLevel)
.putString(DATA_DONATION_PROCESSOR, donationProcessor.getCode());
if (requestContext != null) {
builder.putBlobAsString(DATA_REQUEST_BYTES, requestContext.serialize());
@@ -153,7 +161,7 @@ public class BoostReceiptRequestResponseJob extends BaseJob {
Log.d(TAG, "Submitting credential to server", true);
ServiceResponse<ReceiptCredentialResponse> response = ApplicationDependencies.getDonationsService()
.submitBoostReceiptCredentialRequestSync(paymentIntentId, requestContext.getRequest());
.submitBoostReceiptCredentialRequestSync(paymentIntentId, requestContext.getRequest(), donationProcessor);
if (response.getApplicationError().isPresent()) {
handleApplicationError(context, response, donationErrorSource);
@@ -258,18 +266,20 @@ public class BoostReceiptRequestResponseJob extends BaseJob {
public static class Factory implements Job.Factory<BoostReceiptRequestResponseJob> {
@Override
public @NonNull BoostReceiptRequestResponseJob create(@NonNull Parameters parameters, @NonNull Data data) {
String paymentIntentId = data.getString(DATA_PAYMENT_INTENT_ID);
DonationErrorSource donationErrorSource = DonationErrorSource.deserialize(data.getStringOrDefault(DATA_ERROR_SOURCE, DonationErrorSource.BOOST.serialize()));
long badgeLevel = data.getLongOrDefault(DATA_BADGE_LEVEL, Long.parseLong(SubscriptionLevels.BOOST_LEVEL));
String paymentIntentId = data.getString(DATA_PAYMENT_INTENT_ID);
DonationErrorSource donationErrorSource = DonationErrorSource.deserialize(data.getStringOrDefault(DATA_ERROR_SOURCE, DonationErrorSource.BOOST.serialize()));
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);
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);
return new BoostReceiptRequestResponseJob(parameters, requestContext, paymentIntentId, donationErrorSource, badgeLevel, donationProcessor);
} else {
return new BoostReceiptRequestResponseJob(parameters, null, paymentIntentId, donationErrorSource, badgeLevel);
return new BoostReceiptRequestResponseJob(parameters, null, paymentIntentId, donationErrorSource, badgeLevel, donationProcessor);
}
} catch (InvalidInputException e) {
throw new IllegalStateException(e);

View File

@@ -5,6 +5,7 @@ import androidx.annotation.Nullable;
import androidx.annotation.VisibleForTesting;
import org.signal.core.util.logging.Log;
import org.signal.donations.PaymentSourceType;
import org.signal.donations.StripeDeclineCode;
import org.signal.libsignal.zkgroup.InvalidInputException;
import org.signal.libsignal.zkgroup.VerificationFailedException;
@@ -295,20 +296,27 @@ public class SubscriptionReceiptRequestResponseJob extends BaseJob {
StripeDeclineCode declineCode = StripeDeclineCode.Companion.getFromCode(chargeFailure.getOutcomeNetworkReason());
DonationError.PaymentSetupError paymentSetupError;
PaymentSourceType paymentSourceType = SignalStore.donationsValues().getSubscriptionPaymentSourceType();
boolean isStripeSource = paymentSourceType instanceof PaymentSourceType.Stripe;
if (declineCode.isKnown()) {
paymentSetupError = new DonationError.PaymentSetupError.DeclinedError(
if (declineCode.isKnown() && isStripeSource) {
paymentSetupError = new DonationError.PaymentSetupError.StripeDeclinedError(
getErrorSource(),
new Exception(chargeFailure.getMessage()),
declineCode,
SignalStore.donationsValues().getSubscriptionPaymentSourceType()
(PaymentSourceType.Stripe) paymentSourceType
);
} else {
paymentSetupError = new DonationError.PaymentSetupError.CodedError(
} else if (isStripeSource) {
paymentSetupError = new DonationError.PaymentSetupError.StripeCodedError(
getErrorSource(),
new Exception("Card was declined. " + chargeFailure.getCode()),
chargeFailure.getCode()
);
} else {
paymentSetupError = new DonationError.PaymentSetupError.GenericError(
getErrorSource(),
new Exception("Payment Failed for " + paymentSourceType.getCode())
);
}
Log.w(TAG, "Not for a keep-alive and we have a charge failure. Routing a payment setup error...", true);

View File

@@ -5,8 +5,8 @@ import io.reactivex.rxjava3.core.Observable
import io.reactivex.rxjava3.subjects.BehaviorSubject
import io.reactivex.rxjava3.subjects.Subject
import org.signal.core.util.logging.Log
import org.signal.donations.PaymentSourceType
import org.signal.donations.StripeApi
import org.signal.donations.StripePaymentSourceType
import org.signal.libsignal.zkgroup.InvalidInputException
import org.signal.libsignal.zkgroup.VerificationFailedException
import org.signal.libsignal.zkgroup.receipts.ReceiptCredentialPresentation
@@ -450,12 +450,12 @@ internal class DonationsValues internal constructor(store: KeyValueStore) : Sign
remove(SUBSCRIPTION_CREDENTIAL_RECEIPT)
}
fun setSubscriptionPaymentSourceType(stripePaymentSourceType: StripePaymentSourceType) {
putString(SUBSCRIPTION_PAYMENT_SOURCE_TYPE, stripePaymentSourceType.code)
fun setSubscriptionPaymentSourceType(paymentSourceType: PaymentSourceType) {
putString(SUBSCRIPTION_PAYMENT_SOURCE_TYPE, paymentSourceType.code)
}
fun getSubscriptionPaymentSourceType(): StripePaymentSourceType {
return StripePaymentSourceType.fromCode(getString(SUBSCRIPTION_PAYMENT_SOURCE_TYPE, null))
fun getSubscriptionPaymentSourceType(): PaymentSourceType {
return PaymentSourceType.fromCode(getString(SUBSCRIPTION_PAYMENT_SOURCE_TYPE, null))
}
var subscriptionEndOfPeriodConversionStarted by longValue(SUBSCRIPTION_EOP_STARTED_TO_CONVERT, 0L)

View File

@@ -107,6 +107,7 @@ public final class FeatureFlags {
private static final String CDS_HARD_LIMIT = "android.cds.hardLimit";
private static final String PAYMENTS_IN_CHAT_MESSAGES = "android.payments.inChatMessages";
private static final String CHAT_FILTERS = "android.chat.filters";
private static final String PAYPAL_DONATIONS = "android.donations.paypal";
/**
* We will only store remote values for flags in this set. If you want a flag to be controllable
@@ -166,7 +167,8 @@ public final class FeatureFlags {
KEEP_MUTED_CHATS_ARCHIVED,
CDS_HARD_LIMIT,
PAYMENTS_IN_CHAT_MESSAGES,
CHAT_FILTERS
CHAT_FILTERS,
PAYPAL_DONATIONS
);
@VisibleForTesting
@@ -538,8 +540,6 @@ public final class FeatureFlags {
/**
* Whether or not we should allow credit card payments for donations
*
* WARNING: This feature is not done, and this should not be enabled.
*/
public static boolean creditCardPayments() {
return getBoolean(CREDIT_CARD_PAYMENTS, Environment.IS_STAGING);
@@ -597,6 +597,13 @@ public final class FeatureFlags {
return getBoolean(CHAT_FILTERS, false);
}
/**
* Whether or not we should allow PayPal payments for donations
*/
public static boolean paypalDonations() {
return getBoolean(PAYPAL_DONATIONS, Environment.IS_STAGING);
}
/** Only for rendering debug info. */
public static synchronized @NonNull Map<String, Object> getMemoryValues() {
return new TreeMap<>(REMOTE_VALUES);