Add View/Edit FI and credit messaging data layer to BraintreePayPalSavedPaymentMethod - #1850
Conversation
|
/inner source |
9a4be0e to
60d6ecf
Compare
Add the data layer for the View/Edit FI component so the UI can read the funding instrument PayPal will charge for a vaulted PayPal payment method. UI/rendering lands separately. - BTPayPalSavedPaymentMethodClient.fetchPaymentMethod(fundingInstrumentType:orderID: merchantAccountID:) calls the Atmosphere `paypalFundingInstrumentDetails` GraphQL query over the existing .graphQLAPI rail; no new networking layer - STICKY_FI reads the payment method ID JWT from apiClient.authorization.paymentMethodIDJWT, while FI_FROM_APPROVED_CHECKOUT requires an order ID, so exactly the identity field the API expects is sent - BTPayPalSavedPaymentMethodSummary / BTPayPalSavedPaymentMethod / BTPayPalPayer model the instrument and display-only payer responses, with unknown instrument types degrading to nil rather than dropping the instrument - Everything is internal to the module; no public API is added yet Depends on braintree#1844, which surfaces paymentMethodIDJWT on ClientAuthorization. Analytics are intentionally deferred until the FPTI event catalog is finalized.
…rument entries Make BTPayPalSavedPaymentMethod.init failable and guard json.isObject, matching BTPayPalPayer and BTPayPalSavedPaymentMethodSummary, then compactMap the array so a null or non-object entry is dropped instead of producing an all-nil instrument the UI would render as a blank row.
Add BTPayPalSavedPaymentMethodClient.fetchCreditPresentmentMessages(amount:currencyCode:), which POSTs to /v2/credit/fetch-presentment-messages over the existing .payPalAPI rail. That rail already supplies the api.paypal.com base URL, the client-token bearer, and skips BT metadata injection, so no new networking is required. The request always asks for the Treatment A copy via content_attributes, so no experiment arm has to be resolved before the message is fetched. The response is returned as BTPayPalCreditMessagingResult: the main, disclaimer, and action content blocks in the order PayPal returns them, plus the impression beacon. Blocks are modelled faithfully rather than flattened, because the logo arrives inline mid-sentence and callers must render something in its place for the copy to read correctly. Also drops a private assertThrows test helper in favour of the do/catch style used throughout the rest of the test suite.
…t as no message Reporting success without main_items would fire the impression beacon for a message the buyer never saw.
…the API docs Also drops the claim that payer and paymentMethods are mutually exclusive; the resolver always sets paymentMethods and adds payer when present, so nothing guarantees that.
870ce84 to
2309de3
Compare
…porting a message BTPayPalCreditMessageItem.init was non-failable, so every main_items entry survived and the emptiness guard passed for a message with no copy. Make the init failable like the other models in the module, compactMap the three block arrays, and check for displayable text instead of item count.
|
/ready |
jaxdesmarais
left a comment
There was a problem hiding this comment.
@rainaarya please break this PR down into smaller chunks as 400 should be the limit for PRs to provide accurate reviews. Additionally nothing in this PR is Obj-C compatible or public for merchants to use. This PR is also missing docstrings and not following existing patterns in several places. Moving this back to draft in the meantime, please reopen when ready.
… JSON parsers The style guide asks for a doc comment on every declaration; the cases of BTPayPalCreditMessageItemType and BTPayPalSavedPaymentMethodType and three of the init?(json:) parsers were missing one.
Hi @jaxdesmarais, We are following Ehud's guidelines to limit the no. of open PRs for this deliverable and we aligned earlier that it will increase overall LOC per PR. Happy to chat more on Slack if you have specific questions. This PR is focused on the data models and API calls which are internal to the module. The next PR which will cover UI layer will expose the functionality for the merchant (public) and will invoke the APIs introduced in this PR. All types and stored properties have We have tried to follow the existing codebase patterns. Let us know any specific places that you think may require alignment with code patterns. We have modelled this based on the existing modules, ( |
…age item type PayPal returns variable content only when the request carries a feature flag this SDK does not send, so this request shape only ever yields TEXT, LINK and IMAGE. Unknown types already parse to nil, so the flow is unaffected if that changes.
There was a problem hiding this comment.
Pull request overview
Note
Copilot was unable to run its full agentic suite in this review.
Adds a concrete PayPal “saved payment method” feature surface (client + models) and replaces placeholder unit test scaffolding with real model/client request/response/error tests.
Changes:
- Introduces
BTPayPalSavedPaymentMethodClientwith GraphQL funding-instrument fetching and PayPal Credit presentment messaging support. - Adds parsing models for funding instrument details and credit messaging payloads, plus request body encoders.
- Replaces the placeholder scaffolding test file with focused async unit tests for request construction, parsing, and error cases.
Reviewed changes
Copilot reviewed 17 out of 17 changed files in this pull request and generated 6 comments.
Show a summary per file
| File | Description |
|---|---|
| UnitTests/BraintreePayPalSavedPaymentMethodTests/BraintreePayPalSavedPaymentMethodTests.swift | Removes placeholder scaffolding test file. |
| UnitTests/BraintreePayPalSavedPaymentMethodTests/BTPayPalSavedPaymentMethodSummary_Tests.swift | Adds unit tests validating summary parsing behavior and edge cases. |
| UnitTests/BraintreePayPalSavedPaymentMethodTests/BTPayPalSavedPaymentMethodClient_Tests.swift | Adds async unit tests for GraphQL request building, response parsing, and error propagation. |
| UnitTests/BraintreePayPalSavedPaymentMethodTests/BTPayPalCreditMessaging_Tests.swift | Adds async unit tests for PayPal Credit presentment request/response parsing and error behavior. |
| Sources/BraintreePayPalSavedPaymentMethod/Models/PayPalFundingInstrumentDetailsGraphQLBody.swift | Adds GraphQL POST body encoding for funding instrument details query. |
| Sources/BraintreePayPalSavedPaymentMethod/Models/PayPalCreditMessagingPOSTBody.swift | Adds REST POST body encoding for /v2/credit/fetch-presentment-messages. |
| Sources/BraintreePayPalSavedPaymentMethod/Models/BTPayPalSavedPaymentMethodType.swift | Adds enum for funding instrument type mapping from API payloads. |
| Sources/BraintreePayPalSavedPaymentMethod/Models/BTPayPalSavedPaymentMethodSummary.swift | Adds summary model parsing payer + payment methods from BTJSON. |
| Sources/BraintreePayPalSavedPaymentMethod/Models/BTPayPalSavedPaymentMethod.swift | Adds payment method model parsing instrument fields from BTJSON. |
| Sources/BraintreePayPalSavedPaymentMethod/Models/BTPayPalPayer.swift | Adds payer model parsing buyer email/editable flags from BTJSON. |
| Sources/BraintreePayPalSavedPaymentMethod/Models/BTPayPalFundingInstrumentFetchType.swift | Adds enum controlling which identity field is required for fetch. |
| Sources/BraintreePayPalSavedPaymentMethod/Models/BTPayPalCreditMessagingResult.swift | Adds credit messaging result parsing + validation that message contains displayable copy. |
| Sources/BraintreePayPalSavedPaymentMethod/Models/BTPayPalCreditMessageItemType.swift | Adds enum for credit message content block types. |
| Sources/BraintreePayPalSavedPaymentMethod/Models/BTPayPalCreditMessageItem.swift | Adds content block parsing for credit message payload. |
| Sources/BraintreePayPalSavedPaymentMethod/BTPayPalSavedPaymentMethodError.swift | Adds error enum with domain/codes/descriptions for new APIs. |
| Sources/BraintreePayPalSavedPaymentMethod/BTPayPalSavedPaymentMethodClient.swift | Adds client methods to fetch funding instrument details and credit presentment messages. |
| Braintree.xcodeproj/project.pbxproj | Wires new sources + tests into the Xcode project, removes placeholder test from build. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
| // MARK: - Request | ||
|
|
||
| func testFetchPaymentMethod_whenStickyFI_postsQueryWithPaymentMethodIDJWT() async throws { | ||
| _ = try? await sut.fetchPaymentMethod(fundingInstrumentType: .stickyFI, merchantAccountID: "fake-merchant-account-id") |
There was a problem hiding this comment.
It is intentional, with no canned response the parse step throws after the POST, and this test only asserts request shape.
If no request went out, XCTAssertEqual(lastPOSTPath, "") and XCTUnwrap(lastPOSTParameters) below both fail, so it can't pass silently.
| // MARK: - Request | ||
|
|
||
| func testFetchCreditPresentmentMessages_postsTreatmentARequestToThePayPalAPI() async throws { | ||
| _ = try? await sut.fetchCreditPresentmentMessages(amount: "55.00", currencyCode: "USD") |
There was a problem hiding this comment.
Same as above, the throw would happen after the POST, and the request assertions below still fail if nothing was sent.
| init(authorization: String) { | ||
| self.apiClient = BTAPIClient(authorization: authorization) |
There was a problem hiding this comment.
BTAPIClient.init(authorization:) is non-failable, so this compiles. It is similar to how other modules initialize it.
| #if canImport(BraintreeCore) | ||
| import BraintreeCore | ||
| #endif |
There was a problem hiding this comment.
This is the repo-wide pattern already (e.g. BTPayPalAccountNonce). Importing unconditionally may break the CocoaPods builds.
|
|
||
| /// Parses the `paypalFundingInstrumentDetails` field of a `PaypalFundingInstrumentDetails` response. | ||
| /// - Returns: `nil` when the field is not an object. | ||
| init?(json: BTJSON) { |
There was a problem hiding this comment.
Same as above, it is a repo pattern.
| // swiftlint:disable nesting | ||
| /// The POST body for `v2/credit/fetch-presentment-messages` |
There was a problem hiding this comment.
there's already a matching // swiftlint:enable nesting after the type, and the file contains only that one type.
jaxdesmarais
left a comment
There was a problem hiding this comment.
I am a bit confused by the models directory here. The models should be the API layer and follow a pattern like GenerateCustomerRecommendationsGraphQLBody, PayPalAccountPOSTEncodable, etc and not prefixed with BT. These are all prefixed though with BT which is the pattern using for public types exposed to merchants and would not live in the models directory. But none of them are exposed publicly. I am not sure which these are supposed to be, but either way need to be updated to match existing patterns.
|
|
||
| /// Fetches what to display for a buyer's vaulted PayPal payment method: the funding instrument PayPal will charge, and the | ||
| /// Pay Later message that accompanies it. | ||
| final class BTPayPalSavedPaymentMethodClient { |
There was a problem hiding this comment.
Who constructs this? It doesn't need to be public?
There was a problem hiding this comment.
Client needs to be internal. Nothing constructs it in this PR, but the future PRs will use this client for fetch/edit actions (i.e ViewModel of the UI layer PR).
| merchantAccountID: merchantAccountID | ||
| ) | ||
|
|
||
| let (body, _) = try await apiClient.post("", parameters: parameters, httpType: .graphQLAPI) |
There was a problem hiding this comment.
We are not handling errors returned here? Should this be a do/catch? Is there a case where we need to check the json with asError like we need to do with some other GrpahQL APIs?
There was a problem hiding this comment.
Both rails throw before post returns (GraphQL on any errors[], BTHTTP on ≥400 and via asError() internally), and our parsers are failable, so a broken node throws failedToParseSummary
|
|
||
| let parameters = PayPalCreditMessagingPOSTBody(amount: amount, currencyCode: currencyCode) | ||
|
|
||
| let (body, _) = try await apiClient.post( |
There was a problem hiding this comment.
Same question here, we are never handing errors
There was a problem hiding this comment.
Same here, where BTHTTP throws before we get the body, and BTPayPalCreditMessagingResult.init? returns nil rather than building an empty result, so we throw missingPreferredMessage.
…egroup files Documents the client token requirement and leaves Models/ holding only the GraphQL and POST bodies.
Have moved the response models to the module root so We noticed that |
buzzamus
left a comment
There was a problem hiding this comment.
I left a few comments/had a few questions. I am also curious if there will be a future task for adding the analytic events to this client? If so, can we also add some TODOs to show where we will be send
| ) async throws -> BTPayPalSavedPaymentMethodSummary { | ||
| guard apiClient.authorization.type == .clientToken else { | ||
| throw BTPayPalSavedPaymentMethodError.invalidAuthorization | ||
| } |
There was a problem hiding this comment.
There is some duplication in this file with the guard blocks. This guard/throw block is identical to the one on lines 100-102. There are also empty body blocks on lines 76-78 that match 112-114. Could we create some sort of helper functions to remove this repeated code in case exception names change?
There was a problem hiding this comment.
Pulled both into validateClientTokenAuthorization() and a private post(_:parameters:httpType:) that unwraps the body to reduce some duplication.
I have kept the auth check as the first line of each method so a tokenization key still throws invalidAuthorization rather than missingPaymentMethodIDJWT.
| ) async throws -> BTPayPalCreditMessagingResult { | ||
| guard apiClient.authorization.type == .clientToken else { | ||
| throw BTPayPalSavedPaymentMethodError.invalidAuthorization | ||
| } |
There was a problem hiding this comment.
This is the repeated guard statement for the client token check
| guard let body else { | ||
| throw BTPayPalSavedPaymentMethodError.emptyBodyReturned | ||
| } | ||
|
|
There was a problem hiding this comment.
Actually, after reading the error message for BTPayPalSavedPaymentMethodError.emptyBodyReturned I don't think that is applicable here is it? It says it is for afunding instrument details request, but this seems to be an entirely different request. I think we may need a new error type for this, or change the response
There was a problem hiding this comment.
Made the message text request agnostic rather than adding a second case, since the condition is identical.
case .emptyBodyReturned:
return "An empty body was returned from the request."|
|
||
| guard let body else { | ||
| throw BTPayPalSavedPaymentMethodError.emptyBodyReturned | ||
| } |
| import Foundation | ||
|
|
||
| public enum BTPayPalSavedPaymentMethodError: Int, Error, CustomNSError, LocalizedError, Equatable { | ||
|
|
There was a problem hiding this comment.
I am just trying to parse this all in my mind: This is a public error, but everywhere that it is currently thrown is in an internal struct. Will these be bubbled up to the public classes that merchants will interact with?
There was a problem hiding this comment.
Yes, they could. The next UI PR #1854's BTPayPalSavedPaymentMethodView can surface them through its completion: (BTPayPalAccountNonce?, Error?) handler, so they need to be public.
…to helpers Both fetches shared the client token check and the empty body unwrap. Also drops the funding instrument wording from an error both requests throw.
Yes analytics would be added in future PRs. The catalog is being finalized and needs review. I have added TODOs for some of the analytics events we plan to emit from the client (such as sticky-FI fetch, post-edit FI refresh, credit messaging) |
| } | ||
|
|
||
| init(amount: String, currencyCode: String) { | ||
| self.messagePlacements = [MessagePlacement(amount: MessagePlacement.Amount(currencyCode: currencyCode, value: amount))] |
There was a problem hiding this comment.
nit: can we newline this for better readability
| resolvedOrderID = orderID | ||
| } | ||
|
|
||
| let parameters = PayPalFundingInstrumentDetailsGraphQLBody( |
There was a problem hiding this comment.
question: it looks like merchantAccountID isn't validated against fundingInstrumentType the same way 1orderID/paymentMethodIDJWT` are - just would like to confirm if this is intentionally optional
There was a problem hiding this comment.
Yeah it's intentional. orderID/paymentMethodIDJWT are identity fields where the API needs exactly the one matching the fetch type, whereas merchantAccountID is optional, valid for both.
When it's nil, the key is omitted and the default merchant account is used in the server. Also updated this in the docstring.
…ent literal Also notes that merchantAccountID applies to both fetch types.
Summary of changes
Adds the data layer for the View/Edit FI component in
BraintreePayPalSavedPaymentMethod(funding instrument fetch and the credit messaging fetch).Funding instrument fetch
BTPayPalSavedPaymentMethodClient.fetchPaymentMethod(fundingInstrumentType:orderID:merchantAccountID:)calls the BT GQLpaypalFundingInstrumentDetailsGraphQL query over the existing.graphQLAPIrail.STICKY_FIreads the payment method ID JWT fromapiClient.authorization.paymentMethodIDJWT;FI_FROM_APPROVED_CHECKOUTrequires anorderID. Exactly one identity field is sent, as the API requires.BTPayPalSavedPaymentMethodSummary/BTPayPalSavedPaymentMethod/BTPayPalPayermodel the instrument and display-only payer responses.Credit messaging fetch
BTPayPalSavedPaymentMethodClient.fetchCreditPresentmentMessages(amount:currencyCode:)POSTs to/v2/credit/fetch-presentment-messagesover the existing.payPalAPIrail.BTPayPalCreditMessagingResultreturns the main, disclaimer and action content blocks in the order PayPal returns them, plus the impression beacon.AI Usage
Which AI Agent Was Used?
How was AI used?
Code generation, unit tests, and refactoring. The backend contracts were verified against the Atmosphere schema and the Offer Presentment API dev docs. AI drafted the client, models and tests.
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