Add View/Edit FI saved payment method UI and checkout integration - #1854
Conversation
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.
…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.
… 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.
…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.
| /// `(nonce, error)` — a refresh failure keeps the last-known FI, since the edit itself succeeded. | ||
| private func performEdit( | ||
| checkoutRequest: BTPayPalCheckoutRequest, |
There was a problem hiding this comment.
Just want to confirm, is this the desired behavior in case of edit success but fetch fail? If the FI changes, new nonce will be present, but the user is being informed that the old FI is going to be charged.
i think as per LLD, post-edit fetch failure but edit success case should drop to email-only (if present) or hide it altogether.
There was a problem hiding this comment.
it will be hidden , fixed the behavior.
| ) { | ||
| guard !isEditing else { return } | ||
| fiStateBeforeEdit = fiState | ||
| isEditing = true | ||
| Task { await performEdit(checkoutRequest: checkoutRequest, request: request) } | ||
| } |
There was a problem hiding this comment.
appReturnedToForeground() nils fiStateBeforeEdit, but performEdit's defer still needs it, so on the app-switch rail a successful edit whose FI refresh fails leaves the skeleton on screen forever.
If what i asked here is the desired behavior, then capture the prior state in a local inside performEdit instead of sharing the property.
There was a problem hiding this comment.
fixed exactly as you suggested — performEdit now captures let priorState = fiState locally instead of reading the shared property. Traced it and your sequence is right: on the app-switch rail appReturnedToForeground() fires before the continuation resumes, so fiStateBeforeEdit was already nil by the time the defer ran, leaving .loading on screen permanently.
| func testRender_loadingStateWithCreditMessaging_rendersBothSkeletons() throws { | ||
| try render(view(state: .loading, showCreditMessage: false)) | ||
| } |
There was a problem hiding this comment.
Test is named WithCreditMessaging but showCreditMessage is false
|
|
||
| /// Logo width has no SDK-default constant here: callers fall back to the brand cluster's own | ||
| /// intrinsic size when the merchant leaves it unset. | ||
| static func logoWidth(_ value: CGFloat) -> CGFloat { |
There was a problem hiding this comment.
Stale after the constants move, Defaults.payPalLogoSide now exists.
There was a problem hiding this comment.
fixed. Reworded to say the Defaults.payPalLogoSide fallback is applied by the caller, since that's the only place that can tell an unset width from a supplied one.
…egroup files Documents the client token requirement and leaves Models/ holding only the GraphQL and POST bodies.
…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.
…ent literal Also notes that merchantAccountID applies to both fetch types.
Bring the SavedPaymentMethod SwiftUI layer (view, view model, style, rows, assets) onto rainaarya's data-layer branch + cherry-picked braintree#1844, and wire the 13 UI files into the module target. Compiles; view model still uses the preview seam (integration with BTPayPalSavedPaymentMethodClient follows).
onAppear now fetches the sticky FI (STICKY_FI) via BTPayPalSavedPaymentMethodClient and maps BTPayPalSavedPaymentMethodSummary -> FIState (instrument / display-only / brand-only fallback on empty or error). Edit pencil always shows for an instrument, and hides for display-only when payer.isEditable is false. Credit messaging fetches the presentment message and renders the composed block content (CreditMessageContent). Exposes amount/currencyCode/merchantAccountID on BTPayPalCheckoutRequest for the fetches.
- View/ViewModel take (authorization, universalLink, fallbackURLScheme, request: BTPayPalCheckoutRequest, style, completion:(BTPayPalAccountNonce?, Error?)), mirroring PayPalButton. Drop the Request wrapper and Result enum. - Edit tap tokenizes via BTPayPalClient(authorization:universalLink:fallbackURLScheme:); the merchant's request carries editBillingAgreement so create-payment-resource emits edit_billing_agreement_jwt from the client token. - Credit messaging gated by style.showCreditMessaging. - CreditMessageContent composes main + disclaimer (space-joined groups; image blocks resolve to alternativeText), action items remain the tappable Learn more.
…ytics, test build
FIX 1: route currencyCode/merchantAccountID through BTPayPalCheckoutRequest init in
tests (BTPayPalCheckoutRequest_Tests, BTPayPalClient_Tests) so the private(set)
properties compile; unblocks the UnitTests scheme build.
FIX 2: correct BTClientToken JSON key to camelCase paymentMethodIdJwt (+ tests).
FIX 7: replace WKWebView lander with SFSafariViewController
(BTPayPalCreditMessagingLanderView) presented via .sheet + presentationDetents.
FIX 8: fire credit-messaging:failed analytics on the credit fetch failure path.
- Merchant passes amount to the View; credit-messaging fetch uses it (currency defaults to USD). Component no longer reads amount/currencyCode off the request. - Revert amount + currencyCode on BTPayPalCheckoutRequest back to internal. - merchantAccountID stays public private(set) — still read for the sticky-FI fetch.
state(from:) returned .brandOnly when a successful fetch had neither a funding instrument nor a payer email, making the documented .hidden state unreachable. Network failures still fall back to .brandOnly via loadStickyFI's catch.
- Show a 'Sending you to PayPal' full-screen loader while create-payment-resource is in flight (isEditing); dismiss on foreground return and shimmer the FI while the cosmetic refresh runs. - Route the edit through fetchClient.editFundingInstrument(request:merchantAccountID:), restoring the pre-edit FI when the refresh yields no summary or fails. - Add editFundingInstrument placeholder on the client for the UI to wire against.
…flow - Remove editBillingAgreement from both public BTPayPalCheckoutRequest inits and expose an @_spi(BraintreePayPalSavedPaymentMethod) enableEditBillingAgreement() instead, so merchants not using this module can't reach a no-op property. @nonobjc keeps it out of the generated Objective-C header. - Implement editFundingInstrument: tokenize through BTPayPalClient and return the nonce; the caller refreshes the FI with nonce.paymentID as the order ID. - Hold the full-screen loader until the nonce arrives, hand it to the merchant, then shimmer only the FI row while the cosmetic refresh runs. Credit messaging keeps its resolved message instead of re-shimmering.
- New PayPal Saved Payment Method integration, registered in the settings menu and the ContainmentViewController switch; links BraintreePayPalSavedPaymentMethod into the Demo target. - Forward appearance transitions in embed(), otherwise a UIHostingController added to an already-visible parent never receives them and SwiftUI's onAppear (which kicks off the FI + credit-messaging fetches) never fires. - Add Demo-Simulator.entitlements with an associated domain in developer mode so universal-link app switch returns resolve on Simulator; device entitlements are unchanged.
…ontract Restructure the merchant-facing styling API to match the revised styling doc (v29, section 6.2) and the two scope decisions taken against it. Naming (review feedback: be explicit, a merchant does not know what an 'fi' is): - showLogo/showLabel/showCreditMessaging -> showPayPalLogo/showPayPalLabel/ showPayPalCreditMessaging - theme/Theme -> componentAppearance/ComponentAppearance, textColorBase -> textColor - Container -> ContainerStyle, Logo -> PayPalLogoStyle, Label -> PayPalLabelStyle, FiCluster -> FundingInstrumentStyle, CreditMessaging -> CreditMessagingStyle Optionality and resolution: - Every field is now Optional. nil means 'merchant did not set this', so the SDK default applies; nil never means zero. - EditFiStyleGuard becomes a resolver: it applies the SDK default first, then the existing floor-at-zero clamp. All defaults live in one Defaults enum and keep their previous values, so rendering is unchanged. - New componentAppearance.baseFontSize gives text sizes a three-tier fallback: element value, then baseFontSize, then the SDK default. - linkColor moves from the global appearance group to container.creditMessaging, since it only ever affected the credit-messaging row. Scope reduction: - FundingInstrumentStyle drops the eight pill and card-icon fields, keeping only textFontSize, editIconSize and leadingGap. Those values are now fixed constants read directly at the render site, so the component still looks identical while merchants can no longer alter chrome that sits next to the PayPal brand. The doc's version history shows these fields were never part of this contract; they came from a separate Figma pixel-matching pass. Two supporting changes fall out of the above: - The preview initializer's showCreditMessage parameter was declared but never forwarded, so the credit row could not render outside the network path and its styling was untestable. It is now passed through to the view model, which seeds a sample message. This also fixes a public parameter that advertised behaviour it did not deliver. - The edit-flow loader no longer shows the 'Sending you to PayPal' caption; it is now the spinner alone. Verified on Simulator: 22 scenarios covering all 21 configurable fields render correctly, including the three-tier font fallback and negative values clamping to zero.
BTPayPalClient.init appends itself to BTAppContextSwitcher's client list without de-duplicating, so building one per pencil tap grew that global array unbounded. The fetch client now owns universalLink/fallbackURLScheme and builds the PayPal client lazily on first edit. The view model's copies of both became write-only and are removed.
PayPal returns 'embeddable' per action item to say whether click_url may load in an embedded browser; we presented every lander in SFSafariViewController. Now non-embeddable landers hand off to the system browser via URLOpener, and non-web schemes are ignored. Also drops the medium detent, which cropped Safari's toolbar.
The Pay Later offer is quoted against the pre-edit funding instrument, so it no longer applies once the buyer changes it. Clearing creditMessage alone was not enough: performEdit sets fiState to .loading for the FI refresh, which would have dropped the row into its skeleton. A didCompleteEdit flag gates the whole region.
The client now owns the edit flow's BTPayPalClient, so its initializer requires a universal link.
The component needs amount, currency, and merchant account ID to resolve the funding instrument and its Pay Later message. Reading them off BTPayPalCheckoutRequest required widening merchantAccountID to public, so take them as a module-owned request type instead.
- Scope editBillingAgreement to one tokenize call via withEditBillingAgreement, so the merchant's BTPayPalCheckoutRequest is no longer left in edit mode. - Dismiss the edit loader when the app returns to the foreground, matching PayPalButton; an abandoned app switch never resumes the continuation. - Hold the requests and style on the view rather than the view model, which @StateObject builds once, so merchant updates no longer go stale. - Render the data layer's BTPayPalSavedPaymentMethod directly and drop the duplicate BTPayPalSavedPaymentMethodFISummary. - Remove analytics and the preview-only public API; both land separately. - Fall back to a stacked layout when the row no longer fits, so the funding instrument is not truncated at large accessibility text sizes. - Apply the requested weight on the custom-font path. - Pad before setting the container height, and keep the fallback glyph off AsyncImage's empty phase. - Give the nested style types memberwise initializers, move fixed Figma values into EditFiStyleGuard.Defaults, and drop the placeholder messaging copy.
Take the fetch client through the view model's designated initializer so the network layer can be stubbed; the public initializer stays a convenience. Covers the style guard's default/clamp matrix, the fallback states in state(from:), both fetch paths, the three learnMoreTapped branches, and the loader dismissal on foreground. Also asserts requestChanged refetches with the new amount, which is the behaviour the @StateObject fix depends on.
Nothing reads it and the CIB/CISB contract is not settled, so it would ship as public API we cannot change later.
Combine pattern bindings, rename a two-character binding, restore the trailing newline, and split the preview arguments so each call is one per line. The demo screen now uses trailing closure syntax.
The component is exercised by unit tests; the Demo integration lands with the custom-env token entry in a follow-up. Kept on feature/saved-payment-method-with-demo so it can be restored if reviewers want to run the flow.
BTPayPalSavedPaymentMethodView and BTPayPalSavedPaymentMethodViewModel imported BraintreeCore and BraintreePayPal directly. Under CocoaPods every subspec compiles into a single Braintree module, so those modules do not exist and `pod lib lint` failed with "no such module 'BraintreeCore'". Guard both with `#if canImport`, matching the rest of the SDK. Resolve the URLOpener default inside the initializer body rather than in a default argument. `UIApplication.shared` is main-actor isolated and default arguments are evaluated nonisolated, which produced two warnings that fail `pod lib lint` on their own. Route container height through EditFiStyleGuard. It was the only style dimension forwarded to SwiftUI unclamped, contradicting the guard's own contract that a merchant can never supply a negative frame. Add render tests driving every visual state, dynamic type size and style permutation through ImageRenderer, which forces SwiftUI to evaluate each body. The SwiftUI layer was at 0% and unreachable from the view model: the accessibility truncation bug fixed in this branch lived entirely in EditFIRow's layout and every view model assertion passed while it was present. Also cover the error enum, font builder and resource bundle. Module coverage 31.2% -> 82.4%, repository total 76.3% -> 81.1%.
Capture the pre-edit state in a local inside performEdit. It was read from a property that appReturnedToForeground clears, and on the app-switch rail foregrounding happens before the task resumes, so a successful edit whose FI refresh failed left the skeleton on screen with no recovery path. Hide the row when the post-edit refresh fails instead of restoring the pre-edit instrument. The edit succeeded and a new nonce was issued, so re-showing the old instrument states that a payment method will be charged when it will not. The same applies when the nonce carries no paymentID. Render PAYPAL_CREDIT as its label alone. PayPal returns "Pay in 4" and "Pay Monthly" with a placeholder lastDigits of "0000" and no imageUrl, so the row previously showed a generic glyph beside a meaningless "..0000". Correct the logoWidth doc comment, which still claimed no SDK default existed after Defaults.payPalLogoSide was added. Split the two loading render tests, which were duplicates under names implying they differed in credit-messaging behaviour.
The sticky FI fetch and the credit messaging fetch run as independent tasks with no coupling, so a failed FI fetch alongside a successful messaging fetch left a live Pay Later offer on screen next to the brand-only tile. The offer is quoted against the funding instrument, so it must not outlive an instrument that could not be resolved. The two fetches race, so clearing the message is not sufficient on its own: a messaging response landing after the failure would reinstate it. A flag set in the failure path covers both orderings.
Replace the fiFetchFailed latch with a derived showsCreditMessaging rule on the view model. The offer is quoted against the funding instrument, so it should track the FI region rather than resolving independently: loading, instrument -> shown displayOnly -> only when the payer is editable brandOnly, hidden -> hidden A non-editable payer cannot change the instrument, so the offer is not actionable and is hidden alongside the edit pencil. Deriving this also removes the race the latch existed to paper over. The two fetches complete in either order, and the rule is re-read on every render rather than captured at failure time. Also assert the style defaults, which were only ever exercised through their false overrides.
It claimed the attribute is what keeps the method out of the generated Objective-C header. Verified otherwise: an SPI method that is Obj-C representable still lands in the header, so @_spi and @nonobjc guard the Swift and Obj-C sides independently. The attribute is inert while this signature stays generic and async, and load-bearing if it stops being.
774e8a1 to
30f5679
Compare
There is one call site and it always returns a BTPayPalAccountNonce, so the generic advertised flexibility nothing used. Narrowing it keeps the SPI surface to what the feature actually needs. Correct the doc comment, which claimed @nonobjc is what keeps this out of the generated Objective-C header. It is not: the method is skipped automatically because an async rethrows signature is not Objective-C representable, and removing @nonobjc leaves the header unchanged. The attribute is retained because @_spi has no bearing on header generation, so an SPI member that is representable would still be exported. Also record why the declaration is public: @_spi can only be applied to public or open declarations, so public satisfies the compiler while @_spi does the restricting. A plain import BraintreePayPal cannot reach it.
Add the UI and checkout integration layer for
BTPayPalSavedPaymentMethodView— the drop-in component that shows a returning PayPal buyer's saved funding instrument (FI), lets them edit it via the PayPal paysheet, and renders the accompanying Pay Later message. Builds on the data layer from #1850 and theedit_billing_agreement_jwtplumbing from #1844.BTPayPalCheckoutRequestplus a newBTPayPalSavedPaymentMethodRequest(amount, currency, merchant account). The component never reads internals off the checkout request, so noBraintreePayPalproperty had to be widened topublic.editBillingAgreementis scoped to a single tokenize call.BTPayPalCheckoutRequestis a class, so the previous one-way SPI setter permanently flipped a flag on the merchant's own instance — and it was applied at view construction, so merely rendering the component poisoned their object.Important
Stacked on #1850, which is still open, so the commit list here includes its 6 commits. They drop out of this diff once #1850 merges into
view-edit-fi-beta-feature. Only the commits after7b57dd9c3are new in this PR.Note
This PR is SDK-only — it touches no files under
Demo/. The Demo screen used to record the video below lives onsaurabh-P9525:feature/saved-payment-method-with-demoand will be raised separately. See Steps to test for how to pick it up.Summary of changes
BTPayPalSavedPaymentMethodView(new, public): renders the FI row and optional credit-messaging row. Render states are skeleton → instrument / display-only email / brand-only / hidden. Requests and style are held on the view struct and passed to the view model per call —@StateObjectbuilds the view model once, so anything captured there goes stale when the merchant updates the cart amount.BTPayPalSavedPaymentMethodRequest(new, public):amount,currencyCode,merchantAccountID.Equatable, soonChangecan refetch credit messaging when the amount changes.BTPayPalSavedPaymentMethodViewStyle(new, public): 21 merchant-configurable fields acrosscomponentAppearanceandcontainer, each with a memberwise initializer.EditFiStyleGuard: resolves every style valuefield → baseFontSize → SDK defaultand clamps negatives to0, so a merchant can never hand SwiftUI a negative frame.BTPayPalCheckoutRequest: replacesenableEditBillingAgreement()with a scopedwithEditBillingAgreement { }, which restores the previous value in adeferon every exit path including throws.BTPayPalSavedPaymentMethodClient: addseditFundingInstrument(request:), wrappingBTPayPalClient.tokenizein that scope. Also takes the fetch client through the view model's designated initializer so the network layer can be stubbed in tests.BTPayPalClient.applicationDidBecomeActivenever resumes its continuation, so an abandoned app switch left the full-screen loader up permanently with no way to dismiss it. Now cleared onwillEnterForegroundNotification, matchingPayPalButton.EditFIRowfalls back to a stacked layout viaViewThatFits. Previously the account digits truncated to··1…at large text sizes, hiding which card would be charged from the users who most need it legible.BraintreePayPalSavedPaymentMethodTests, up from 27 — the style-guard default/clamp matrix, everystate(from:)branch, both fetch paths, the threelearnMoreTappedbranches, foreground dismissal, andrequestChangedrefetching with a new amount.Steps to test
Automated — this is the full extent of what this PR can be verified with on its own:
BraintreePayPalSavedPaymentMethodTestscovers the render-state matrix, both fetch paths, the style-guard defaults and clamping, foreground loader dismissal, and refetch-on-amount-change.Manual — requires the Demo screen, which is not in this PR. To exercise the live flow, pull it in on top of this branch:
Then run the PayPal Saved Payment Method screen with a client token carrying
paymentMethodIdJwtand:enablePayPalAppSwitch: falseto exercise theASWebAuthenticationSessionfallback.Notes for reviewers
withEditBillingAgreementreplaces theenableEditBillingAgreement()SPI added in Source edit_billing_agreement_jwt from the client token for PayPal Edit FI #1844. A copy of the request is not possible from our module — all 27 properties onBTPayPalCheckoutRequestareinternal— so the flag is set and restored around the call instead. After the call the merchant's object is byte-identical to how they handed it to us.Demo/changes here; the screen is preserved onfeature/saved-payment-method-with-demoand can be folded back in on request.BraintreePayPalSavedPaymentMethod_IntegrationTestsis still a scaffold. The blocker is provisioning, not this PR: the demo merchant's token endpoint does not issue thepaymentMethodIdJwtclaim, so there is no way to reach the FI-fetch or edit paths from a test. Four narrower tests are feasible once that exists (credit-messaging fetch, tokenization-key rejection, missing-JWT, missing-orderID).#F0F2F9pill,#CCCCCCcard-icon border), so it needs design input rather than a code-only change. Noting we are inconsistent withCardFields, which already uses.systemBackground/.label.BTPayPalSavedPaymentMethodFontcallsUIFontMetrics.scaledValue(for:)without a trait collection, so it reads the device-wide text size and ignores a scoped.dynamicTypeSize(...). Correct on device; flagging as a follow-up.integrationChannelisBT_NATIVE_SDK, which is semantically correct but currently returns no funding instrument in sandbox. Pending backend confirmation.Demo Video
Recorded from the companion branch described above, since the Demo screen is not part of this PR.
Simulator.Screen.Recording.-.iPhone.17.Pro.-.2026-08-25.at.12.52.00.mov
AI Usage
Which AI Agent Was Used?
How was AI used?
Used Claude (guided by the repo's
CLAUDE.md) to build the SwiftUI component, view model and styling layer, scope theeditBillingAgreementflag, fix the loader and accessibility issues found in internal review, and write the unit tests. Layout and font behaviour were verified by rendering the component withImageRendererand comparing output rather than by inspection.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