Skip to content

Add View/Edit FI saved payment method UI and checkout integration - #1854

Draft
saurabh-P9525 wants to merge 38 commits into
braintree:view-edit-fi-beta-featurefrom
saurabh-P9525:feature/saved-payment-method-integration
Draft

Add View/Edit FI saved payment method UI and checkout integration#1854
saurabh-P9525 wants to merge 38 commits into
braintree:view-edit-fi-beta-featurefrom
saurabh-P9525:feature/saved-payment-method-integration

Conversation

@saurabh-P9525

@saurabh-P9525 saurabh-P9525 commented Aug 25, 2026

Copy link
Copy Markdown

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 the edit_billing_agreement_jwt plumbing from #1844.

  • The merchant supplies a BTPayPalCheckoutRequest plus a new BTPayPalSavedPaymentMethodRequest (amount, currency, merchant account). The component never reads internals off the checkout request, so no BraintreePayPal property had to be widened to public.
  • editBillingAgreement is scoped to a single tokenize call. BTPayPalCheckoutRequest is 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.
  • Every failure path degrades rather than blocking checkout: a failed FI fetch falls back to the brand-only tile, and credit messaging is additive.

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 after 7b57dd9c3 are 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 on saurabh-P9525:feature/saved-payment-method-with-demo and 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 — @StateObject builds the view model once, so anything captured there goes stale when the merchant updates the cart amount.
  • BTPayPalSavedPaymentMethodRequest (new, public): amount, currencyCode, merchantAccountID. Equatable, so onChange can refetch credit messaging when the amount changes.
  • BTPayPalSavedPaymentMethodViewStyle (new, public): 21 merchant-configurable fields across componentAppearance and container, each with a memberwise initializer.
  • EditFiStyleGuard: resolves every style value field → baseFontSize → SDK default and clamps negatives to 0, so a merchant can never hand SwiftUI a negative frame.
  • BTPayPalCheckoutRequest: replaces enableEditBillingAgreement() with a scoped withEditBillingAgreement { }, which restores the previous value in a defer on every exit path including throws.
  • BTPayPalSavedPaymentMethodClient: adds editFundingInstrument(request:), wrapping BTPayPalClient.tokenize in that scope. Also takes the fetch client through the view model's designated initializer so the network layer can be stubbed in tests.
  • Loader dismissal: BTPayPalClient.applicationDidBecomeActive never resumes its continuation, so an abandoned app switch left the full-screen loader up permanently with no way to dismiss it. Now cleared on willEnterForegroundNotification, matching PayPalButton.
  • Accessibility: EditFIRow falls back to a stacked layout via ViewThatFits. 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.
  • Tests: 57 unit tests in BraintreePayPalSavedPaymentMethodTests, up from 27 — the style-guard default/clamp matrix, every state(from:) branch, both fetch paths, the three learnMoreTapped branches, foreground dismissal, and requestChanged refetching with a new amount.

Steps to test

Automated — this is the full extent of what this PR can be verified with on its own:

xcodebuild test -workspace Braintree.xcworkspace -scheme UnitTests \
  -destination 'platform=iOS Simulator,name=iPhone 16 Pro'

BraintreePayPalSavedPaymentMethodTests covers 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:

git fetch https://github.com/saurabh-P9525/braintree_ios.git feature/saved-payment-method-with-demo
git checkout FETCH_HEAD -- Demo/

Then run the PayPal Saved Payment Method screen with a client token carrying paymentMethodIdJwt and:

  1. Confirm the FI row resolves and the Pay Later message renders beneath it.
  2. Tap the edit pencil → PayPal paysheet → complete. A nonce is returned, credit messaging hides (the offer was quoted against the pre-edit instrument), and the FI refreshes.
  3. Repeat with enablePayPalAppSwitch: false to exercise the ASWebAuthenticationSession fallback.
  4. Abandonment: tap edit, force-quit the PayPal app, return to the merchant app. The loader dismisses and the previous FI is restored.
  5. Accessibility: set Larger Text to maximum. The row stacks instead of truncating the digits.

Notes for reviewers

  • withEditBillingAgreement replaces the enableEditBillingAgreement() 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 on BTPayPalCheckoutRequest are internal — 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.
  • The Demo integration was split out to keep this PR to the shipped surface. Nothing in Demo/ changes here; the screen is preserved on feature/saved-payment-method-with-demo and can be folded back in on request.
  • BraintreePayPalSavedPaymentMethod_IntegrationTests is still a scaffold. The blocker is provisioning, not this PR: the demo merchant's token endpoint does not issue the paymentMethodIdJwt claim, 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).
  • Dark mode is out of scope for the beta. The defaults are light-mode values. A proper fix also needs dark variants for the fixed Figma values (#F0F2F9 pill, #CCCCCC card-icon border), so it needs design input rather than a code-only change. Noting we are inconsistent with CardFields, which already uses .systemBackground / .label.
  • Analytics were deliberately removed from this PR and will land separately.
  • BTPayPalSavedPaymentMethodFont calls UIFontMetrics.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.
  • integrationChannel is BT_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?

  • Copilot
  • Claude
  • Other (Type Name Here)

How was AI used?
Used Claude (guided by the repo's CLAUDE.md) to build the SwiftUI component, view model and styling layer, scope the editBillingAgreement flag, 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 with ImageRenderer and comparing output rather than by inspection.

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.


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

Aryaman Raina added 6 commits August 22, 2026 15:52
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.
@saurabh-P9525
saurabh-P9525 requested a review from a team as a code owner August 25, 2026 08:40
@saurabh-P9525
saurabh-P9525 marked this pull request as draft August 25, 2026 08:59
Aryaman Raina added 2 commits August 26, 2026 19:05
… 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.
Comment on lines +196 to +198
/// `(nonce, error)` — a refresh failure keeps the last-known FI, since the edit itself succeeded.
private func performEdit(
checkoutRequest: BTPayPalCheckoutRequest,

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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.

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.

it will be hidden , fixed the behavior.

Comment on lines +176 to +181
) {
guard !isEditing else { return }
fiStateBeforeEdit = fiState
isEditing = true
Task { await performEdit(checkoutRequest: checkoutRequest, request: request) }
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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.

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

Comment on lines +88 to +90
func testRender_loadingStateWithCreditMessaging_rendersBothSkeletons() throws {
try render(view(state: .loading, showCreditMessage: false))
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Test is named WithCreditMessaging but showCreditMessage is false

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

Comment on lines +104 to +107

/// 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 {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Stale after the constants move, Defaults.payPalLogoSide now exists.

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

Aryaman Raina added 2 commits August 31, 2026 16:33
…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.
Aryaman Raina and others added 16 commits September 3, 2026 03:18
…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.
Saurabh Pandey added 11 commits September 3, 2026 15:57
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.
@saurabh-P9525
saurabh-P9525 force-pushed the feature/saved-payment-method-integration branch from 774e8a1 to 30f5679 Compare September 3, 2026 10:41
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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants