Skip to content

feat(PayPalCheckoutRequest): source editBillingAgreementJwt from client token for PayPal view/Edit FI flow - #1710

Merged
saralvasquez merged 10 commits into
braintree:view-edit-fi-beta-featurefrom
balamaninayard:feature/view-edit-fi-add-editBillingAgreementJwt
Oct 13, 2026
Merged

saralvasquez merged 10 commits into
braintree:view-edit-fi-beta-featurefrom
balamaninayard:feature/view-edit-fi-add-editBillingAgreementJwt

Conversation

@balamaninayard

@balamaninayard balamaninayard commented Sep 22, 2026 •

Copy link
Copy Markdown

Source the editBillingAgreementJwt for the PayPal Edit FI flow from the client token and thread it into create_payment_resource. The JWT is never merchant-supplied, and the flow is enabled only through a dedicated internal entry point rather than exposed on the public API.

Summary of changes

  • Added paymentMethodIdJwt to Authorization as an open property defaulting to null; ClientToken overrides it and parses the value from the paymentMethodIdJwt key in the client token JSON — other Authorization subtypes (e.g. tokenization key) keep the default null
  • Added an internal editBillingAgreement flag on PayPalCheckoutRequest — a non-null Boolean defaulting to false, not exposed on either public constructor, with internal set and a @get:RestrictTo(LIBRARY_GROUP) getter so it is neither settable nor readable by merchants
  • Added PayPalClient.createPaymentAuthRequestForEditFi(context, payPalCheckoutRequest, callback), annotated @RestrictTo(LIBRARY_GROUP), as the sole entry point that sets editBillingAgreement = true and delegates to createPaymentAuthRequest, so the JWT is included in the create_payment_resource call only when the SDK is initialized with a client token carrying it
  • The opt-in is consumed as a one-shot: after the JWT is emitted, editBillingAgreement is reset to false, so reusing the same request instance for a subsequent normal checkout does not resend edit_billing_agreement_jwt
  • PayPalRequest / PayPalVaultRequest are unchanged — the JWT and flag are checkout-only
  • Added unit tests covering: opt-in + JWT present (sent), opt-in false (omitted), opt-in + JWT nil (omitted), ClientToken parsing with/without the claim, and request reuse after an edit (JWT omitted on the second call)

AI Usage

Which AI Agent Was Used?

  • Copilot
  • Claude
  • Other (Type Name Here)

Estimated AI Code Contribution

  • less than 30%
  • 30 - 60%
  • 60 - 100%

Checklist

  • Added a changelog entry
  • Tested and confirmed payment flows affected by this change are functioning as expected

Authors

List GitHub usernames for everyone who contributed to this pull request.

  • go-os-code

Inner Source Process

Internal to PayPal contributors should fill out this section. All others can delete.

PR should follow these steps before codeowners review will begin:

  1. Comment /inner source on this PR — this will automatically add the inner source and tech lead review required labels. Open the PR in a draft state.
  2. PR should be reviewed by and approved by your team's technical lead, we do not allow LGTM reviews, there should be comments and feedback provided on all PR reviews
  3. Once the above steps are completed, comment /ready on this PR — this will automatically remove the tech lead review required label. Move the PR to ready to review.
  4. PR comments must be addressed within 24 hours, if you are unable to address within this timeframe, move the PR back to a draft state so our team knows not to review

Inner Source Checklist

  • Added all labels to the PR
  • Provide steps to test the flows changed, if applicable in the summary
  • Demo video of the functionality, if applicable
  • All upstream dependencies are merged in and this PR can be released at any time; PRs should not be opened until this is true
  • Unit tests and builds have been run locally and pass/compile as expected

@balamaninayard
balamaninayard requested a review from a team as a code owner September 22, 2026 18:18
@saralvasquez

Copy link
Copy Markdown
Contributor

Hi! I noticed some of the steps from our Inner Sourcing process (there at the bottom of the comment template) haven't been completed yet. Pleas take a look and let us know if you have any questions

@balamaninayard

Copy link
Copy Markdown
Author

/inner source

@github-actions github-actions Bot added inner source This PR is internal to PP but external to the mobile SDK team tech lead review required labels Sep 23, 2026
@jaxdesmarais
jaxdesmarais marked this pull request as draft September 23, 2026 13:42

@Bhuvana-S100 Bhuvana-S100 left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

LGTM

Comment thread BraintreeCore/src/main/java/com/braintreepayments/api/core/Authorization.kt Outdated
Comment thread PayPal/src/main/java/com/braintreepayments/api/paypal/PayPalClient.kt Outdated
Comment thread PayPal/src/main/java/com/braintreepayments/api/paypal/PayPalClient.kt Outdated

