Repository navigation
feat(PayPalCheckoutRequest): source editBillingAgreementJwt from client token for PayPal view/Edit FI flow - #1710
Conversation
|
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 |
|
/inner source |
rvmondeti-svg
left a comment
There was a problem hiding this comment.
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.
a04a05b to
0e13381
Compare
|
Heads up: PayPalClientUnitTest still has assertNull(editBillingAgreement) |
|
LGTM |
Added |
|
Approved looks good |
|
/ready |
|
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)". |
updated the KDoc now says "Defaults to false (opted out)" to match the non-null Boolean type. |
|
Looks like there are some lint errors here. Could you please address those? |
| payPalCheckoutRequest: PayPalCheckoutRequest, | ||
| callback: PayPalPaymentAuthCallback, | ||
| ) { | ||
| payPalCheckoutRequest.editBillingAgreement = true |
There was a problem hiding this comment.
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
There was a problem hiding this comment.
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) |
There was a problem hiding this comment.
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
There was a problem hiding this comment.
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 |
There was a problem hiding this comment.
Why is this needed in this class? Isn't this only relevant for client tokens?
There was a problem hiding this comment.
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( |
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
Updated: suspend fun createPaymentAuthRequestForEditFi(context, payPalCheckoutRequest): PayPalPaymentAuthRequest is now the primary, and the callback overload wraps it (same pattern as createPaymentAuthRequest).
|
It also appears these changes have also broken the local payments tests. Please take a look at those |
|
Non-blocking: LLD corrections Reviewing against the Edit FI LLD, this PR's changes don't match the doc in a few places:
Separately, for the follow-up PR: the §7.3 Android |
fixed. The lint job was failing on two detekt issues in this PR's tests: an unused import in |
Thanks for flagging. This PR doesn't touch LocalPayment — the failing instrumentation tests ( Could we sync the feature branch with |
Thanks Matt, updated the LLD (§7.1, §7.2, §7.3, §8.1):
|
|
I see there is no |
yes, planning to later |
Done |
| assertTrue(requestBody.contains("\"shopper_session_id\":" + "\"shopper-insights-id\"")) | ||
| } | ||
|
|
||
| @OptIn(ExperimentalBetaApi::class) |
There was a problem hiding this comment.
should we also add a test to check that jwt is omitted if the string is " "?
There was a problem hiding this comment.
Added an isNotBlank() guard in createRequestBody and a test that a " " JWT is omitted. ClientToken already handles blank values too.
…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>
cef7684 to
3515394
Compare
| editBillingAgreementJwt = if (editBillingAgreement) { | ||
| (authorization as? ClientToken)?.paymentMethodIdJwt | ||
| } else { | ||
| null | ||
| } |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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:
InvalidAuthorizationfor a tokenization keyMissingPaymentMethodIdJwtwhen 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
There was a problem hiding this comment.
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>
|
thank you for adding requested changes, I think this should be good to go. |
819f5e0
into
braintree:view-edit-fi-beta-feature
Source the
editBillingAgreementJwtfor the PayPal Edit FI flow from the client token and thread it intocreate_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
paymentMethodIdJwttoAuthorizationas an open property defaulting tonull;ClientTokenoverrides it and parses the value from thepaymentMethodIdJwtkey in the client token JSON — otherAuthorizationsubtypes (e.g. tokenization key) keep the defaultnulleditBillingAgreementflag onPayPalCheckoutRequest— a non-nullBooleandefaulting tofalse, not exposed on either public constructor, withinternal setand a@get:RestrictTo(LIBRARY_GROUP)getter so it is neither settable nor readable by merchantsPayPalClient.createPaymentAuthRequestForEditFi(context, payPalCheckoutRequest, callback), annotated@RestrictTo(LIBRARY_GROUP), as the sole entry point that setseditBillingAgreement = trueand delegates tocreatePaymentAuthRequest, so the JWT is included in thecreate_payment_resourcecall only when the SDK is initialized with a client token carrying iteditBillingAgreementis reset tofalse, so reusing the same request instance for a subsequent normal checkout does not resendedit_billing_agreement_jwtPayPalRequest/PayPalVaultRequestare unchanged — the JWT and flag are checkout-onlyClientTokenparsing with/without the claim, and request reuse after an edit (JWT omitted on the second call)AI Usage
Which AI Agent Was Used?
Estimated AI Code Contribution
Checklist
Authors
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:
/inner sourceon this PR — this will automatically add theinner sourceandtech lead review requiredlabels. Open the PR in a draft state./readyon this PR — this will automatically remove thetech lead review requiredlabel. Move the PR to ready to review.Inner Source Checklist