From 736895204d833e1578275665cf1372513f6615d8 Mon Sep 17 00:00:00 2001 From: Matthew Date: Thu, 28 Mar 2024 15:08:30 -0700 Subject: [PATCH] add adding payment method to the checkout screen --- app/src/main/graphql/checkout.graphql | 3 + .../mock/services/MockApolloClient.kt | 4 +- .../kickstarter/services/KSApolloClientV2.kt | 16 ++- .../ui/activities/ProjectPageActivity.kt | 125 +++++++++++++++++ .../compose/projectpage/CheckoutScreen.kt | 4 +- .../LatePledgeCheckoutViewModel.kt | 131 +++++++++++++++--- 6 files changed, 248 insertions(+), 35 deletions(-) diff --git a/app/src/main/graphql/checkout.graphql b/app/src/main/graphql/checkout.graphql index a57c5022d4..cd0f3152de 100644 --- a/app/src/main/graphql/checkout.graphql +++ b/app/src/main/graphql/checkout.graphql @@ -68,6 +68,9 @@ mutation CompleteOnSessionCheckout($checkoutId: ID!, $paymentIntentClientSecret: completeOnSessionCheckout(input:{ checkoutId: $checkoutId, paymentIntentClientSecret: $paymentIntentClientSecret, paymentSourceId: $paymentSourceId } ) { checkout { id + backing { + requiresAction + } } } } diff --git a/app/src/main/java/com/kickstarter/mock/services/MockApolloClient.kt b/app/src/main/java/com/kickstarter/mock/services/MockApolloClient.kt index c86d661e93..edfddfacb6 100644 --- a/app/src/main/java/com/kickstarter/mock/services/MockApolloClient.kt +++ b/app/src/main/java/com/kickstarter/mock/services/MockApolloClient.kt @@ -289,8 +289,8 @@ open class MockApolloClientV2 : ApolloClientTypeV2 { override fun completeOnSessionCheckout( checkoutId: String, paymentIntentClientSecret: String, - paymentSourceId: String - ): io.reactivex.Observable { + paymentSourceId: String? + ): io.reactivex.Observable> { return io.reactivex.Observable.empty() } diff --git a/app/src/main/java/com/kickstarter/services/KSApolloClientV2.kt b/app/src/main/java/com/kickstarter/services/KSApolloClientV2.kt index f9ae36d613..e29c295923 100644 --- a/app/src/main/java/com/kickstarter/services/KSApolloClientV2.kt +++ b/app/src/main/java/com/kickstarter/services/KSApolloClientV2.kt @@ -187,8 +187,8 @@ interface ApolloClientTypeV2 { fun completeOnSessionCheckout( checkoutId: String, paymentIntentClientSecret: String, - paymentSourceId: String - ): Observable + paymentSourceId: String? + ): Observable> fun createAttributionEvent(eventInput: CreateAttributionEventData): Observable } @@ -1565,10 +1565,10 @@ class KSApolloClientV2(val service: ApolloClient, val gson: Gson) : ApolloClient override fun completeOnSessionCheckout( checkoutId: String, paymentIntentClientSecret: String, - paymentSourceId: String - ): Observable { + paymentSourceId: String? + ): Observable> { return Observable.defer { - val ps = PublishSubject.create() + val ps = PublishSubject.create>() this.service.mutate( CompleteOnSessionCheckoutMutation.builder() @@ -1585,8 +1585,10 @@ class KSApolloClientV2(val service: ApolloClient, val gson: Gson) : ApolloClient if (response.hasErrors()) { ps.onError(Exception(response.errors?.first()?.message)) } else { - response.data?.completeOnSessionCheckout()?.checkout()?.id()?.let { - ps.onNext(it) + response.data?.completeOnSessionCheckout()?.checkout()?.id()?.let { checkoutId -> + response.data?.completeOnSessionCheckout()?.checkout()?.backing()?.requiresAction()?.let { requiresAction -> + ps.onNext(Pair(checkoutId, requiresAction)) + } } ?: ps.onError(Exception("Checkout ID was null")) } ps.onComplete() diff --git a/app/src/main/java/com/kickstarter/ui/activities/ProjectPageActivity.kt b/app/src/main/java/com/kickstarter/ui/activities/ProjectPageActivity.kt index 2aa051f7bd..d76ee9579b 100644 --- a/app/src/main/java/com/kickstarter/ui/activities/ProjectPageActivity.kt +++ b/app/src/main/java/com/kickstarter/ui/activities/ProjectPageActivity.kt @@ -54,10 +54,12 @@ import com.kickstarter.libs.utils.ApplicationUtils import com.kickstarter.libs.utils.ViewUtils import com.kickstarter.libs.utils.extensions.addToDisposable import com.kickstarter.libs.utils.extensions.getEnvironment +import com.kickstarter.libs.utils.extensions.getPaymentSheetConfiguration import com.kickstarter.libs.utils.extensions.showLatePledgeFlow import com.kickstarter.libs.utils.extensions.toVisibility import com.kickstarter.models.Project import com.kickstarter.models.Reward +import com.kickstarter.models.StoredCard import com.kickstarter.ui.IntentKey import com.kickstarter.ui.activities.compose.projectpage.ProjectPledgeButtonAndFragmentContainer import com.kickstarter.ui.adapters.ProjectPagerAdapter @@ -72,6 +74,7 @@ import com.kickstarter.ui.extensions.finishWithAnimation import com.kickstarter.ui.extensions.hideKeyboard import com.kickstarter.ui.extensions.selectPledgeFragment import com.kickstarter.ui.extensions.setUpConnectivityStatusCheck +import com.kickstarter.ui.extensions.showErrorToast import com.kickstarter.ui.extensions.showSnackbar import com.kickstarter.ui.extensions.startRootCommentsActivity import com.kickstarter.ui.extensions.startUpdatesActivity @@ -87,11 +90,19 @@ import com.kickstarter.viewmodels.projectpage.LatePledgeCheckoutViewModel import com.kickstarter.viewmodels.projectpage.PagerTabConfig import com.kickstarter.viewmodels.projectpage.ProjectPageViewModel import com.kickstarter.viewmodels.projectpage.RewardsSelectionViewModel +import com.stripe.android.ApiResultCallback +import com.stripe.android.PaymentIntentResult +import com.stripe.android.Stripe +import com.stripe.android.StripeIntentResult +import com.stripe.android.paymentsheet.PaymentSheet +import com.stripe.android.paymentsheet.PaymentSheetResult +import com.stripe.android.paymentsheet.model.PaymentOption import com.stripe.android.view.CardInputWidget import io.reactivex.android.schedulers.AndroidSchedulers import io.reactivex.disposables.CompositeDisposable import io.reactivex.schedulers.Schedulers import kotlinx.coroutines.launch +import timber.log.Timber class ProjectPageActivity : AppCompatActivity(), @@ -118,6 +129,9 @@ class ProjectPageActivity : private lateinit var addOnsViewModelFactory: AddOnsViewModel.Factory private val addOnsViewModel: AddOnsViewModel by viewModels { addOnsViewModelFactory } + private lateinit var stripe: Stripe + private lateinit var flowController: PaymentSheet.FlowController + private val projectShareLabelString = R.string.project_accessibility_button_share_label private val projectShareCopyString = R.string.project_share_twitter_message private val projectStarConfirmationString = R.string.project_star_confirmation @@ -156,9 +170,16 @@ class ProjectPageActivity : confirmDetailsViewModelFactory = ConfirmDetailsViewModel.Factory(env) addOnsViewModelFactory = AddOnsViewModel.Factory(env) latePledgeCheckoutViewModelFactory = LatePledgeCheckoutViewModel.Factory(env) + stripe = requireNotNull(env.stripe()) env } + flowController = PaymentSheet.FlowController.create( + activity = this, + paymentOptionCallback = ::onPaymentOption, + paymentResultCallback = ::onPaymentSheetResult + ) + this.ksString = requireNotNull(environment?.ksString()) viewModel.configureWith(intent) @@ -525,6 +546,22 @@ class ProjectPageActivity : val userStoredCards = latePledgeCheckoutUIState.storeCards val userEmail = latePledgeCheckoutUIState.userEmail + LaunchedEffect(Unit) { + latePledgeCheckoutViewModel.clientSecretForNewPaymentMethod.collect { + flowControllerPresentPaymentOption(it) + } + } + + LaunchedEffect(Unit) { + latePledgeCheckoutViewModel.paymentRequiresAction.collect { + stripeNextAction(it) + } + } + + latePledgeCheckoutViewModel.provideErrorAction { message -> + showToastError(message) + } + val pagerState = rememberPagerState(initialPage = 0, pageCount = { 4 }) this@ProjectPageActivity.onBackPressedDispatcher.addCallback { @@ -614,6 +651,7 @@ class ProjectPageActivity : latePledgeCheckoutViewModel.onPledgeButtonClicked(selectedCard = selectedCard, project = projectData.project(), totalAmount = totalAmount) }, onAddPaymentMethodClicked = { + latePledgeCheckoutViewModel.onAddNewCardClicked(project = projectData.project(), totalAmount = totalAmount) } ) } @@ -1085,9 +1123,96 @@ class ProjectPageActivity : } } + // Update the UI with the returned PaymentOption + private fun onPaymentOption(paymentOption: PaymentOption?) { + paymentOption?.let { + val storedCard = StoredCard.Builder( + lastFourDigits = paymentOption.label.takeLast(4), + resourceId = paymentOption.drawableResourceId, + clientSetupId = "-1" + ).build() + latePledgeCheckoutViewModel.onNewCardSuccessfullyAdded(storedCard) + Timber.d(" ${this.javaClass.canonicalName} onPaymentOption with ${storedCard.lastFourDigits()} and ${storedCard.clientSetupId()}") + flowController.confirm() + } + } + + private fun onPaymentSheetResult(paymentSheetResult: PaymentSheetResult) { + when (paymentSheetResult) { + is PaymentSheetResult.Canceled -> { + showErrorToast( + applicationContext, + binding.pledgeContainerCompose, + getString(R.string.general_error_oops) + ) + } + + is PaymentSheetResult.Failed -> { + val errorMessage = paymentSheetResult.error.localizedMessage ?: getString(R.string.general_error_something_wrong) + showErrorToast( + applicationContext, + binding.pledgeContainerCompose, + errorMessage + ) + } + + is PaymentSheetResult.Completed -> { + } + } + } + + private fun stripeNextAction(it: String) { + try { + // - PaymentIntent format + if (it.contains("pi_")) { + stripe.handleNextActionForPayment(this, it) + } else { + // - SetupIntent format + stripe.handleNextActionForSetupIntent(this, it) + } + } catch (exception: Exception) { + FirebaseCrashlytics.getInstance().recordException(exception) + } + } + + private fun flowControllerPresentPaymentOption(clientSecret: String) { + flowController.configureWithPaymentIntent( + paymentIntentClientSecret = clientSecret, + configuration = getPaymentSheetConfiguration(), + callback = ::onConfigured + ) + } + + // error is not used by is needed in the callback object + private fun onConfigured(success: Boolean, error: Throwable?) { + if (success) { + flowController.presentPaymentOptions() + } else { + showToastError() + } + } + + private fun showToastError(message: String? = null) { + showErrorToast(applicationContext, binding.pledgeContainerCompose, message ?: getString(R.string.general_error_something_wrong)) + } + override fun onActivityResult(requestCode: Int, resultCode: Int, intent: Intent?) { super.onActivityResult(requestCode, resultCode, intent) viewModel.activityResult(create(requestCode, resultCode, intent)) + stripe.onPaymentResult( + requestCode, intent, + object : ApiResultCallback { + override fun onSuccess(result: PaymentIntentResult) { + if (result.outcome == StripeIntentResult.Outcome.SUCCEEDED) { + // Go to thanks page + } else showToastError() + } + + override fun onError(e: Exception) { + showToastError() + } + } + ) } override fun onDestroy() { diff --git a/app/src/main/java/com/kickstarter/ui/activities/compose/projectpage/CheckoutScreen.kt b/app/src/main/java/com/kickstarter/ui/activities/compose/projectpage/CheckoutScreen.kt index f2524adca8..946e59da24 100644 --- a/app/src/main/java/com/kickstarter/ui/activities/compose/projectpage/CheckoutScreen.kt +++ b/app/src/main/java/com/kickstarter/ui/activities/compose/projectpage/CheckoutScreen.kt @@ -136,9 +136,7 @@ fun CheckoutScreen( var (selectedOption, onOptionSelected) = remember { mutableStateOf( storedCards.firstOrNull { - project.acceptedCardType( - it.type() - ) + project.acceptedCardType(it.type()) || it.isFromPaymentSheet() } ) } diff --git a/app/src/main/java/com/kickstarter/viewmodels/projectpage/LatePledgeCheckoutViewModel.kt b/app/src/main/java/com/kickstarter/viewmodels/projectpage/LatePledgeCheckoutViewModel.kt index 61cd36ab3d..2341ae7a75 100644 --- a/app/src/main/java/com/kickstarter/viewmodels/projectpage/LatePledgeCheckoutViewModel.kt +++ b/app/src/main/java/com/kickstarter/viewmodels/projectpage/LatePledgeCheckoutViewModel.kt @@ -8,12 +8,17 @@ import com.kickstarter.libs.utils.extensions.isNotNull import com.kickstarter.models.CreatePaymentIntentInput import com.kickstarter.models.Project import com.kickstarter.models.StoredCard +import com.stripe.android.ApiResultCallback import com.stripe.android.Stripe import com.stripe.android.confirmPaymentIntent import com.stripe.android.model.ConfirmPaymentIntentParams +import com.stripe.android.model.PaymentIntent +import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.collect @@ -37,6 +42,10 @@ class LatePledgeCheckoutViewModel(val environment: Environment) : ViewModel() { private var stripe: Stripe = requireNotNull(environment.stripe()) + private var clientSecretForNewCard: String = "" + private var newStoredCard: StoredCard = StoredCard.builder().build() + private var errorAction: (message: String?) -> Unit = {} + private var mutableLatePledgeCheckoutUIState = MutableStateFlow(LatePledgeCheckoutUIState()) val latePledgeCheckoutUIState: StateFlow get() = mutableLatePledgeCheckoutUIState @@ -47,36 +56,34 @@ class LatePledgeCheckoutViewModel(val environment: Environment) : ViewModel() { initialValue = LatePledgeCheckoutUIState() ) + private var mutableClientSecretForNewPaymentMethod = MutableSharedFlow() + val clientSecretForNewPaymentMethod: SharedFlow + get() = mutableClientSecretForNewPaymentMethod.asSharedFlow() + + private var mutablePaymentRequiresAction = MutableSharedFlow() + val paymentRequiresAction: SharedFlow + get() = mutablePaymentRequiresAction.asSharedFlow() + init { viewModelScope.launch { environment.currentUserV2()?.observable()?.asFlow()?.map { if (it.isPresent()) { apolloClient.userPrivacy().asFlow().map { userPrivacy -> userEmail = userPrivacy.email - mutableLatePledgeCheckoutUIState.emit( - LatePledgeCheckoutUIState( - storeCards = storedCards, - userEmail = userEmail, - ) - ) + emitCurrentState() }.catch { - // Some error + errorAction.invoke(null) }.collect() apolloClient.getStoredCards().asFlow().map { cards -> storedCards = cards - mutableLatePledgeCheckoutUIState.emit( - LatePledgeCheckoutUIState( - storeCards = storedCards, - userEmail = userEmail, - ) - ) + emitCurrentState() }.catch { - // Some error + errorAction.invoke(null) }.collect() } }?.catch { - // Some error + errorAction.invoke(null) }?.collect() } } @@ -85,7 +92,66 @@ class LatePledgeCheckoutViewModel(val environment: Environment) : ViewModel() { this.checkoutId = checkoutId.toString() } + fun onAddNewCardClicked(project: Project, totalAmount: Double) { + viewModelScope.launch { + apolloClient.createPaymentIntent( + CreatePaymentIntentInput( + project = project, + amount = totalAmount.toString() + ) + ).asFlow().map { clientSecret -> + clientSecretForNewCard = clientSecret + mutableClientSecretForNewPaymentMethod.emit(clientSecretForNewCard) + }.catch { + errorAction.invoke(null) + }.collect() + } + } + + fun onNewCardSuccessfullyAdded(storedCard: StoredCard) { + newStoredCard = storedCard + var mutableStoredCardList = storedCards.toMutableList() + mutableStoredCardList.add(0, storedCard) + storedCards = mutableStoredCardList.toList() + viewModelScope.launch { + emitCurrentState() + } + } + fun onPledgeButtonClicked(selectedCard: StoredCard?, project: Project, totalAmount: Double) { + if (selectedCard == newStoredCard) { + stripe.retrievePaymentIntent( + clientSecret = clientSecretForNewCard, + callback = object : ApiResultCallback { + override fun onError(e: Exception) { + errorAction.invoke(null) + } + + override fun onSuccess(result: PaymentIntent) { + result.paymentMethodId?.let { cardId -> + val cardWithId = selectedCard.toBuilder().stripeCardId(cardId).build() + newStoredCard = cardWithId + createPaymentIntentForCheckout(cardWithId, project, totalAmount) + } ?: run { + errorAction.invoke(null) + } + } + } + ) + } else { + createPaymentIntentForCheckout(selectedCard, project, totalAmount) + } + } + + fun provideErrorAction(errorAction: (message: String?) -> Unit) { + this.errorAction = errorAction + } + + private fun createPaymentIntentForCheckout( + selectedCard: StoredCard?, + project: Project, + totalAmount: Double + ) { viewModelScope.launch { apolloClient.createPaymentIntent( CreatePaymentIntentInput( @@ -97,13 +163,13 @@ class LatePledgeCheckoutViewModel(val environment: Environment) : ViewModel() { checkoutId?.let { validateCheckout(clientSecret = clientSecret, selectedCard = selectedCard) } ?: run { - // Some error + errorAction.invoke(null) } } ?: run { - // Some error + errorAction.invoke(null) } }.catch { - // Some error + errorAction.invoke(null) }.collect() } } @@ -119,9 +185,14 @@ class LatePledgeCheckoutViewModel(val environment: Environment) : ViewModel() { stripeConfirmPaymentIntent(clientSecret = clientSecret, selectedCard = selectedCard) } else { // User validation.messages for displaying an error + if (validation.messages.isNotEmpty()) { + errorAction.invoke(validation.messages.first()) + } else { + errorAction.invoke(null) + } } }.catch { - // Some error + errorAction.invoke(null) }.collect() } @@ -135,6 +206,7 @@ class LatePledgeCheckoutViewModel(val environment: Environment) : ViewModel() { val paymentIntent = stripe.confirmPaymentIntent(withSDK) if (paymentIntent.lastPaymentError.isNotNull()) { // Display error with lastPaymentError.message + errorAction.invoke(paymentIntent.lastPaymentError?.message) } else { // Success, move on completeOnSessionCheckout(clientSecret = clientSecret, selectedCard = selectedCard) @@ -145,14 +217,27 @@ class LatePledgeCheckoutViewModel(val environment: Environment) : ViewModel() { apolloClient.completeOnSessionCheckout( checkoutId = checkoutId ?: "", paymentIntentClientSecret = clientSecret, - paymentSourceId = selectedCard.id() ?: "" - ).asFlow().map { - // Full flow success, show thanks page + paymentSourceId = if(selectedCard == newStoredCard) null else selectedCard.id() ?: "" + ).asFlow().map { iDRequiresActionPair -> + if (iDRequiresActionPair.second) { + mutablePaymentRequiresAction.emit(clientSecret) + } else { + // Go to Thanks Page, full complete flow + } }.catch { - // Some error + errorAction.invoke(null) }.collect() } + private suspend fun emitCurrentState() { + mutableLatePledgeCheckoutUIState.emit( + LatePledgeCheckoutUIState( + storeCards = storedCards, + userEmail = userEmail, + ) + ) + } + class Factory(private val environment: Environment) : ViewModelProvider.Factory { override fun create(modelClass: Class): T {