Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
package com.braintreepayments.api.paypalsavedpaymentmethod

import com.braintreepayments.api.core.GraphQLConstants
import org.json.JSONObject

/**
* The POST body for the GraphQL query `PaypalFundingInstrumentDetails`. A null [paymentMethodIdJwt], [orderId] or
* [merchantAccountId] is omitted from the request.
*
* @property fundingInstrumentType Which funding instrument to resolve.
* @property paymentMethodIdJwt The client token's payment method ID JWT. Sent for
* [PayPalFundingInstrumentFetchType.BUYER_DEFAULT_BILLING_AGREEMENT].
* @property orderId The approved checkout order ID. Sent for
* [PayPalFundingInstrumentFetchType.BUYER_UPDATED_BILLING_AGREEMENT].
* @property merchantAccountId Optional. A non-default merchant account to resolve the funding instrument against.
*/
internal data class PayPalFundingInstrumentDetailsGraphQLBody(
val fundingInstrumentType: PayPalFundingInstrumentFetchType,
val paymentMethodIdJwt: String?,
val orderId: String?,
val merchantAccountId: String?
) {

/**
* @return The JSON body for [com.braintreepayments.api.core.BraintreeClient.sendGraphQLPOST].
*/
fun toJson(): JSONObject {
val input = JSONObject()
.put(FUNDING_INSTRUMENT_TYPE_KEY, fundingInstrumentType.rawValue)
.put(INTEGRATION_CHANNEL_KEY, INTEGRATION_CHANNEL)
.put(OS_TYPE_KEY, OS_TYPE)
.putOpt(PAYMENT_METHOD_ID_JWT_KEY, paymentMethodIdJwt)
.putOpt(ORDER_ID_KEY, orderId)
.putOpt(MERCHANT_ACCOUNT_ID_KEY, merchantAccountId)
Comment on lines +32 to +34

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

putOpt sends "" for a blank id. Please treat blank as null.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed — blank paymentMethodIdJwt, orderId, and merchantAccountId are now normalized to null before putOpt, so they're omitted like the docs say.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I am sorry, where are those normalized ? is it in the previous PR? if so can it be referenced by the specific commit?


return JSONObject()
.put(GraphQLConstants.Keys.QUERY, QUERY)
.put(GraphQLConstants.Keys.VARIABLES, JSONObject().put(GraphQLConstants.Keys.INPUT, input))
}

private companion object {
const val FUNDING_INSTRUMENT_TYPE_KEY = "fundingInstrumentType"
const val INTEGRATION_CHANNEL_KEY = "integrationChannel"
const val OS_TYPE_KEY = "osType"
const val PAYMENT_METHOD_ID_JWT_KEY = "paymentMethodIdJwt"
const val ORDER_ID_KEY = "orderId"
const val MERCHANT_ACCOUNT_ID_KEY = "merchantAccountId"

const val INTEGRATION_CHANNEL = "BT_NATIVE_SDK"
const val OS_TYPE = "ANDROID"

val QUERY = """
query PaypalFundingInstrumentDetails(${'$'}input: PayPalFundingInstrumentDetailsInput!) {
paypalFundingInstrumentDetails(input: ${'$'}input) {
payer {
email
editable
}
paymentMethods {
label
imageUrl
lastDigits
type
subtype
}
}
}
""".trimIndent()
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
package com.braintreepayments.api.paypalsavedpaymentmethod

/**
* Determines which funding instrument the Braintree GraphQL API resolves, and therefore which identity field is
* required.
*
* @property rawValue The `PayPalFundingInstrumentFetchType` GraphQL enum value sent in the request.
*/
internal enum class PayPalFundingInstrumentFetchType(val rawValue: String) {

/**
* The default funding instrument vaulted on the buyer's billing agreement. Resolved from the client token's
* payment method ID JWT.
*/
BUYER_DEFAULT_BILLING_AGREEMENT("STICKY_FI"),

/**
* The funding instrument the buyer selected while approving a checkout. Resolved from that order's ID.
*/
BUYER_UPDATED_BILLING_AGREEMENT("FI_FROM_APPROVED_CHECKOUT")
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,181 @@
package com.braintreepayments.api.paypalsavedpaymentmethod

import android.content.Context
import com.braintreepayments.api.core.BraintreeClient
import com.braintreepayments.api.core.BraintreeException
import com.braintreepayments.api.core.ClientToken
import com.braintreepayments.api.core.ExperimentalBetaApi
import com.braintreepayments.api.core.GraphQLConstants
import com.braintreepayments.api.core.MerchantRepository
import com.braintreepayments.api.paypal.PayPalCheckoutRequest
import com.braintreepayments.api.paypal.PayPalClient
import com.braintreepayments.api.paypal.PayPalPaymentAuthRequest
import com.braintreepayments.api.paypal.PayPalPaymentAuthResult
import com.braintreepayments.api.paypal.PayPalResult
import com.braintreepayments.api.paypalsavedpaymentmethod.model.PayPalCreditMessagingResult
import com.braintreepayments.api.paypalsavedpaymentmethod.model.PayPalSavedPaymentMethodSummary
Comment on lines +15 to +16
import com.braintreepayments.api.sharedutils.Json
import org.json.JSONObject

/**
* Fetches what to display for a buyer's vaulted PayPal payment method: the buyer's saved funding instrument, and the
* Pay Later message that accompanies it. Also runs the PayPal side of the edit flow; the views launch the paysheet
* with `PayPalLauncher` between [createEditFundingInstrumentAuthRequest] and [tokenize].
*
* Requires a client token generated with the buyer's payment method ID. A tokenization key carries no
* `paymentMethodIdJwt`, so the saved funding instrument cannot be resolved.
*/
@OptIn(ExperimentalBetaApi::class)
internal class PayPalSavedPaymentMethodClient internal constructor(
private val braintreeClient: BraintreeClient,
private val merchantRepository: MerchantRepository = MerchantRepository.instance,
private val payPalClient: PayPalClient
) {

/**
* Starts the edit flow by creating the PayPal auth request, seeded with the buyer's billing agreement. Launch it
* with `PayPalLauncher`, then pass the result to [tokenize].
*
* @param context an Android Context.
* @param payPalCheckoutRequest the checkout the edited funding instrument applies to.
* @return the [PayPalPaymentAuthRequest] to launch, or a failure from PayPal.
* @throws PayPalSavedPaymentMethodException.InvalidAuthorization if the authorization is not a client token.
* @throws PayPalSavedPaymentMethodException.MissingPaymentMethodIdJwt if the client token carries no payment method
* ID JWT. Both are thrown before any request, so the paysheet never opens as a regular checkout.
*/
suspend fun createEditFundingInstrumentAuthRequest(
context: Context,
payPalCheckoutRequest: PayPalCheckoutRequest
): PayPalPaymentAuthRequest {
if (requireClientToken().paymentMethodIdJwt == null) {
throw PayPalSavedPaymentMethodException.MissingPaymentMethodIdJwt()
}
return payPalClient.createPaymentAuthRequestForEditFi(context, payPalCheckoutRequest)
}

/**
* Tokenizes the PayPal account once the buyer returns from the edit flow. On success, the nonce's `paymentId` is
* the approved order ID to pass to [fetchPaymentMethod] with
* [PayPalFundingInstrumentFetchType.BUYER_UPDATED_BILLING_AGREEMENT].
*
* @param paymentAuthResult the success returned by `PayPalLauncher.handleReturnToApp`.
*/
suspend fun tokenize(paymentAuthResult: PayPalPaymentAuthResult.Success): PayPalResult =
payPalClient.tokenize(paymentAuthResult)

/**
* Fetches the funding instrument details for a vaulted PayPal payment method.
*
* @param fundingInstrumentType Which funding instrument to resolve.
* [PayPalFundingInstrumentFetchType.BUYER_DEFAULT_BILLING_AGREEMENT] uses the payment method ID JWT carried by the
* client token; [PayPalFundingInstrumentFetchType.BUYER_UPDATED_BILLING_AGREEMENT] requires [orderId].
* @param orderId The approved checkout order ID. Required for a buyer updated billing agreement and ignored
* otherwise.
* @param merchantAccountId Optional. A non-default merchant account to resolve the funding instrument against.
* Applies to both fetch types and is omitted from the request when null.
* @return The funding instrument details to display for the buyer.
* @throws PayPalSavedPaymentMethodException if the authorization is not a client token, the identity field for
* [fundingInstrumentType] is missing, or the response is empty or cannot be parsed.
* @throws BraintreeException if the GraphQL response contains errors.
* @throws org.json.JSONException if the response is not valid JSON.
* @throws java.io.IOException if the request fails.
*/
@Suppress("ThrowsCount")
suspend fun fetchPaymentMethod(
fundingInstrumentType: PayPalFundingInstrumentFetchType,
orderId: String? = null,
merchantAccountId: String? = null
): PayPalSavedPaymentMethodSummary {
// TODO: send the default and updated billing agreement analytics events once the catalog is approved.
val clientToken = requireClientToken()

// The API rejects the request unless exactly the identity field matching the fetch type is sent.
val body = when (fundingInstrumentType) {
PayPalFundingInstrumentFetchType.BUYER_DEFAULT_BILLING_AGREEMENT -> {
val paymentMethodIdJwt = clientToken.paymentMethodIdJwt
?: throw PayPalSavedPaymentMethodException.MissingPaymentMethodIdJwt()

PayPalFundingInstrumentDetailsGraphQLBody(
fundingInstrumentType = fundingInstrumentType,
paymentMethodIdJwt = paymentMethodIdJwt,
orderId = null,
merchantAccountId = merchantAccountId
)
}

PayPalFundingInstrumentFetchType.BUYER_UPDATED_BILLING_AGREEMENT -> {
if (orderId == null) throw PayPalSavedPaymentMethodException.MissingOrderId()

PayPalFundingInstrumentDetailsGraphQLBody(
fundingInstrumentType = fundingInstrumentType,
paymentMethodIdJwt = null,
orderId = orderId,
merchantAccountId = merchantAccountId
)
}
}

val responseBody = braintreeClient.sendGraphQLPOST(body.toJson())
if (responseBody.isEmpty()) throw PayPalSavedPaymentMethodException.EmptyBodyReturned()

// GraphQL returns errors in a 200 response, so they are checked here.
val response = JSONObject(responseBody)
val errors = response.optJSONArray(GraphQLConstants.Keys.ERRORS)
if (errors != null && errors.length() > 0) {
throw BraintreeException(
Comment on lines +124 to +125

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Json.optString is passed errors.optJSONObject(0), which can be null. Please add a test for "errors": ["x"].

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added the test — confirmed it doesn't crash (Json.optString falls back to the full response body as the message when the error entry isn't an object).

Json.optString(errors.optJSONObject(0), GraphQLConstants.Keys.MESSAGE, responseBody)
)
}

val details = response
.optJSONObject(DATA_KEY)
?.optJSONObject(PAYPAL_FUNDING_INSTRUMENT_DETAILS_KEY)
return PayPalSavedPaymentMethodSummary.fromJson(details)
?: throw PayPalSavedPaymentMethodException.FailedToParseSummary()
}

/**
* Fetches the PayPal Pay Later message to display alongside the funding instrument. The message is additive, so
* callers are expected to hide the row when this throws rather than fail checkout.
*
* @param amount The order amount the message is calculated from, for example `"55.00"`.
* @param currencyCode The ISO-4217 currency code for [amount], for example `"USD"`.
* @return The Pay Later message to display.
* @throws PayPalSavedPaymentMethodException if the authorization is not a client token, the response is empty, or
* PayPal returns no message.
* @throws org.json.JSONException if the response is not valid JSON.
* @throws java.io.IOException if the request fails.
*/
suspend fun fetchCreditPresentmentMessages(
amount: String,
currencyCode: String
): PayPalCreditMessagingResult {
// TODO: send the credit messaging analytics events once the catalog is approved.
requireClientToken()

val configuration = braintreeClient.getConfiguration()
val baseUrl = if (configuration.environment == PRODUCTION) PRODUCTION_BASE_URL else SANDBOX_BASE_URL
val responseBody = braintreeClient.sendPOST(
url = "$baseUrl$CREDIT_PRESENTMENT_MESSAGES_PATH",
data = PayPalCreditMessagingRequest(amount = amount, currencyCode = currencyCode).toJson().toString()
)
if (responseBody.isEmpty()) throw PayPalSavedPaymentMethodException.EmptyBodyReturned()

return PayPalCreditMessagingResult.fromJson(JSONObject(responseBody))
?: throw PayPalSavedPaymentMethodException.MissingPreferredMessage()
}
Comment on lines +162 to +166

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Invalid JSON surfaces as a raw JSONException. Consider mapping it to FailedToParseSummary, so merchants handle a single exception type.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

malformed JSON now gets wrapped into FailedToParseSummary instead of leaking a raw JSONException, so callers only need to catch BraintreeException

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

sorry where is this fixed as well?


private fun requireClientToken(): ClientToken =
merchantRepository.authorization as? ClientToken
?: throw PayPalSavedPaymentMethodException.InvalidAuthorization()
Comment on lines +168 to +170

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

requireClientToken() reads MerchantRepository.instance instead of this client’s authorization. LLD §7.1 says two components on one screen never collide. Please keep the authorization on the instance.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

requireClientToken() reads MerchantRepository.instance, a shared singleton every BraintreeClient overwrites on construction — two components with different tokens can collide. Same pattern exists across every client in the SDK (Card, PayPal, Venmo, GooglePay, ThreeDSecure, LocalPayment, ShopperInsights).


private companion object {
const val DATA_KEY = "data"
const val PAYPAL_FUNDING_INSTRUMENT_DETAILS_KEY = "paypalFundingInstrumentDetails"

const val PRODUCTION = "production"
const val PRODUCTION_BASE_URL = "https://api.paypal.com"
const val SANDBOX_BASE_URL = "https://api.sandbox.paypal.com"
const val CREDIT_PRESENTMENT_MESSAGES_PATH = "/v2/credit/fetch-presentment-messages"
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
package com.braintreepayments.api.paypalsavedpaymentmethod

import com.braintreepayments.api.core.BraintreeException
import com.braintreepayments.api.core.ExperimentalBetaApi

/**
* Exceptions thrown by the PayPal saved payment method module.
*/
@ExperimentalBetaApi
sealed class PayPalSavedPaymentMethodException(message: String) : BraintreeException(message) {

/**
* The authorization used to initialize the client is not a client token.
*/
class InvalidAuthorization internal constructor() : PayPalSavedPaymentMethodException(
"Invalid authorization. This feature can only be used with a client token."
)

/**
* The client token does not carry a payment method ID JWT.
*/
class MissingPaymentMethodIdJwt internal constructor() : PayPalSavedPaymentMethodException(
"The client token is missing a payment method ID JWT. Generate it with a payment method ID."
)

/**
* An order ID was not provided for a buyer updated billing agreement fetch.
*/
class MissingOrderId internal constructor() : PayPalSavedPaymentMethodException(
"An order ID is required to fetch the funding instrument selected on an approved checkout."
)

/**
* An empty body was returned from the request and no error was returned.
*/
class EmptyBodyReturned internal constructor() : PayPalSavedPaymentMethodException(
"An empty body was returned from the request."
)

/**
* The funding instrument details could not be parsed from the response.
*/
class FailedToParseSummary internal constructor() : PayPalSavedPaymentMethodException(
"Unable to parse the funding instrument details from the response."
)

/**
* PayPal returned no Pay Later message to display for this buyer.
*/
class MissingPreferredMessage internal constructor() : PayPalSavedPaymentMethodException(
"No Pay Later message was returned for this buyer."
)
}
Loading
Loading