Skip to content

Commit

Permalink
Card: Vault without Purchase 3DS (#243)
Browse files Browse the repository at this point in the history
* Move OptionList into shared components.

* Create BooleanOptionList.

* Add 3DS when required step to vault card view.

* Add SCA_ALWAYS option to project.

* Add SCA auth to CardClient.vault().

* Add parsing method for card client vaulting.

* Add notes for changes related to semantic versioning.

* Fix unit test.

* Rename CardAuthChallengeLauncher to CardAuthLauncher.

* Migrate remaining browser switch related code from CardClient into CardAuthChallenge.

* Migrate browser switch related tests out of CardClient.

* Remove parcelable conformance from CardVaultResult.

* Update CardAuthLauncher unit test.

* Restore unit tests for browser switch result parsing in card auth launcher.

* Add success test for CardAuthLauncher.

* Update CardAuthLauncher unit test to support vault deliver result cases.

* Implement CardClient unit tests for notify of order cancelation.

* Add tests for verifying approve order notifications.

* Add additional card client tests for vault notifications using listeners.

* Add vault notification tests for paypal web checkout client.

* Reduce function count in CardClient to comply with linter.

* Remove unused imports.

* Add supporting methods for fetching a setup token.

* Add GetSetupTokenUseCase to project.

* Implement auth challenge present with into about updated setup token.

* Remove unused imports in InfoColumn.

* Remove additional unused imports.

* Add unit tests for present auth challenge.

* Add Unit test stub.

* Add unit test for approval url.

* Update CHANGELOG and add documentation.

* Fix lint error.

* Use sca enum.

* Remove unecessary comments.

* Update order id capturing for status objects.
  • Loading branch information
sshropshire authored Mar 12, 2024
1 parent a4bd718 commit 3af42b4
Show file tree
Hide file tree
Showing 40 changed files with 1,233 additions and 342 deletions.
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@
* CardPayments
* Add `liabilityShift` property to `CardResult`
* Callback `PayPalSDKError` when `CardClient#approveOrder()` 3DS verification fails
* Add `CardClient#presentAuthChallenge()`
* Add `returnUrl` property to `CardVaultRequest`
* Add `authChallenge` property to `CardVaultResult`
* Add `CardAuthChallenge` type
* FraudDetection
* Bump Magnes dependency to version 5.4.0
* PaymentButtons
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,10 @@ import com.paypal.android.cardpayments.model.PaymentSource
import com.paypal.android.corepayments.PaymentsJSON
import org.json.JSONObject

internal data class ApproveOrderMetadata(val orderId: String, val paymentSource: PaymentSource?) {
internal data class ApproveOrderMetadata(
val orderId: String,
val paymentSource: PaymentSource? = null
) {

companion object {

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
package com.paypal.android.cardpayments

import android.net.Uri
import android.os.Parcelable
import kotlinx.parcelize.Parcelize

/**
* Pass this object to [CardClient.presentAuthChallenge] to present an authentication challenge
* that was received in response to a [CardClient.approveOrder] or [CardClient.vault] call.
*/
sealed class CardAuthChallenge {
// Ref: https://stackoverflow.com/a/44420084
internal abstract val url: Uri
internal abstract val returnUrlScheme: String?

@Parcelize
internal class ApproveOrder(
override val url: Uri,
val request: CardRequest,
override val returnUrlScheme: String? = Uri.parse(request.returnUrl).scheme
) : CardAuthChallenge(), Parcelable

@Parcelize
internal class Vault(
override val url: Uri,
val request: CardVaultRequest,
override val returnUrlScheme: String? = Uri.parse(request.returnUrl).scheme
) : CardAuthChallenge(), Parcelable
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
package com.paypal.android.cardpayments

import androidx.fragment.app.FragmentActivity
import com.braintreepayments.api.BrowserSwitchClient
import com.braintreepayments.api.BrowserSwitchException
import com.braintreepayments.api.BrowserSwitchOptions
import com.braintreepayments.api.BrowserSwitchResult
import com.braintreepayments.api.BrowserSwitchStatus
import com.paypal.android.corepayments.PayPalSDKError
import org.json.JSONObject

internal class CardAuthLauncher(
private val browserSwitchClient: BrowserSwitchClient = BrowserSwitchClient(),
) {

companion object {
private const val METADATA_KEY_REQUEST_TYPE = "request_type"
private const val REQUEST_TYPE_APPROVE_ORDER = "approve_order"
private const val REQUEST_TYPE_VAULT = "vault"

private const val METADATA_KEY_ORDER_ID = "order_id"
private const val METADATA_KEY_SETUP_TOKEN_ID = "setup_token_id"
}

fun presentAuthChallenge(
activity: FragmentActivity,
authChallenge: CardAuthChallenge
): PayPalSDKError? {
val metadata = when (authChallenge) {
is CardAuthChallenge.ApproveOrder -> {
val request = authChallenge.request
JSONObject()
.put(METADATA_KEY_REQUEST_TYPE, REQUEST_TYPE_APPROVE_ORDER)
.put(METADATA_KEY_ORDER_ID, request.orderId)
}

is CardAuthChallenge.Vault -> {
val request = authChallenge.request
JSONObject()
.put(METADATA_KEY_REQUEST_TYPE, REQUEST_TYPE_VAULT)
.put(METADATA_KEY_SETUP_TOKEN_ID, request.setupTokenId)
}
}

// launch the 3DS flow
val browserSwitchOptions = BrowserSwitchOptions()
.url(authChallenge.url)
.returnUrlScheme(authChallenge.returnUrlScheme)
.metadata(metadata)
return launchBrowserSwitch(activity, browserSwitchOptions)
}

private fun launchBrowserSwitch(
activity: FragmentActivity,
options: BrowserSwitchOptions
): PayPalSDKError? {
var error: PayPalSDKError? = null
try {
browserSwitchClient.start(activity, options)
} catch (e: BrowserSwitchException) {
error = CardError.browserSwitchError(e)
}
return error
}

fun deliverBrowserSwitchResult(activity: FragmentActivity) =
browserSwitchClient.deliverResult(activity)?.let { browserSwitchResult ->
val requestType =
browserSwitchResult.requestMetadata?.optString(METADATA_KEY_REQUEST_TYPE)
if (requestType == REQUEST_TYPE_VAULT) {
parseVaultResult(browserSwitchResult)
} else {
// Assume REQUEST_TYPE_APPROVE_ORDER
parseApproveOrderResult(browserSwitchResult)
}
}

private fun parseVaultResult(browserSwitchResult: BrowserSwitchResult): CardStatus? {
val setupTokenId =
browserSwitchResult.requestMetadata?.optString(METADATA_KEY_SETUP_TOKEN_ID)
return when (browserSwitchResult.status) {
BrowserSwitchStatus.SUCCESS -> parseVaultSuccessResult(browserSwitchResult)
BrowserSwitchStatus.CANCELED -> CardStatus.VaultCanceled(setupTokenId)
else -> null
}
}

private fun parseApproveOrderResult(browserSwitchResult: BrowserSwitchResult): CardStatus? {
val orderId = browserSwitchResult.requestMetadata?.optString(METADATA_KEY_ORDER_ID)
return if (orderId == null) {
CardStatus.ApproveOrderError(CardError.unknownError, orderId)
} else {
when (browserSwitchResult.status) {
BrowserSwitchStatus.SUCCESS ->
parseApproveOrderSuccessResult(browserSwitchResult, orderId)

BrowserSwitchStatus.CANCELED -> CardStatus.ApproveOrderCanceled(orderId)
else -> null
}
}
}

private fun parseVaultSuccessResult(browserSwitchResult: BrowserSwitchResult): CardStatus {
val deepLinkUrl = browserSwitchResult.deepLinkUrl
val requestMetadata = browserSwitchResult.requestMetadata

return if (deepLinkUrl == null || requestMetadata == null) {
CardStatus.VaultError(CardError.unknownError)
} else {
// TODO: see if there's a way that we can require the merchant to make their
// return and cancel urls conform to a strict schema

// NOTE: this assumes that when the merchant created a setup token, they used a
// return_url with word "success" in it (or a cancel_url with the word "cancel" in it)
val setupTokenId =
browserSwitchResult.requestMetadata?.optString(METADATA_KEY_SETUP_TOKEN_ID)
val deepLinkUrlString = deepLinkUrl.toString()
val didSucceed = deepLinkUrlString.contains("success")
if (didSucceed) {
val result = CardVaultResult(setupTokenId!!, "SCA_COMPLETE")
CardStatus.VaultSuccess(result)
} else {
val didCancel = deepLinkUrlString.contains("cancel")
if (didCancel) {
CardStatus.VaultCanceled(setupTokenId)
} else {
CardStatus.VaultError(CardError.unknownError)
}
}
}
}

private fun parseApproveOrderSuccessResult(
browserSwitchResult: BrowserSwitchResult,
orderId: String
): CardStatus {
val deepLinkUrl = browserSwitchResult.deepLinkUrl

return if (deepLinkUrl == null || deepLinkUrl.getQueryParameter("error") != null) {
CardStatus.ApproveOrderError(CardError.threeDSVerificationError, orderId)
} else {
val state = deepLinkUrl.getQueryParameter("state")
val code = deepLinkUrl.getQueryParameter("code")
if (state == null || code == null) {
CardStatus.ApproveOrderError(CardError.malformedDeepLinkError, orderId)
} else {
val liabilityShift = deepLinkUrl.getQueryParameter("liability_shift")
val result = CardResult(orderId, deepLinkUrl, liabilityShift)
CardStatus.ApproveOrderSuccess(result)
}
}
}
}
Loading

0 comments on commit 3af42b4

Please sign in to comment.