@rvmondeti-svg rvmondeti-svg left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Good test coverage. Left a few questions on API exposure, the missing-JWT path, and request state; Also please link the gateway contract for the JWT key name, confirm parity with iOS #1844, and complete the checklist.

Comment thread PayPal/src/main/java/com/braintreepayments/api/paypal/PayPalCheckoutRequest.kt Outdated
@balamaninayard
balamaninayard force-pushed the feature/view-edit-fi-add-editBillingAgreementJwt branch from a04a05b to 0e13381 Compare October 6, 2026 07:18
@rvmondeti-svg

Copy link
Copy Markdown

Heads up: PayPalClientUnitTest still has assertNull(editBillingAgreement)

@anibalb2500

Copy link
Copy Markdown
Contributor

LGTM

@balamaninayard

balamaninayard commented Oct 6, 2026 •

Copy link
Copy Markdown
Author

Heads up: PayPalClientUnitTest still has assertNull(editBillingAgreement)

Added assertFalse for the createPaymentAuthRequestForEditFi in the PayPalClientUnitTest

@vkspune

vkspune commented Oct 6, 2026

Copy link
Copy Markdown

Approved looks good

@balamaninayard

Copy link
Copy Markdown
Author

/ready

@balamaninayard
balamaninayard marked this pull request as ready for review October 6, 2026 16:51
@rvmondeti-svg

Copy link
Copy Markdown

Approving. Entry point is library-internal, opt-in resets on every request-build path with tests, and the missing-JWT guard upstream matches iOS.

Non-blocking: KDoc on editBillingAgreement still says "Defaults to null (opted out)". It's now a non-null Boolean, so please update it to "Defaults to false (opted out)".

@balamaninayard

Copy link
Copy Markdown
Author

Non-blocking: KDoc on editBillingAgreement still says "Defaults to null (opted out)". It's now a non-null Boolean, so please update it to "Defaults to false (opted out)".

updated the KDoc now says "Defaults to false (opted out)" to match the non-null Boolean type.

@saralvasquez

Copy link
Copy Markdown
Contributor

Looks like there are some lint errors here. Could you please address those?

