Skip to content

Commit

Permalink
Paypal Web Vault Without Purchase (#220)
Browse files Browse the repository at this point in the history
* Bump AGP and Kotlin library versions, and add PaymentMethod enum to OrderRequest.

* Refactor CreateOrderUseCase to use an internal JSON builder method.

* Add extra parameter to CreateSetupTokenUseCase to take a PaymentMethod enum.

* Update CreateSetupToken to create a paypal setup token.

* Add PayPalWebVaultView and supporting classes to project.

* Add PayPal Web Vault screen.

* Integrate PayPal Web Vault view into Demo app.

* Add deep link url to PayPalWebClient.

* Add vault method.

* Print setup token approval result.

* Rename Demo app PaymentToken class to CardPaymentToken.

* Differentiate between CardPaymentToken and PayPalPaymentToken.

* Fix detekt lint errors.

* Add error reporting to PayPalWebCheckoutClient.

* Update view model in response to paypal web vaulting notifications.

* Add proper user canceled notification for vault paypal.

* Add error handling for PayPal vault without purchase.

* Add PayPalWebCheckoutVaultExperienceContext type.

* Add PayPal experience context support to CreatePayPalSetupTokenUseCase.

* Add parsing constants for PayPalWebCheckoutClient deep link.

* Update README.md with new changes and use constant in BrowserSwitchHelper instead of magic string.

* Fix broken unit tests.

* Create CardSetupToken and PayPalSetupToken types.

* Change MainActivity superclass to Component activity.

* Remove PaymentMethod enum.

* Revert PayPalWebViewModel.

* Update cancel URL format in unit test.

* Update CHANGELOG after rebase.

* Add serializable type to CreateSetupTokenRequest.

* Refactor parsing logic for CreatePayPalSetupTokenUseCase.

* Update CreateCardSetupToken to use kotlinx.serialization.

* Update CreatePayPalPaymentTokenUseCase to use serializable.

* Update CreateCardPaymentTokenUseCase to use kotlin serialization.

* Change AnalyticsService coroutine invocation method.

* Remove internal order ID property from PayPalWebCheckoutClient."

* Rename PayPalDeepLinkUrlResult to PayPalWebCheckoutDeepLink.

* Add PayPalWebStatus to project.

* Add PayPalWebVaultRequest to project.

* Delete BrowserSwitchHelper.

* Add unit test for PayPalWebLauncher.

* Migrate BrowserSwitchHelper unit tests into PayPalWebLauncher file.

* Refactor unit tests for PayPalWebLauncher and PayPalWebCheckoutClient.

* Refactor nested block depth lint error.

* Fix detekt errors.

* Update demo app to call new vault method.

* Revert to AppCompatActivty until our app supports ComponentActivity.

* Fix double parsing error.

* Remove kotlinx.serialization from project.

* Remove mention of Kotlinx Retrofit.

* Reanme PayPalWebCheckoutVaultListener to PayPalWebVaultListener.

* Add test for browser switch failure propagation.

* Add additonal test cases for browser switch failure.

* Clean up lint errors.

* Add tests for vault and checkout error propagation in PayPalWebCheckoutClient.

* Add metadata to browser switch call for vaulting and checkout.

* Refactor unit tests.

* Fix lint errors.

* Clean up compilation error.

* Fix loader bug.

* Update PayPalWebCheckoutVault prefix to PayPalWebVault when naming.

* Rename PayPal web vault view model method from updateSetupToken to vaultSetupToken.

* Add documentation where needed.
  • Loading branch information
sshropshire authored Jan 4, 2024
1 parent f204a22 commit 7d4c613
Show file tree
Hide file tree
Showing 48 changed files with 1,377 additions and 640 deletions.
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,11 @@
## unreleased
* PaymentButtons
* Supporting custom corner radius on the PayPal Button
* PayPalWebPayments
* Add `PayPalWebVaultListener` interface
* Add `PayPalWebVaultResult` data class
* Add `vaultListener` property to `PayPalWebCheckoutClient`
* Add `vault()` method to `PayPalWebCheckoutClient`

## 1.1.0 (2023-12-05)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import com.paypal.android.corepayments.TrackingEventsAPI
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
import kotlinx.coroutines.launch

/**
* @suppress
Expand Down Expand Up @@ -43,7 +43,7 @@ class AnalyticsService internal constructor(
fun sendAnalyticsEvent(name: String, orderId: String?) {
// TODO: send analytics event using WorkManager (supports coroutines) to avoid lint error
// thrown because we don't use the Deferred result
scope.async {
scope.launch {
val timestamp = System.currentTimeMillis()
try {
val deviceData = deviceInspector.inspect()
Expand Down
3 changes: 1 addition & 2 deletions Demo/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -10,11 +10,10 @@ plugins {
def paypalProperties = loadPropertiesFromFile("paypal.properties")

android {
compileSdkVersion 34

defaultConfig {
applicationId "com.paypal.android"
minSdkVersion 21
compileSdk 34
targetSdkVersion 34
versionCode modules.demoAppVersionCode
versionName modules.sdkVersionName
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
package com.paypal.android.api.model

data class PaymentToken(
data class CardPaymentToken(
val id: String,
val customerId: String,
val cardLast4: String,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
package com.paypal.android.api.model

data class CardSetupToken(
val id: String,
val customerId: String,
val status: String,
)
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
package com.paypal.android.api.model

data class SetupToken(
data class PayPalPaymentToken(
val id: String,
val customerId: String,
val status: String
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
package com.paypal.android.api.model

data class PayPalSetupToken(
val id: String,
val customerId: String,
val status: String,
val approveVaultHref: String? = null
)
4 changes: 4 additions & 0 deletions Demo/src/main/java/com/paypal/android/ui/DemoApp.kt
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import com.paypal.android.ui.features.FeaturesView
import com.paypal.android.ui.paypalbuttons.PayPalButtonsView
import com.paypal.android.ui.paypalnative.PayPalNativeView
import com.paypal.android.ui.paypalweb.PayPalWebView
import com.paypal.android.ui.paypalwebvault.PayPalWebVaultView
import com.paypal.android.ui.selectcard.SelectCardView
import com.paypal.android.ui.vaultcard.VaultCardView
import com.paypal.android.ui.vaultcard.VaultCardViewModel
Expand Down Expand Up @@ -98,6 +99,9 @@ fun DemoApp() {
composable(DemoAppDestinations.PAYPAL_WEB) {
PayPalWebView()
}
composable(DemoAppDestinations.PAYPAL_WEB_VAULT) {
PayPalWebVaultView()
}
composable(DemoAppDestinations.PAYPAL_BUTTONS) {
PayPalButtonsView()
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ object DemoAppDestinations {
const val FEATURES_ROUTE = "features"
const val CARD_VAULT = "card_vault"
const val PAYPAL_WEB = "paypal_web"
const val PAYPAL_WEB_VAULT = "paypal_web_vault"
const val PAYPAL_BUTTONS = "paypal_buttons"
const val PAYPAL_NATIVE = "paypal_native"
const val SELECT_TEST_CARD = "select_test_card"
Expand All @@ -17,6 +18,7 @@ object DemoAppDestinations {
PAYPAL_BUTTONS -> "PayPal Buttons"
PAYPAL_NATIVE -> "PayPal Native"
SELECT_TEST_CARD -> "Select a Test Card"
PAYPAL_WEB_VAULT -> "PayPal Web Vault"
else -> "Demo"
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ enum class Feature(@StringRes val stringRes: Int, val routeName: String) {
CARD_APPROVE_ORDER(R.string.feature_approve_order, DemoAppDestinations.CARD_APPROVE_ORDER),
CARD_VAULT(R.string.feature_vault, DemoAppDestinations.CARD_VAULT),
PAYPAL_WEB(R.string.feature_paypal_web, DemoAppDestinations.PAYPAL_WEB),
PAYPAL_WEB_VAULT(R.string.feature_paypal_web_vault, DemoAppDestinations.PAYPAL_WEB_VAULT),
PAYPAL_BUTTONS(R.string.feature_paypal_buttons, DemoAppDestinations.PAYPAL_BUTTONS),
PAYPAL_NATIVE(R.string.feature_paypal_native, DemoAppDestinations.PAYPAL_NATIVE)
}
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,8 @@ private val cardFeatures = listOf(

private val payPalWebFeatures = listOf(
Feature.PAYPAL_WEB,
Feature.PAYPAL_BUTTONS
Feature.PAYPAL_BUTTONS,
Feature.PAYPAL_WEB_VAULT
)

private val payPalNativeFeatures = listOf(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,8 @@ import com.paypal.android.R
fun PayPalWebCheckoutCanceledView() {
OutlinedCard(modifier = Modifier.fillMaxWidth()) {
Column(modifier = Modifier.padding(8.dp)) {
Text(stringResource(id = R.string.checkout_cancelled))
Text(stringResource(id = R.string.user_cancelled))
Text(stringResource(id = R.string.checkout_canceled))
Text(stringResource(id = R.string.user_canceled))
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ class PayPalWebViewModel @Inject constructor(

companion object {
private val TAG = PayPalWebViewModel::class.qualifiedName
private const val URL_SCHEME = "com.paypal.android.demo"
}

private lateinit var paypalClient: PayPalWebCheckoutClient
Expand Down Expand Up @@ -117,26 +118,29 @@ class PayPalWebViewModel @Inject constructor(
}
}

private suspend fun fetchClientId(): String? = try {
sdkSampleServerAPI.fetchClientId()
} catch (e: UnknownHostException) {
payPalWebCheckoutError = APIClientError.payPalCheckoutError(e.message!!)
isStartCheckoutLoading = false
null
} catch (e: HttpException) {
payPalWebCheckoutError = APIClientError.payPalCheckoutError(e.message!!)
isStartCheckoutLoading = false
null
}

fun startWebCheckout(activity: AppCompatActivity) {
isStartCheckoutLoading = true
viewModelScope.launch {
try {
val clientId = sdkSampleServerAPI.fetchClientId()
fetchClientId()?.let { clientId ->
val coreConfig = CoreConfig(clientId)
payPalDataCollector = PayPalDataCollector(coreConfig)

paypalClient =
PayPalWebCheckoutClient(activity, coreConfig, "com.paypal.android.demo")
paypalClient = PayPalWebCheckoutClient(activity, coreConfig, URL_SCHEME)
paypalClient.listener = this@PayPalWebViewModel

val orderId = createdOrder!!.id!!
paypalClient.start(PayPalWebCheckoutRequest(orderId, fundingSource))
} catch (e: UnknownHostException) {
payPalWebCheckoutError = APIClientError.payPalCheckoutError(e.message!!)
isStartCheckoutLoading = false
} catch (e: HttpException) {
payPalWebCheckoutError = APIClientError.payPalCheckoutError(e.message!!)
isStartCheckoutLoading = false
}
}
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
package com.paypal.android.ui.paypalwebvault

import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.OutlinedCard
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import com.paypal.android.R

@Composable
fun PayPalWebCheckoutCanceledView() {
OutlinedCard(modifier = Modifier.fillMaxWidth()) {
Column(modifier = Modifier.padding(8.dp)) {
Text(stringResource(id = R.string.pay_pal_vault_canceled))
Text(stringResource(id = R.string.user_canceled))
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
package com.paypal.android.ui.paypalwebvault

import com.paypal.android.api.model.PayPalPaymentToken
import com.paypal.android.api.model.PayPalSetupToken
import com.paypal.android.corepayments.PayPalSDKError
import com.paypal.android.paypalwebpayments.PayPalWebVaultResult

data class PayPalWebVaultUiState(
val isCreateSetupTokenLoading: Boolean = false,
val vaultCustomerId: String = "",
val setupToken: PayPalSetupToken? = null,
val isVaultPayPalLoading: Boolean = false,
val payPalWebVaultResult: PayPalWebVaultResult? = null,
val payPalWebVaultError: PayPalSDKError? = null,
val isCreatePaymentTokenLoading: Boolean = false,
val paymentToken: PayPalPaymentToken? = null,
val isVaultingCanceled: Boolean = false,
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
package com.paypal.android.ui.paypalwebvault

import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedCard
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.paypal.android.paypalwebpayments.PayPalWebVaultResult
import com.paypal.android.ui.WireframeButton
import com.paypal.android.ui.approveorder.getActivity
import com.paypal.android.ui.paypalweb.PayPalWebCheckoutCanceledView
import com.paypal.android.ui.vaultcard.CreatePaymentTokenForm
import com.paypal.android.ui.vaultcard.CreateSetupTokenForm
import com.paypal.android.uishared.components.PayPalPaymentTokenView
import com.paypal.android.uishared.components.PayPalSDKErrorView
import com.paypal.android.uishared.components.PayPalSetupTokenView
import com.paypal.android.uishared.components.PropertyView

@Composable
fun PayPalWebVaultView(viewModel: PayPalWebVaultViewModel = hiltViewModel()) {
val context = LocalContext.current
val uiState by viewModel.uiState.collectAsStateWithLifecycle()

val scrollState = rememberScrollState()
LaunchedEffect(uiState) {
// continuously scroll to bottom of the list when event state is updated
scrollState.animateScrollTo(scrollState.maxValue)
}
Column(
modifier = Modifier
.fillMaxWidth()
.padding(8.dp)
.verticalScroll(scrollState)
) {
CreateSetupTokenForm(
isLoading = uiState.isCreateSetupTokenLoading,
customerId = uiState.vaultCustomerId,
onCustomerIdValueChange = { value -> viewModel.vaultCustomerId = value },
onSubmit = {
viewModel.createSetupToken()
}
)
uiState.setupToken?.let { setupToken ->
Spacer(modifier = Modifier.size(8.dp))
PayPalSetupTokenView(setupToken = setupToken)
Spacer(modifier = Modifier.size(8.dp))
VaultPayPal(
isLoading = uiState.isVaultPayPalLoading,
onSubmit = {
context.getActivity()?.let { activity ->
viewModel.vaultSetupToken(activity)
}
}
)
}
uiState.payPalWebVaultResult?.let { vaultResult ->
Spacer(modifier = Modifier.size(8.dp))
PayPalWebVaultResultView(vaultResult)
Spacer(modifier = Modifier.size(8.dp))
CreatePaymentTokenForm(
isLoading = uiState.isCreatePaymentTokenLoading,
onSubmit = { viewModel.createPaymentToken() }
)
}
uiState.payPalWebVaultError?.let { error ->
Spacer(modifier = Modifier.size(24.dp))
PayPalSDKErrorView(error = error)
}
if (uiState.isVaultingCanceled) {
Spacer(modifier = Modifier.size(24.dp))
PayPalWebCheckoutCanceledView()
}
uiState.paymentToken?.let { paymentToken ->
Spacer(modifier = Modifier.size(8.dp))
PayPalPaymentTokenView(paymentToken = paymentToken)
}
}
}

@Composable
fun VaultPayPal(
isLoading: Boolean,
onSubmit: () -> Unit
) {
OutlinedCard(
modifier = Modifier.fillMaxWidth()
) {
Column(
modifier = Modifier.padding(8.dp)
) {
Text(
text = "Vault PayPal",
style = MaterialTheme.typography.headlineSmall
)
Spacer(modifier = Modifier.size(8.dp))
WireframeButton(
text = "Vault PayPal",
isLoading = isLoading,
onClick = { onSubmit() },
modifier = Modifier.fillMaxWidth()
)
}
}
}

@Composable
fun PayPalWebVaultResultView(result: PayPalWebVaultResult) {
OutlinedCard(
modifier = Modifier.fillMaxWidth()
) {
Column(
modifier = Modifier.padding(8.dp)
) {
Text(
text = "PayPal Web Vault Result",
style = MaterialTheme.typography.titleLarge
)
PropertyView(name = "Approval Session ID", value = result.approvalSessionId)
}
}
}
Loading

0 comments on commit 7d4c613

Please sign in to comment.