Skip to content

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

Open
balamaninayard wants to merge 7 commits into
braintree:view-edit-fi-beta-featurefrom
balamaninayard:feature/view-edit-fi-add-editBillingAgreementJwt
Open

balamaninayard wants to merge 7 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

Bala Mani Nayar D and others added 3 commits August 17, 2026 14:41
…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
@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

* @param callback [PayPalPaymentAuthCallback]
*/
@OptIn(ExperimentalBetaApi::class)
fun createPaymentAuthRequestForEditFi(

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

The PR says this flow isn't exposed publicly. What stops a merchant from calling this method today?

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.

Nothing originally , it was public. Now @RestrictTo(LIBRARY_GROUP) on createPaymentAuthRequestForEditFi + internal set/@get:RestrictTo on editBillingAgreement, so only internal SDK modules can invoke it (Lint-enforced).

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

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

who can see it once it ships?

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.

paymentMethodIdJwt lives on a class annotated @RestrictTo(LIBRARY_GROUP) and @suppress, so it's library-group-only and hidden from docs/merchants.

callback: PayPalPaymentAuthCallback,
) {
payPalCheckoutRequest.editBillingAgreement = true
createPaymentAuthRequest(context, payPalCheckoutRequest, callback)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

What happens if the client token has no JWT, or the merchant uses a tokenization key? What does the user experience after tapping Edit?

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.

  • No JWT in client token: enters the ClientToken branch but paymentMethodIdJwt?.let{} is null, so edit_billing_agreement_jwt is omitted — proceeds as a normal checkout, no crash.

  • Tokenization key: authorization is ClientToken is false, so the edit block is skipped entirely . JWT never sent, proceeds as a normal checkout.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Right, and that's the concern: the user tapped Edit and silently gets a normal checkout, possibly creating a new billing agreement. Should the Edit entry point proceed at all without a JWT, or return a Failure via the callback, like the PayPal-disabled case? Either way, let's have a test that pins the behavior.

@balamaninayard balamaninayard Oct 6, 2026 •

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.

If the merchant passes a client token that doesn't carry the JWT, the guarding happens upstream in PayPalSavedPaymentMethodClient:

  • If the authorization is not a client token, the flow fails with an InvalidAuthorization exception.
  • If the client token is valid but lacks the payment method ID JWT, the flow fails with a MissingPaymentMethodIdJwt exception.

In both cases the View/Edit Funding Instrument call fails, and the component falls back state - rendering only the brand logo.

iOS handling ref : https://github.com/braintree/braintree_ios/pull/1850/changes#diff-9a18a6836dbd89b5d427d4d00026297eef69975645c4a164a6c0c49c5022d03eR55

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

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

What happens if the caller reuses this same request for a normal checkout afterwards?

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.

editBillingAgreement is reset to false right after emitting the JWT, so reusing the same request won't resend edit_billing_agreement_jwt

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Reset only runs in the ClientToken path. What about tokenization-key calls, or early failures like PayPal disabled?

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 , reset will happen both ClientToken and tokenizationKey paths.

@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.

*/
@IgnoredOnParcel
@get:RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)
var editBillingAgreement: Boolean? = 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.

What is the benefit of having this nullable?

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.

None , Changed to a non-null Boolean = false, since there's no tri-state need - null and false both meant "not opted in".

…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
@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)

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.
@anibalb2500

Copy link
Copy Markdown
Contributor

LGTM

Update stale assertNull to assertFalse now that editBillingAgreement is a
non-null Boolean defaulting to false.
@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)".

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@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

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

* 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?

*/
@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?

@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.

This branch has not been deployed

No deployments
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.

8 participants