payPalCheckoutRequest: PayPalCheckoutRequest,
callback: PayPalPaymentAuthCallback,
) {
payPalCheckoutRequest.editBillingAgreement = true

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.

What happens to this value if something throws in between when this is set to true and then set back to false in createRequestBody? It seems like there are several situations where this could get stuck as true

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.

I removed the stored flag entirely: PayPalCheckoutRequest.editBillingAgreement is gone, and the edit intent is passed as a parameter for that one call — createPaymentAuthRequestForEditFi → sendRequest(…, editBillingAgreement = true) → createRequestBody(…, editBillingAgreementJwt). The merchant's request is never modified, so nothing can get stuck on any path. This matches iOS (#1844/#1884), where the flag is also scoped to the call (@TaskLocal) rather than stored on the request.

bearer = authorizationFingerprint
customerId = parseCustomerId(authorizationFingerprint)
paymentMethodIdJwt = jsonObject.takeIf { it.has(PAYMENT_METHOD_ID_JWT_KEY) }
?.getString(PAYMENT_METHOD_ID_JWT_KEY)

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.

This will throw a JSONException which is caught and thrown as "Client token was invalid" if the key is present but the value is null. Could there be a situation where that could happen? If so, I don't know if we want to fail init for an optional field being 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.

Agreed — an optional field shouldn't invalidate the client token. It's now read with Json.optString(jsonObject, PAYMENT_METHOD_ID_JWT_KEY, null), and a blank value is treated as absent too. A missing key, a JSON null, or a blank string all give paymentMethodIdJwt == null and the token still parses. Added ClientTokenUnitTest cases for null and blank.

* The payment method ID JWT extracted from the client token, if present.
* @suppress
*/
open val paymentMethodIdJwt: String? get() = null

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.

Why is this needed in this class? Isn't this only relevant for client tokens?

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.

Removed from Authorization. paymentMethodIdJwt now lives only on ClientToken, and the one reader (PayPalInternalClient) uses authorization as? ClientToken for the edit flow. Same as iOS, where the JWT is on the client-token-only ClientTokenAuthorizationProviding

*/
@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)
@OptIn(ExperimentalBetaApi::class)
fun createPaymentAuthRequestForEditFi(

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.

We have moved to a suspend primary with callback wrapper model. Please fit this functionality into that. Additionally, if this is not exposed publicly, how is this intended to be used? Will those changes be coming in a different PR?

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.

Updated: suspend fun createPaymentAuthRequestForEditFi(context, payPalCheckoutRequest): PayPalPaymentAuthRequest is now the primary, and the callback overload wraps it (same pattern as createPaymentAuthRequest).

@saralvasquez

Copy link
Copy Markdown
Contributor

It also appears these changes have also broken the local payments tests. Please take a look at those

@mzlangreder

Copy link
Copy Markdown
Contributor

Non-blocking: LLD corrections

Reviewing against the Edit FI LLD, this PR's changes don't match the doc in a few places:

  • The module map says BraintreeCore has no change, but this PR changes Authorization and ClientToken.
  • It describes an editBillingAgreementJwt field and a Boolean? flag, but the code uses a non-null Boolean flag with the JWT read from the client token.

Separately, for the follow-up PR: the §7.3 Android editFundingInstrument sample calls payPalClient.tokenize(request), which doesn't exist (tokenize takes a PayPalPaymentAuthResult.Success). §8.1 also doesn't say who handles a missing JWT on the edit path. Worth settling both in the doc before that PR lands.

@balamaninayard

Copy link
Copy Markdown
Author

Looks like there are some lint errors here. Could you please address those?

fixed. The lint job was failing on two detekt issues in this PR's tests: an unused import in PayPalClientUnitTest and a test name over the max line length in PayPalCheckoutRequestUnitTest. Both are addressed, and detekt and Android Lint now pass locally.

@balamaninayard

Copy link
Copy Markdown
Author

It also appears these changes have also broken the local payments tests. Please take a look at those

Thanks for flagging. This PR doesn't touch LocalPayment — the failing instrumentation tests (LocalPaymentApiTest, LocalPaymentClientTest) all fail with 403 Unauthorized on the sandbox configuration call for merchant hh4cpc39zq4rgjcg. That's the outdated sandbox tokenization key, which was fixed on main in #1690 (and #1700), but those changes haven't been merged into view-edit-fi-beta-feature yet.

Could we sync the feature branch with main? Once that's in, these tests should pass here and on the other PRs targeting this branch.

@balamaninayard

Copy link
Copy Markdown
Author

Non-blocking: LLD corrections

Reviewing against the Edit FI LLD, this PR's changes don't match the doc in a few places:

  • The module map says BraintreeCore has no change, but this PR changes Authorization and ClientToken.
  • It describes an editBillingAgreementJwt field and a Boolean? flag, but the code uses a non-null Boolean flag with the JWT read from the client token.

Separately, for the follow-up PR: the §7.3 Android editFundingInstrument sample calls payPalClient.tokenize(request), which doesn't exist (tokenize takes a PayPalPaymentAuthResult.Success). §8.1 also doesn't say who handles a missing JWT on the edit path. Worth settling both in the doc before that PR lands.

Thanks Matt, updated the LLD (§7.1, §7.2, §7.3, §8.1):

  • Module map / file structure: BraintreeCore now lists the ClientToken change (paymentMethodIdJwt; missing, null or blank → null). Authorization is no longer touched after the review fixes.
  • JWT handoff: removed the editBillingAgreementJwt field and the Boolean? flag. Nothing is stored on the request; createPaymentAuthRequestForEditFi passes the edit intent per call, PayPalInternalClient reads the JWT from ClientToken, and a checkout-only internal createRequestBody overload adds it. PayPalRequest / PayPalVaultRequest are unchanged (iOS parity).
  • §7.3: replaced the payPalClient.tokenize(request) sample with the actual flow: createPaymentAuthRequestForEditFi → PayPalLauncher.launch → handleReturnToApp → tokenize(PayPalPaymentAuthResult.Success).
  • §8.1: PayPalSavedPaymentMethodClient checks the authorization before starting the edit and fails with InvalidAuthorization or MissingPaymentMethodIdJwt; the PayPal layer only omits the key. This matches iOS #1884 and will land in the follow-up PR.

@noguier

noguier commented Oct 13, 2026

Copy link
Copy Markdown
Contributor

I see there is no CHANGELOG entry for this PR, were you planning on adding it later?

@balamaninayard

Copy link
Copy Markdown
Author

I see there is no CHANGELOG entry for this PR, were you planning on adding it later?

yes, planning to later

@saralvasquez

Copy link
Copy Markdown
Contributor

Could we sync the feature branch with main? Once that's in, these tests should pass here and on the other PRs targeting this branch.

Done

assertTrue(requestBody.contains("\"shopper_session_id\":" + "\"shopper-insights-id\""))
}

@OptIn(ExperimentalBetaApi::class)

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.

