From 979f87db78a25b2ac7bc05082194a0e9402dec72 Mon Sep 17 00:00:00 2001 From: Alex Hart Date: Wed, 30 Nov 2022 12:43:46 -0400 Subject: [PATCH] Add initial PayPal implementation behind a feature flag. --- .../flow/GiftFlowConfirmationFragment.kt | 4 + .../app/subscription/InAppDonations.kt | 2 +- .../subscription/OneTimeDonationRepository.kt | 17 +- .../app/subscription/PayPalRepository.kt | 89 ++++++++ .../app/subscription/StripeRepository.kt | 20 +- .../donate/DonateToSignalFragment.kt | 9 + .../donate/DonationCheckoutDelegate.kt | 17 +- .../gateway/GatewaySelectorBottomSheet.kt | 20 +- .../PayPalConfirmationDialogFragment.kt | 101 +++++++++ .../donate/paypal/PayPalConfirmationResult.kt | 27 +++ .../paypal/PayPalPaymentInProgressFragment.kt | 162 +++++++++++++ .../PayPalPaymentInProgressViewModel.kt | 214 ++++++++++++++++++ .../donate/paypal/PayPalPaymentMethodId.kt | 8 + .../donate/stripe/Stripe3DSDialogFragment.kt | 6 +- .../stripe/StripePaymentInProgressFragment.kt | 6 +- .../StripePaymentInProgressViewModel.kt | 20 +- .../app/subscription/errors/DonationError.kt | 14 +- .../errors/DonationErrorParams.kt | 46 ++-- .../app/subscription/models/PayPalButton.kt | 25 ++ .../jobs/BoostReceiptRequestResponseJob.java | 46 ++-- ...SubscriptionReceiptRequestResponseJob.java | 18 +- .../securesms/keyvalue/DonationsValues.kt | 10 +- .../securesms/util/FeatureFlags.java | 13 +- app/src/main/res/drawable/paypal.xml | 43 ++++ ....xml => donation_in_progress_fragment.xml} | 0 ...ment.xml => donation_webview_fragment.xml} | 0 app/src/main/res/layout/paypal_button.xml | 24 ++ .../main/res/navigation/donate_to_signal.xml | 40 +++- app/src/main/res/navigation/gift_flow.xml | 40 +++- app/src/main/res/values-night/dark_colors.xml | 2 + app/src/main/res/values/colors.xml | 2 + .../paypal/PayPalConfirmationResultTest.kt | 41 ++++ .../donations/CreditCardPaymentSource.kt | 2 +- .../donations/GooglePayPaymentSource.kt | 2 +- .../org/signal/donations/PaymentSourceType.kt | 36 +++ .../java/org/signal/donations/StripeApi.kt | 2 +- .../donations/StripePaymentSourceType.kt | 12 - .../api/services/DonationsService.java | 125 +++++++++- .../PayPalConfirmPaymentIntentResponse.java | 21 ++ .../PayPalCreatePaymentIntentResponse.java | 27 +++ .../PayPalCreatePaymentMethodResponse.java | 23 ++ .../BoostReceiptCredentialRequestJson.java | 6 +- .../internal/push/DonationProcessor.java | 32 +++ ...PalConfirmOneTimePaymentIntentPayload.java | 35 +++ ...yPalCreateOneTimePaymentIntentPayload.java | 31 +++ .../PayPalCreatePaymentMethodPayload.java | 16 ++ .../internal/push/PushServiceSocket.java | 70 ++++-- 47 files changed, 1382 insertions(+), 144 deletions(-) create mode 100644 app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/PayPalRepository.kt create mode 100644 app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/paypal/PayPalConfirmationDialogFragment.kt create mode 100644 app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/paypal/PayPalConfirmationResult.kt create mode 100644 app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/paypal/PayPalPaymentInProgressFragment.kt create mode 100644 app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/paypal/PayPalPaymentInProgressViewModel.kt create mode 100644 app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/paypal/PayPalPaymentMethodId.kt create mode 100644 app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/models/PayPalButton.kt create mode 100644 app/src/main/res/drawable/paypal.xml rename app/src/main/res/layout/{stripe_payment_in_progress_fragment.xml => donation_in_progress_fragment.xml} (100%) rename app/src/main/res/layout/{stripe_3ds_dialog_fragment.xml => donation_webview_fragment.xml} (100%) create mode 100644 app/src/main/res/layout/paypal_button.xml create mode 100644 app/src/test/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/paypal/PayPalConfirmationResultTest.kt create mode 100644 donations/lib/src/main/java/org/signal/donations/PaymentSourceType.kt delete mode 100644 donations/lib/src/main/java/org/signal/donations/StripePaymentSourceType.kt create mode 100644 libsignal/service/src/main/java/org/whispersystems/signalservice/api/subscriptions/PayPalConfirmPaymentIntentResponse.java create mode 100644 libsignal/service/src/main/java/org/whispersystems/signalservice/api/subscriptions/PayPalCreatePaymentIntentResponse.java create mode 100644 libsignal/service/src/main/java/org/whispersystems/signalservice/api/subscriptions/PayPalCreatePaymentMethodResponse.java create mode 100644 libsignal/service/src/main/java/org/whispersystems/signalservice/internal/push/DonationProcessor.java create mode 100644 libsignal/service/src/main/java/org/whispersystems/signalservice/internal/push/PayPalConfirmOneTimePaymentIntentPayload.java create mode 100644 libsignal/service/src/main/java/org/whispersystems/signalservice/internal/push/PayPalCreateOneTimePaymentIntentPayload.java create mode 100644 libsignal/service/src/main/java/org/whispersystems/signalservice/internal/push/PayPalCreatePaymentMethodPayload.java diff --git a/app/src/main/java/org/thoughtcrime/securesms/badges/gifts/flow/GiftFlowConfirmationFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/badges/gifts/flow/GiftFlowConfirmationFragment.kt index 0a60ac05b..973575723 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/badges/gifts/flow/GiftFlowConfirmationFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/badges/gifts/flow/GiftFlowConfirmationFragment.kt @@ -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)) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/InAppDonations.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/InAppDonations.kt index af0d8a15b..2c3ad7ab8 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/InAppDonations.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/InAppDonations.kt @@ -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() } /** diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/OneTimeDonationRepository.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/OneTimeDonationRepository.kt index e68b3c687..a4811c32a 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/OneTimeDonationRepository.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/OneTimeDonationRepository.kt @@ -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 handleCreatePaymentIntentError(throwable: Throwable, badgeRecipient: RecipientId, paymentSourceType: PaymentSourceType): Single { + 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>> { @@ -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 -> diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/PayPalRepository.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/PayPalRepository.kt new file mode 100644 index 000000000..4caae85f8 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/PayPalRepository.kt @@ -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 { + 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 { + 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 { + 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()) + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/StripeRepository.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/StripeRepository.kt index 05863f1c7..9b630bd2d 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/StripeRepository.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/StripeRepository.kt @@ -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 { 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 handleCreatePaymentIntentError(throwable: Throwable, badgeRecipient: RecipientId, paymentSourceType: StripePaymentSourceType): Single { - 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)) - } - } } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/DonateToSignalFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/DonateToSignalFragment.kt index 12be5c633..7338dde57 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/DonateToSignalFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/DonateToSignalFragment.kt @@ -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)) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/DonationCheckoutDelegate.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/DonationCheckoutDelegate.kt index 2e1308b81..68e2de512 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/DonationCheckoutDelegate.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/DonationCheckoutDelegate.kt @@ -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() diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/gateway/GatewaySelectorBottomSheet.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/gateway/GatewaySelectorBottomSheet.kt index a3f65bc5d..32f4ce8d0 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/gateway/GatewaySelectorBottomSheet.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/gateway/GatewaySelectorBottomSheet.kt @@ -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), diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/paypal/PayPalConfirmationDialogFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/paypal/PayPalConfirmationDialogFragment.kt new file mode 100644 index 000000000..df1c0d8e9 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/paypal/PayPalConfirmationDialogFragment.kt @@ -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() + } + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/paypal/PayPalConfirmationResult.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/paypal/PayPalConfirmationResult.kt new file mode 100644 index 000000000..4c0711e2d --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/paypal/PayPalConfirmationResult.kt @@ -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 + ) + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/paypal/PayPalPaymentInProgressFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/paypal/PayPalPaymentInProgressFragment.kt new file mode 100644 index 000000000..d31c25ff3 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/paypal/PayPalPaymentInProgressFragment.kt @@ -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 { + return Single.create { 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 { + return Single.create { 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()) + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/paypal/PayPalPaymentInProgressViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/paypal/PayPalPaymentInProgressViewModel.kt new file mode 100644 index 000000000..f35fdd9da --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/paypal/PayPalPaymentInProgressViewModel.kt @@ -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 = 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, + routeToMonthlyConfirmation: (PayPalCreatePaymentMethodResponse) -> Single + ) { + 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 + ) { + 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) { + 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 create(modelClass: Class): T { + return modelClass.cast(PayPalPaymentInProgressViewModel(payPalRepository, monthlyDonationRepository, oneTimeDonationRepository)) as T + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/paypal/PayPalPaymentMethodId.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/paypal/PayPalPaymentMethodId.kt new file mode 100644 index 000000000..71e195f1b --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/paypal/PayPalPaymentMethodId.kt @@ -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 diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/stripe/Stripe3DSDialogFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/stripe/Stripe3DSDialogFragment.kt index 5012d31da..55edb67b8 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/stripe/Stripe3DSDialogFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/stripe/Stripe3DSDialogFragment.kt @@ -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() } diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/stripe/StripePaymentInProgressFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/stripe/StripePaymentInProgressFragment.kt index 957180f3e..e024c87a0 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/stripe/StripePaymentInProgressFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/stripe/StripePaymentInProgressFragment.kt @@ -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() diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/stripe/StripePaymentInProgressViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/stripe/StripePaymentInProgressViewModel.kt index d96786c96..9541b0b34 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/stripe/StripePaymentInProgressViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/stripe/StripePaymentInProgressViewModel.kt @@ -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(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 ) diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/errors/DonationError.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/errors/DonationError.kt index 8bdb8e998..37e0e7e51 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/errors/DonationError.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/errors/DonationError.kt @@ -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 { diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/errors/DonationErrorParams.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/errors/DonationErrorParams.kt index a26e47c6c..ac6bf1a91 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/errors/DonationErrorParams.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/errors/DonationErrorParams.kt @@ -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 private constructor( @@ -25,7 +25,7 @@ class DonationErrorParams private constructor( ): DonationErrorParams { 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 private constructor( } } - private fun getDeclinedErrorParams(context: Context, declinedError: DonationError.PaymentSetupError.DeclinedError, callback: Callback): DonationErrorParams { + private fun getDeclinedErrorParams(context: Context, declinedError: DonationError.PaymentSetupError.StripeDeclinedError, callback: Callback): DonationErrorParams { val getStripeDeclineCodePositiveActionParams: (Context, Callback, Int) -> DonationErrorParams = 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 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) diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/models/PayPalButton.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/models/PayPalButton.kt new file mode 100644 index 000000000..327ac60b9 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/models/PayPalButton.kt @@ -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 { + override fun areItemsTheSame(newItem: Model): Boolean = true + override fun areContentsTheSame(newItem: Model): Boolean = isEnabled == newItem.isEnabled + } + + class ViewHolder(binding: PaypalButtonBinding) : BindingViewHolder(binding) { + override fun bind(model: Model) { + binding.paypalButton.isEnabled = model.isEnabled + binding.paypalButton.setOnClickListener { model.onClick() } + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/BoostReceiptRequestResponseJob.java b/app/src/main/java/org/thoughtcrime/securesms/jobs/BoostReceiptRequestResponseJob.java index b365b245b..8b8dd67c0 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/jobs/BoostReceiptRequestResponseJob.java +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/BoostReceiptRequestResponseJob.java @@ -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 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 { @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); diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/SubscriptionReceiptRequestResponseJob.java b/app/src/main/java/org/thoughtcrime/securesms/jobs/SubscriptionReceiptRequestResponseJob.java index 1e9577642..2b3b7282c 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/jobs/SubscriptionReceiptRequestResponseJob.java +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/SubscriptionReceiptRequestResponseJob.java @@ -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); diff --git a/app/src/main/java/org/thoughtcrime/securesms/keyvalue/DonationsValues.kt b/app/src/main/java/org/thoughtcrime/securesms/keyvalue/DonationsValues.kt index 702b7683e..c8098d764 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/keyvalue/DonationsValues.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/keyvalue/DonationsValues.kt @@ -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) diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/FeatureFlags.java b/app/src/main/java/org/thoughtcrime/securesms/util/FeatureFlags.java index 986d10662..52714e7df 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/util/FeatureFlags.java +++ b/app/src/main/java/org/thoughtcrime/securesms/util/FeatureFlags.java @@ -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 getMemoryValues() { return new TreeMap<>(REMOTE_VALUES); diff --git a/app/src/main/res/drawable/paypal.xml b/app/src/main/res/drawable/paypal.xml new file mode 100644 index 000000000..73b91e4ef --- /dev/null +++ b/app/src/main/res/drawable/paypal.xml @@ -0,0 +1,43 @@ + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/stripe_payment_in_progress_fragment.xml b/app/src/main/res/layout/donation_in_progress_fragment.xml similarity index 100% rename from app/src/main/res/layout/stripe_payment_in_progress_fragment.xml rename to app/src/main/res/layout/donation_in_progress_fragment.xml diff --git a/app/src/main/res/layout/stripe_3ds_dialog_fragment.xml b/app/src/main/res/layout/donation_webview_fragment.xml similarity index 100% rename from app/src/main/res/layout/stripe_3ds_dialog_fragment.xml rename to app/src/main/res/layout/donation_webview_fragment.xml diff --git a/app/src/main/res/layout/paypal_button.xml b/app/src/main/res/layout/paypal_button.xml new file mode 100644 index 000000000..b54c4a8ea --- /dev/null +++ b/app/src/main/res/layout/paypal_button.xml @@ -0,0 +1,24 @@ + + + + + diff --git a/app/src/main/res/navigation/donate_to_signal.xml b/app/src/main/res/navigation/donate_to_signal.xml index 762a00a20..9775f1349 100644 --- a/app/src/main/res/navigation/donate_to_signal.xml +++ b/app/src/main/res/navigation/donate_to_signal.xml @@ -39,6 +39,9 @@ + @@ -74,7 +77,7 @@ android:id="@+id/stripePaymentInProgressFragment" android:name="org.thoughtcrime.securesms.components.settings.app.subscription.donate.stripe.StripePaymentInProgressFragment" android:label="stripe_payment_in_progress_fragment" - tools:layout="@layout/stripe_payment_in_progress_fragment"> + tools:layout="@layout/donation_in_progress_fragment"> + tools:layout="@layout/donation_webview_fragment"> + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/navigation/gift_flow.xml b/app/src/main/res/navigation/gift_flow.xml index 3eaab60b2..9a3eb5f78 100644 --- a/app/src/main/res/navigation/gift_flow.xml +++ b/app/src/main/res/navigation/gift_flow.xml @@ -59,6 +59,9 @@ + + tools:layout="@layout/donation_in_progress_fragment"> + tools:layout="@layout/donation_webview_fragment"> + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/values-night/dark_colors.xml b/app/src/main/res/values-night/dark_colors.xml index 6e8f1e6dd..e9dc8c82d 100644 --- a/app/src/main/res/values-night/dark_colors.xml +++ b/app/src/main/res/values-night/dark_colors.xml @@ -1,5 +1,7 @@ + #00000000 + @color/signal_colorSurface @color/signal_colorTransparentInverse5 @color/signal_colorTransparentInverse5 diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml index 7f20951a7..f9bcba634 100644 --- a/app/src/main/res/values/colors.xml +++ b/app/src/main/res/values/colors.xml @@ -38,6 +38,8 @@ #e6ffffff #f3ffffff + #80838089 + #32000000 #4d4d4d diff --git a/app/src/test/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/paypal/PayPalConfirmationResultTest.kt b/app/src/test/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/paypal/PayPalConfirmationResultTest.kt new file mode 100644 index 000000000..7bdbea065 --- /dev/null +++ b/app/src/test/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/paypal/PayPalConfirmationResultTest.kt @@ -0,0 +1,41 @@ +package org.thoughtcrime.securesms.components.settings.app.subscription.donate.paypal + +import android.app.Application +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import org.thoughtcrime.securesms.components.settings.app.subscription.PayPalRepository + +@RunWith(RobolectricTestRunner::class) +@Config(application = Application::class) +class PayPalConfirmationResultTest { + + companion object { + private val PAYER_ID = "asdf" + private val PAYMENT_ID = "sdfg" + private val PAYMENT_TOKEN = "dfgh" + + private val TEST_URL = "${PayPalRepository.ONE_TIME_RETURN_URL}?PayerID=$PAYER_ID&paymentId=$PAYMENT_ID&token=$PAYMENT_TOKEN" + private val TEST_MISSING_PARAM_URL = "${PayPalRepository.ONE_TIME_RETURN_URL}?paymentId=$PAYMENT_ID&token=$PAYMENT_TOKEN" + } + + @Test + fun givenATestUrl_whenIFromUri_thenIExpectCorrectResult() { + val result = PayPalConfirmationResult.fromUrl(TEST_URL) + + assertEquals( + PayPalConfirmationResult(PAYER_ID, PAYMENT_ID, PAYMENT_TOKEN), + result + ) + } + + @Test + fun givenATestUrlWithMissingField_whenIFromUri_thenIExpectNull() { + val result = PayPalConfirmationResult.fromUrl(TEST_MISSING_PARAM_URL) + + assertNull(result) + } +} diff --git a/donations/lib/src/main/java/org/signal/donations/CreditCardPaymentSource.kt b/donations/lib/src/main/java/org/signal/donations/CreditCardPaymentSource.kt index b8833b879..8f1f01eed 100644 --- a/donations/lib/src/main/java/org/signal/donations/CreditCardPaymentSource.kt +++ b/donations/lib/src/main/java/org/signal/donations/CreditCardPaymentSource.kt @@ -8,7 +8,7 @@ import org.json.JSONObject class CreditCardPaymentSource( private val payload: JSONObject ) : StripeApi.PaymentSource { - override val type = StripePaymentSourceType.CREDIT_CARD + override val type = PaymentSourceType.Stripe.CreditCard override fun parameterize(): JSONObject = payload override fun getTokenId(): String = parameterize().getString("id") override fun email(): String? = null diff --git a/donations/lib/src/main/java/org/signal/donations/GooglePayPaymentSource.kt b/donations/lib/src/main/java/org/signal/donations/GooglePayPaymentSource.kt index 8014e4498..e185830a7 100644 --- a/donations/lib/src/main/java/org/signal/donations/GooglePayPaymentSource.kt +++ b/donations/lib/src/main/java/org/signal/donations/GooglePayPaymentSource.kt @@ -4,7 +4,7 @@ import com.google.android.gms.wallet.PaymentData import org.json.JSONObject class GooglePayPaymentSource(private val paymentData: PaymentData) : StripeApi.PaymentSource { - override val type = StripePaymentSourceType.GOOGLE_PAY + override val type = PaymentSourceType.Stripe.GooglePay override fun parameterize(): JSONObject { val jsonData = JSONObject(paymentData.toJson()) diff --git a/donations/lib/src/main/java/org/signal/donations/PaymentSourceType.kt b/donations/lib/src/main/java/org/signal/donations/PaymentSourceType.kt new file mode 100644 index 000000000..7114e51d9 --- /dev/null +++ b/donations/lib/src/main/java/org/signal/donations/PaymentSourceType.kt @@ -0,0 +1,36 @@ +package org.signal.donations + +sealed class PaymentSourceType { + abstract val code: String + + object Unknown : PaymentSourceType() { + override val code: String = Codes.UNKNOWN.code + } + + object PayPal : PaymentSourceType() { + override val code: String = Codes.PAY_PAL.code + } + + sealed class Stripe(override val code: String) : PaymentSourceType() { + object CreditCard : Stripe(Codes.CREDIT_CARD.code) + object GooglePay : Stripe(Codes.GOOGLE_PAY.code) + } + + private enum class Codes(val code: String) { + UNKNOWN("unknown"), + PAY_PAL("paypal"), + CREDIT_CARD("credit_card"), + GOOGLE_PAY("google_pay") + } + + companion object { + fun fromCode(code: String?): PaymentSourceType { + return when (Codes.values().firstOrNull { it.code == code } ?: Codes.UNKNOWN) { + Codes.UNKNOWN -> Unknown + Codes.PAY_PAL -> PayPal + Codes.CREDIT_CARD -> Stripe.CreditCard + Codes.GOOGLE_PAY -> Stripe.GooglePay + } + } + } +} diff --git a/donations/lib/src/main/java/org/signal/donations/StripeApi.kt b/donations/lib/src/main/java/org/signal/donations/StripeApi.kt index e7377edf9..70283f7f2 100644 --- a/donations/lib/src/main/java/org/signal/donations/StripeApi.kt +++ b/donations/lib/src/main/java/org/signal/donations/StripeApi.kt @@ -520,7 +520,7 @@ class StripeApi( ) : Parcelable interface PaymentSource { - val type: StripePaymentSourceType + val type: PaymentSourceType fun parameterize(): JSONObject fun getTokenId(): String fun email(): String? diff --git a/donations/lib/src/main/java/org/signal/donations/StripePaymentSourceType.kt b/donations/lib/src/main/java/org/signal/donations/StripePaymentSourceType.kt deleted file mode 100644 index c179d52b1..000000000 --- a/donations/lib/src/main/java/org/signal/donations/StripePaymentSourceType.kt +++ /dev/null @@ -1,12 +0,0 @@ -package org.signal.donations - -enum class StripePaymentSourceType(val code: String) { - CREDIT_CARD("credit_card"), - GOOGLE_PAY("google_pay"); - - companion object { - fun fromCode(code: String?): StripePaymentSourceType { - return values().firstOrNull { it.code == code } ?: GOOGLE_PAY - } - } -} diff --git a/libsignal/service/src/main/java/org/whispersystems/signalservice/api/services/DonationsService.java b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/services/DonationsService.java index 1bb049d04..7ccc1fbe2 100644 --- a/libsignal/service/src/main/java/org/whispersystems/signalservice/api/services/DonationsService.java +++ b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/services/DonationsService.java @@ -9,6 +9,9 @@ import org.whispersystems.signalservice.api.groupsv2.GroupsV2Operations; import org.whispersystems.signalservice.api.profiles.SignalServiceProfile; import org.whispersystems.signalservice.api.push.exceptions.NonSuccessfulResponseCodeException; import org.whispersystems.signalservice.api.subscriptions.ActiveSubscription; +import org.whispersystems.signalservice.api.subscriptions.PayPalConfirmPaymentIntentResponse; +import org.whispersystems.signalservice.api.subscriptions.PayPalCreatePaymentIntentResponse; +import org.whispersystems.signalservice.api.subscriptions.PayPalCreatePaymentMethodResponse; import org.whispersystems.signalservice.api.subscriptions.StripeClientSecret; import org.whispersystems.signalservice.api.subscriptions.SubscriberId; import org.whispersystems.signalservice.api.subscriptions.SubscriptionLevels; @@ -16,6 +19,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.DonationProcessor; import org.whispersystems.signalservice.internal.push.PushServiceSocket; import java.io.IOException; @@ -87,8 +91,8 @@ public class DonationsService { * @param paymentIntentId PaymentIntent ID from a boost donation intent response. * @param receiptCredentialRequest Client-generated request token */ - public ServiceResponse submitBoostReceiptCredentialRequestSync(String paymentIntentId, ReceiptCredentialRequest receiptCredentialRequest) { - return wrapInServiceResponse(() -> new Pair<>(pushServiceSocket.submitBoostReceiptCredentials(paymentIntentId, receiptCredentialRequest), 200)); + public ServiceResponse submitBoostReceiptCredentialRequestSync(String paymentIntentId, ReceiptCredentialRequest receiptCredentialRequest, DonationProcessor processor) { + return wrapInServiceResponse(() -> new Pair<>(pushServiceSocket.submitBoostReceiptCredentials(paymentIntentId, receiptCredentialRequest, processor), 200)); } /** @@ -217,24 +221,129 @@ public class DonationsService { public ServiceResponse setDefaultStripePaymentMethod(SubscriberId subscriberId, String paymentMethodId) { return wrapInServiceResponse(() -> { - pushServiceSocket.setDefaultSubscriptionPaymentMethod(subscriberId.serialize(), paymentMethodId); + pushServiceSocket.setDefaultStripeSubscriptionPaymentMethod(subscriberId.serialize(), paymentMethodId); return new Pair<>(EmptyResponse.INSTANCE, 200); }); } /** - * - * @param subscriberId The subscriber ID to create a payment method for. - * @return Client secret for a SetupIntent. It should not be used with the PaymentIntent stripe APIs - * but instead with the SetupIntent stripe APIs. + * @param subscriberId The subscriber ID to create a payment method for. + * @return Client secret for a SetupIntent. It should not be used with the PaymentIntent stripe APIs + * but instead with the SetupIntent stripe APIs. */ public ServiceResponse createStripeSubscriptionPaymentMethod(SubscriberId subscriberId) { return wrapInServiceResponse(() -> { - StripeClientSecret clientSecret = pushServiceSocket.createSubscriptionPaymentMethod(subscriberId.serialize()); + StripeClientSecret clientSecret = pushServiceSocket.createStripeSubscriptionPaymentMethod(subscriberId.serialize()); return new Pair<>(clientSecret, 200); }); } + /** + * Creates a PayPal one-time payment and returns the approval URL + * Response Codes + * 200 — success + * 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 + */ + public ServiceResponse createPayPalOneTimePaymentIntent(Locale locale, + String currencyCode, + String amount, + long level, + String returnUrl, + String cancelUrl) + { + return wrapInServiceResponse(() -> { + PayPalCreatePaymentIntentResponse response = pushServiceSocket.createPayPalOneTimePaymentIntent( + locale, + currencyCode.toUpperCase(Locale.US), // Chris Eager to make this case insensitive in the next build + Long.parseLong(amount), + level, + returnUrl, + cancelUrl + ); + return new Pair<>(response, 200); + }); + } + + /** + * Confirms a PayPal one-time payment and returns the paymentId for receipt credentials + * Response Codes + * 200 — success + * 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 + */ + public ServiceResponse confirmPayPalOneTimePaymentIntent(String currency, + String amount, + long level, + String payerId, + String paymentId, + String paymentToken) + { + return wrapInServiceResponse(() -> { + PayPalConfirmPaymentIntentResponse response = pushServiceSocket.confirmPayPalOneTimePaymentIntent(currency, amount, level, payerId, paymentId, paymentToken); + return new Pair<>(response, 200); + }); + } + + /** + * Sets up a payment method via PayPal for recurring charges. + * + * 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 + */ + public ServiceResponse createPayPalPaymentMethod(Locale locale, + SubscriberId subscriberId, + String returnUrl, + String cancelUrl) { + return wrapInServiceResponse(() -> { + PayPalCreatePaymentMethodResponse response = pushServiceSocket.createPayPalPaymentMethod(locale, subscriberId.serialize(), returnUrl, cancelUrl); + return new Pair<>(response, 200); + }); + } + + /** + * Sets the given payment method as the default in PayPal + * + * Response Codes + * 200 — success + * 403 — subscriberId password mismatches OR account authentication is present + * 404 — subscriberId is not found or malformed + * 409 — subscriber record is missing customer ID - must call POST /v1/subscription/{subscriberId}/create_payment_method first + * + * @param subscriberId User subscriber id + * @param paymentMethodId Payment method id to make default + */ + public ServiceResponse setDefaultPayPalPaymentMethod(SubscriberId subscriberId, String paymentMethodId) { + return wrapInServiceResponse(() -> { + pushServiceSocket.setDefaultPaypalSubscriptionPaymentMethod(subscriberId.serialize(), paymentMethodId); + return new Pair<>(EmptyResponse.INSTANCE, 200); + }); + } + public ServiceResponse submitReceiptCredentialRequestSync(SubscriberId subscriberId, ReceiptCredentialRequest receiptCredentialRequest) { return wrapInServiceResponse(() -> { ReceiptCredentialResponse response = pushServiceSocket.submitReceiptCredentials(subscriberId.serialize(), receiptCredentialRequest); diff --git a/libsignal/service/src/main/java/org/whispersystems/signalservice/api/subscriptions/PayPalConfirmPaymentIntentResponse.java b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/subscriptions/PayPalConfirmPaymentIntentResponse.java new file mode 100644 index 000000000..d83683825 --- /dev/null +++ b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/subscriptions/PayPalConfirmPaymentIntentResponse.java @@ -0,0 +1,21 @@ +package org.whispersystems.signalservice.api.subscriptions; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonProperty; + +/** + * Response object from creating a payment intent via PayPal + */ +public class PayPalConfirmPaymentIntentResponse { + + private final String paymentId; + + @JsonCreator + public PayPalConfirmPaymentIntentResponse(@JsonProperty("paymentId") String paymentId) { + this.paymentId = paymentId; + } + + public String getPaymentId() { + return paymentId; + } +} diff --git a/libsignal/service/src/main/java/org/whispersystems/signalservice/api/subscriptions/PayPalCreatePaymentIntentResponse.java b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/subscriptions/PayPalCreatePaymentIntentResponse.java new file mode 100644 index 000000000..fb87bca0e --- /dev/null +++ b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/subscriptions/PayPalCreatePaymentIntentResponse.java @@ -0,0 +1,27 @@ +package org.whispersystems.signalservice.api.subscriptions; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonProperty; + +/** + * Response object from creating a payment intent via PayPal + */ +public class PayPalCreatePaymentIntentResponse { + + private final String approvalUrl; + private final String paymentId; + + @JsonCreator + public PayPalCreatePaymentIntentResponse(@JsonProperty("approvalUrl") String approvalUrl, @JsonProperty("paymentId") String paymentId) { + this.approvalUrl = approvalUrl; + this.paymentId = paymentId; + } + + public String getApprovalUrl() { + return approvalUrl; + } + + public String getPaymentId() { + return paymentId; + } +} diff --git a/libsignal/service/src/main/java/org/whispersystems/signalservice/api/subscriptions/PayPalCreatePaymentMethodResponse.java b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/subscriptions/PayPalCreatePaymentMethodResponse.java new file mode 100644 index 000000000..59d4bcb5c --- /dev/null +++ b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/subscriptions/PayPalCreatePaymentMethodResponse.java @@ -0,0 +1,23 @@ +package org.whispersystems.signalservice.api.subscriptions; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonProperty; + +public class PayPalCreatePaymentMethodResponse { + private final String approvalUrl; + private final String token; + + @JsonCreator + public PayPalCreatePaymentMethodResponse(@JsonProperty("approvalUrl") String approvalUrl, @JsonProperty("token") String token) { + this.approvalUrl = approvalUrl; + this.token = token; + } + + public String getApprovalUrl() { + return approvalUrl; + } + + public String getToken() { + return token; + } +} diff --git a/libsignal/service/src/main/java/org/whispersystems/signalservice/internal/push/BoostReceiptCredentialRequestJson.java b/libsignal/service/src/main/java/org/whispersystems/signalservice/internal/push/BoostReceiptCredentialRequestJson.java index 4a633f006..b065a21ad 100644 --- a/libsignal/service/src/main/java/org/whispersystems/signalservice/internal/push/BoostReceiptCredentialRequestJson.java +++ b/libsignal/service/src/main/java/org/whispersystems/signalservice/internal/push/BoostReceiptCredentialRequestJson.java @@ -12,8 +12,12 @@ class BoostReceiptCredentialRequestJson { @JsonProperty("receiptCredentialRequest") private final String receiptCredentialRequest; - BoostReceiptCredentialRequestJson(String paymentIntentId, ReceiptCredentialRequest receiptCredentialRequest) { + @JsonProperty("processor") + private final String processor; + + BoostReceiptCredentialRequestJson(String paymentIntentId, ReceiptCredentialRequest receiptCredentialRequest, DonationProcessor processor) { this.paymentIntentId = paymentIntentId; this.receiptCredentialRequest = Base64.encodeBytes(receiptCredentialRequest.serialize()); + this.processor = processor.getCode(); } } diff --git a/libsignal/service/src/main/java/org/whispersystems/signalservice/internal/push/DonationProcessor.java b/libsignal/service/src/main/java/org/whispersystems/signalservice/internal/push/DonationProcessor.java new file mode 100644 index 000000000..17616a9d6 --- /dev/null +++ b/libsignal/service/src/main/java/org/whispersystems/signalservice/internal/push/DonationProcessor.java @@ -0,0 +1,32 @@ +package org.whispersystems.signalservice.internal.push; + +import java.util.Objects; + +/** + * Represents the processor being used for a given payment, required when accessing + * receipt credentials. + */ +public enum DonationProcessor { + STRIPE("STRIPE"), + PAYPAL("BRAINTREE"); + + private final String code; + + DonationProcessor(String code) { + this.code = code; + } + + public String getCode() { + return code; + } + + public static DonationProcessor fromCode(String code) { + for (final DonationProcessor value : values()) { + if (Objects.equals(code, value.code)) { + return value; + } + } + + throw new IllegalArgumentException(code); + } +} diff --git a/libsignal/service/src/main/java/org/whispersystems/signalservice/internal/push/PayPalConfirmOneTimePaymentIntentPayload.java b/libsignal/service/src/main/java/org/whispersystems/signalservice/internal/push/PayPalConfirmOneTimePaymentIntentPayload.java new file mode 100644 index 000000000..a9d887390 --- /dev/null +++ b/libsignal/service/src/main/java/org/whispersystems/signalservice/internal/push/PayPalConfirmOneTimePaymentIntentPayload.java @@ -0,0 +1,35 @@ +package org.whispersystems.signalservice.internal.push; + +import com.fasterxml.jackson.annotation.JsonProperty; + +/** + * Request JSON for confirming a PayPal one-time payment intent + */ +class PayPalConfirmOneTimePaymentIntentPayload { + @JsonProperty + private String amount; + + @JsonProperty + private String currency; + + @JsonProperty + private long level; + + @JsonProperty + private String payerId; + + @JsonProperty + private String paymentId; + + @JsonProperty + private String paymentToken; + + public PayPalConfirmOneTimePaymentIntentPayload(String amount, String currency, long level, String payerId, String paymentId, String paymentToken) { + this.amount = amount; + this.currency = currency; + this.level = level; + this.payerId = payerId; + this.paymentId = paymentId; + this.paymentToken = paymentToken; + } +} diff --git a/libsignal/service/src/main/java/org/whispersystems/signalservice/internal/push/PayPalCreateOneTimePaymentIntentPayload.java b/libsignal/service/src/main/java/org/whispersystems/signalservice/internal/push/PayPalCreateOneTimePaymentIntentPayload.java new file mode 100644 index 000000000..f48be306d --- /dev/null +++ b/libsignal/service/src/main/java/org/whispersystems/signalservice/internal/push/PayPalCreateOneTimePaymentIntentPayload.java @@ -0,0 +1,31 @@ +package org.whispersystems.signalservice.internal.push; + +import com.fasterxml.jackson.annotation.JsonProperty; + +/** + * Request JSON for creating a PayPal one-time payment intent + */ +class PayPalCreateOneTimePaymentIntentPayload { + @JsonProperty + private long amount; + + @JsonProperty + private String currency; + + @JsonProperty + private long level; + + @JsonProperty + private String returnUrl; + + @JsonProperty + private String cancelUrl; + + public PayPalCreateOneTimePaymentIntentPayload(long amount, String currency, long level, String returnUrl, String cancelUrl) { + this.amount = amount; + this.currency = currency; + this.level = level; + this.returnUrl = returnUrl; + this.cancelUrl = cancelUrl; + } +} diff --git a/libsignal/service/src/main/java/org/whispersystems/signalservice/internal/push/PayPalCreatePaymentMethodPayload.java b/libsignal/service/src/main/java/org/whispersystems/signalservice/internal/push/PayPalCreatePaymentMethodPayload.java new file mode 100644 index 000000000..d23195960 --- /dev/null +++ b/libsignal/service/src/main/java/org/whispersystems/signalservice/internal/push/PayPalCreatePaymentMethodPayload.java @@ -0,0 +1,16 @@ +package org.whispersystems.signalservice.internal.push; + +import com.fasterxml.jackson.annotation.JsonProperty; + +class PayPalCreatePaymentMethodPayload { + @JsonProperty + private String returnUrl; + + @JsonProperty + private String cancelUrl; + + PayPalCreatePaymentMethodPayload(String returnUrl, String cancelUrl) { + this.returnUrl = returnUrl; + this.cancelUrl = cancelUrl; + } +} diff --git a/libsignal/service/src/main/java/org/whispersystems/signalservice/internal/push/PushServiceSocket.java b/libsignal/service/src/main/java/org/whispersystems/signalservice/internal/push/PushServiceSocket.java index b81b22e82..1b8ca76fa 100644 --- a/libsignal/service/src/main/java/org/whispersystems/signalservice/internal/push/PushServiceSocket.java +++ b/libsignal/service/src/main/java/org/whispersystems/signalservice/internal/push/PushServiceSocket.java @@ -86,6 +86,9 @@ import org.whispersystems.signalservice.api.push.exceptions.UsernameMalformedExc import org.whispersystems.signalservice.api.push.exceptions.UsernameTakenException; import org.whispersystems.signalservice.api.storage.StorageAuthResponse; import org.whispersystems.signalservice.api.subscriptions.ActiveSubscription; +import org.whispersystems.signalservice.api.subscriptions.PayPalConfirmPaymentIntentResponse; +import org.whispersystems.signalservice.api.subscriptions.PayPalCreatePaymentIntentResponse; +import org.whispersystems.signalservice.api.subscriptions.PayPalCreatePaymentMethodResponse; import org.whispersystems.signalservice.api.subscriptions.StripeClientSecret; import org.whispersystems.signalservice.api.subscriptions.SubscriptionLevels; import org.whispersystems.signalservice.api.util.CredentialsProvider; @@ -261,17 +264,21 @@ public class PushServiceSocket { private static final String DONATION_REDEEM_RECEIPT = "/v1/donation/redeem-receipt"; - private static final String SUBSCRIPTION_LEVELS = "/v1/subscription/levels"; - private static final String UPDATE_SUBSCRIPTION_LEVEL = "/v1/subscription/%s/level/%s/%s/%s"; - private static final String SUBSCRIPTION = "/v1/subscription/%s"; - private static final String CREATE_SUBSCRIPTION_PAYMENT_METHOD = "/v1/subscription/%s/create_payment_method"; - private static final String DEFAULT_SUBSCRIPTION_PAYMENT_METHOD = "/v1/subscription/%s/default_payment_method/%s"; - private static final String SUBSCRIPTION_RECEIPT_CREDENTIALS = "/v1/subscription/%s/receipt_credentials"; - private static final String BOOST_AMOUNTS = "/v1/subscription/boost/amounts"; - private static final String GIFT_AMOUNT = "/v1/subscription/boost/amounts/gift"; - private static final String CREATE_BOOST_PAYMENT_INTENT = "/v1/subscription/boost/create"; - private static final String BOOST_RECEIPT_CREDENTIALS = "/v1/subscription/boost/receipt_credentials"; - private static final String BOOST_BADGES = "/v1/subscription/boost/badges"; + private static final String SUBSCRIPTION_LEVELS = "/v1/subscription/levels"; + private static final String UPDATE_SUBSCRIPTION_LEVEL = "/v1/subscription/%s/level/%s/%s/%s"; + private static final String SUBSCRIPTION = "/v1/subscription/%s"; + private static final String CREATE_STRIPE_SUBSCRIPTION_PAYMENT_METHOD = "/v1/subscription/%s/create_payment_method"; + private static final String CREATE_PAYPAL_SUBSCRIPTION_PAYMENT_METHOD = "/v1/subscription/%s/create_payment_method/paypal"; + private static final String DEFAULT_STRIPE_SUBSCRIPTION_PAYMENT_METHOD = "/v1/subscription/%s/default_payment_method/%s"; + private static final String DEFAULT_PAYPAL_SUBSCRIPTION_PAYMENT_METHOD = "/v1/subscription/%s/default_payment_method/paypal/%s"; + private static final String SUBSCRIPTION_RECEIPT_CREDENTIALS = "/v1/subscription/%s/receipt_credentials"; + private static final String BOOST_AMOUNTS = "/v1/subscription/boost/amounts"; + private static final String GIFT_AMOUNT = "/v1/subscription/boost/amounts/gift"; + private static final String CREATE_STRIPE_ONE_TIME_PAYMENT_INTENT = "/v1/subscription/boost/create"; + private static final String CREATE_PAYPAL_ONE_TIME_PAYMENT_INTENT = "/v1/subscription/boost/paypal/create"; + private static final String CONFIRM_PAYPAL_ONE_TIME_PAYMENT_INTENT = "/v1/subscription/boost/paypal/confirm"; + private static final String BOOST_RECEIPT_CREDENTIALS = "/v1/subscription/boost/receipt_credentials"; + private static final String BOOST_BADGES = "/v1/subscription/boost/badges"; private static final String CDSI_AUTH = "/v2/directory/auth"; @@ -1019,10 +1026,33 @@ public class PushServiceSocket { public StripeClientSecret createStripeOneTimePaymentIntent(String currencyCode, long amount, long level) throws IOException { String payload = JsonUtil.toJson(new StripeOneTimePaymentIntentPayload(amount, currencyCode, level)); - String result = makeServiceRequestWithoutAuthentication(CREATE_BOOST_PAYMENT_INTENT, "POST", payload); + String result = makeServiceRequestWithoutAuthentication(CREATE_STRIPE_ONE_TIME_PAYMENT_INTENT, "POST", payload); return JsonUtil.fromJsonResponse(result, StripeClientSecret.class); } + public PayPalCreatePaymentIntentResponse createPayPalOneTimePaymentIntent(Locale locale, String currencyCode, long amount, long level, String returnUrl, String cancelUrl) throws IOException { + Map headers = Collections.singletonMap("Accept-Language", locale.getLanguage() + "-" + locale.getCountry()); + String payload = JsonUtil.toJson(new PayPalCreateOneTimePaymentIntentPayload(amount, currencyCode, level, returnUrl, cancelUrl)); + String result = makeServiceRequestWithoutAuthentication(CREATE_PAYPAL_ONE_TIME_PAYMENT_INTENT, "POST", payload, headers, NO_HANDLER); + + return JsonUtil.fromJsonResponse(result, PayPalCreatePaymentIntentResponse.class); + } + + public PayPalConfirmPaymentIntentResponse confirmPayPalOneTimePaymentIntent(String currency, String amount, long level, String payerId, String paymentId, String paymentToken) throws IOException { + String payload = JsonUtil.toJson(new PayPalConfirmOneTimePaymentIntentPayload(amount, currency, level, payerId, paymentId, paymentToken)); + Log.d(TAG, payload); + String result = makeServiceRequestWithoutAuthentication(CONFIRM_PAYPAL_ONE_TIME_PAYMENT_INTENT, "POST", payload); + return JsonUtil.fromJsonResponse(result, PayPalConfirmPaymentIntentResponse.class); + } + + public PayPalCreatePaymentMethodResponse createPayPalPaymentMethod(Locale locale, String subscriberId, String returnUrl, String cancelUrl) throws IOException { + Map headers = Collections.singletonMap("Accept-Language", locale.getLanguage() + "-" + locale.getCountry()); + String payload = JsonUtil.toJson(new PayPalCreatePaymentMethodPayload(returnUrl, cancelUrl)); + String result = makeServiceRequestWithoutAuthentication(String.format(CREATE_PAYPAL_SUBSCRIPTION_PAYMENT_METHOD, subscriberId), "POST", payload); + return JsonUtil.fromJsonResponse(result, PayPalCreatePaymentMethodResponse.class); + } + + public Map> getBoostAmounts() throws IOException { String result = makeServiceRequestWithoutAuthentication(BOOST_AMOUNTS, "GET", null); TypeReference>> typeRef = new TypeReference>>() {}; @@ -1040,8 +1070,8 @@ public class PushServiceSocket { return JsonUtil.fromJsonResponse(result, SubscriptionLevels.class); } - public ReceiptCredentialResponse submitBoostReceiptCredentials(String paymentIntentId, ReceiptCredentialRequest receiptCredentialRequest) throws IOException { - String payload = JsonUtil.toJson(new BoostReceiptCredentialRequestJson(paymentIntentId, receiptCredentialRequest)); + public ReceiptCredentialResponse submitBoostReceiptCredentials(String paymentIntentId, ReceiptCredentialRequest receiptCredentialRequest, DonationProcessor processor) throws IOException { + String payload = JsonUtil.toJson(new BoostReceiptCredentialRequestJson(paymentIntentId, receiptCredentialRequest, processor)); String response = makeServiceRequestWithoutAuthentication( BOOST_RECEIPT_CREDENTIALS, "POST", @@ -1082,13 +1112,17 @@ public class PushServiceSocket { makeServiceRequestWithoutAuthentication(String.format(SUBSCRIPTION, subscriberId), "DELETE", null); } - public StripeClientSecret createSubscriptionPaymentMethod(String subscriberId) throws IOException { - String response = makeServiceRequestWithoutAuthentication(String.format(CREATE_SUBSCRIPTION_PAYMENT_METHOD, subscriberId), "POST", ""); + public StripeClientSecret createStripeSubscriptionPaymentMethod(String subscriberId) throws IOException { + String response = makeServiceRequestWithoutAuthentication(String.format(CREATE_STRIPE_SUBSCRIPTION_PAYMENT_METHOD, subscriberId), "POST", ""); return JsonUtil.fromJson(response, StripeClientSecret.class); } - public void setDefaultSubscriptionPaymentMethod(String subscriberId, String paymentMethodId) throws IOException { - makeServiceRequestWithoutAuthentication(String.format(DEFAULT_SUBSCRIPTION_PAYMENT_METHOD, subscriberId, paymentMethodId), "POST", ""); + public void setDefaultStripeSubscriptionPaymentMethod(String subscriberId, String paymentMethodId) throws IOException { + makeServiceRequestWithoutAuthentication(String.format(DEFAULT_STRIPE_SUBSCRIPTION_PAYMENT_METHOD, subscriberId, paymentMethodId), "POST", ""); + } + + public void setDefaultPaypalSubscriptionPaymentMethod(String subscriberId, String paymentMethodId) throws IOException { + makeServiceRequestWithoutAuthentication(String.format(DEFAULT_PAYPAL_SUBSCRIPTION_PAYMENT_METHOD, subscriberId, paymentMethodId), "POST", ""); } public ReceiptCredentialResponse submitReceiptCredentials(String subscriptionId, ReceiptCredentialRequest receiptCredentialRequest) throws IOException {