Repository navigation
Feature/saved payment method client #1719
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: view-edit-fi-beta-feature
Are you sure you want to change the base?
Changes from all commits
3032f31
8c5c071
dcb4663
5bb6e1d
772961e
3200d8c
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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) | ||
|
|
||
| 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 { | ||
| 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
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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"].
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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.
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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.
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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." | ||
| ) | ||
| } |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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?