should we also add a test to check that jwt is omitted if the string is " "?

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 an isNotBlank() guard in createRequestBody and a test that a " " JWT is omitted. ClientToken already handles blank values too.

Bala Mani Nayar D and others added 9 commits October 13, 2026 22:10
…tToken instead of raw field

editBillingAgreement is now a boolean opt-in on PayPalCheckoutRequest, and the
payment method ID JWT is decoded by Core from the client token
(Authorization.paymentMethodIdJwt) rather than passed in as a raw string by the
merchant.
…ernal write access

editBillingAgreement was a public constructor property, letting any caller opt
into the View/Edit FI flow. It's now internally settable only, via a dedicated
PayPalClient.createPaymentAuthRequestForEditFi entry point
…pt-in

- Add @RestrictTo(LIBRARY_GROUP) to createPaymentAuthRequestForEditFi and
  editBillingAgreement getter so merchants cannot invoke the edit-FI flow
- Reset editBillingAgreement after emitting the JWT so reusing the request
  for a normal checkout does not resend edit_billing_agreement_jwt
- Add regression test for request reuse
Read-and-clear editBillingAgreement at the top of createRequestBody so the
one-shot opt-in is reset regardless of auth type (including tokenization-key
calls), preventing a later ClientToken reuse from resending the JWT. Add a
regression test for the tokenization-key reuse case.
Update stale assertNull to assertFalse now that editBillingAgreement is a
non-null Boolean defaulting to false.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
… null or blank values

- Remove paymentMethodIdJwt from Authorization; only client tokens carry the JWT
- Read it with Json.optString so a missing, null or blank value means no JWT instead of an
  invalid client token

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…gAgreement on the request

- Remove PayPalCheckoutRequest.editBillingAgreement; the request is never modified, so the opt-in
  cannot get stuck after an early failure or leak into a reused request
- createPaymentAuthRequestForEditFi passes the edit intent down to PayPalInternalClient, which sends
  the client token's JWT to a checkout-only createRequestBody overload; vault is unchanged
- Add a suspend createPaymentAuthRequestForEditFi with the callback version wrapping it
- Fix detekt issues in PayPal tests (unused import, long test name)

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@balamaninayard
balamaninayard force-pushed the feature/view-edit-fi-add-editBillingAgreementJwt branch from cef7684 to 3515394 Compare October 13, 2026 16:46
Comment on lines +122 to +126
editBillingAgreementJwt = if (editBillingAgreement) {
(authorization as? ClientToken)?.paymentMethodIdJwt
} else {
null
}

@noguier noguier Oct 13, 2026 •

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.

What exactly this logic must do if editBillingAgreement flag is true, auth is ClientToken with no JWT, or auth is a tokenization key ? I think it will set editBillingAgreementJwt to null is that the logic we want? Just want to make sure I understand what exactly happens in this scenario.

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.

In all three no-JWT cases the request goes out without edit_billing_agreement_jwt. We never send it as a null or empty value.

editBillingAgreementJwt = if (editBillingAgreement) (authorization as? ClientToken)?.paymentMethodIdJwt else null
Scenario editBillingAgreementJwt Request body
Flag true, client token with JWT The JWT edit_billing_agreement_jwt sent
Flag true, client token without JWT (missing, null or blank) null (ClientToken returns null) Key omitted
Flag true, tokenization key null (as? ClientToken is null) Key omitted
Flag false null Key omitted

createRequestBody only adds the key when the value is non-null and non-blank, so the last three rows run as a normal checkout.

This is intended at the PayPal layer, which just forwards the JWT when there is one. The user-facing check is upstream in PayPalSavedPaymentMethodClient. It fails the edit before the paysheet opens:

  • InvalidAuthorization for a tokenization key
  • MissingPaymentMethodIdJwt when there's no JWT

So a buyer never gets a silent regular checkout. This is the same split as iOS #1884.

Covered by PayPalInternalClientUnitTest:

  • …omits the jwt when the client token has none
  • …omits the jwt for a tokenization key

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.

thank you for this additional context

…t request body

ClientToken already turns a blank paymentMethodIdJwt into null, but
createRequestBody trusted its input. Skip a blank editBillingAgreementJwt there
too, so the request layer never sends an empty JWT whatever the caller passes.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@noguier

noguier commented Oct 13, 2026

Copy link
Copy Markdown
Contributor

thank you for adding requested changes, I think this should be good to go.

@saralvasquez
saralvasquez merged commit 819f5e0 into braintree:view-edit-fi-beta-feature Oct 13, 2026
9 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

inner source This PR is internal to PP but external to the mobile SDK team

Projects

None yet

Development

Successfully merging this pull request may close these issues.

9 participants