Skip to content

W-20394105: Save payment method for future use#3570

Merged
rasbhat merged 12 commits intot/team404/sfp-on-pwafrom
rvishwanathbhat/save-payment-method-for-future-use
Jan 19, 2026
Merged

W-20394105: Save payment method for future use#3570
rasbhat merged 12 commits intot/team404/sfp-on-pwafrom
rvishwanathbhat/save-payment-method-for-future-use

Conversation

@rasbhat
Copy link

@rasbhat rasbhat commented Jan 9, 2026

A registered customer will be able to save payment method.

Description

Screenshot 2026-01-14 at 11 45 55 AM

GET customer API call response after payment method is saved:

{
    "authType": "registered",
    "creationDate": "2026-01-14T19:12:19.000Z",
    "customerId": "abwHsXmbJGxucRmrE2maYYkrg1",
    "customerNo": "00000001",
    "email": "rvishwanathbhat@salesforce.com",
    "enabled": true,
    "firstName": "Rashmi",
    "gender": 0,
    "hashedLogin": "f27c710a7a92cea55ddaf8e4ea069b82ef967d17b6bd22e1167fd4e1d02de8c4",
    "lastLoginTime": "2026-01-14T23:59:44.000Z",
    "lastModified": "2026-01-14T23:59:44.000Z",
    "lastName": "Bhat",
    "lastVisitTime": "2026-01-14T23:59:44.000Z",
    "login": "rvishwanathbhat@salesforce.com",
    "paymentMethodReferences": [
        {
            "accountId": "acct_1S5ogDImuWDWWthS",
            "brand": "visa",
            "id": "pm_1SpspoImuWDWWthSZ58dfkQa",
            "last4": "4242",
            "type": "card"
        },
        {
            "accountId": "acct_1S5ogDImuWDWWthS",
            "brand": "visa",
            "id": "pm_1SpsXtImuWDWWthSN3ZcylGi",
            "last4": "1111",
            "type": "card"
        }
    ],
    "previousLoginTime": "2026-01-14T23:59:44.000Z",
    "previousVisitTime": "2026-01-14T23:59:44.000Z"
}

Tests covered:

rvishwanathbhat@rvishwa-ltmux7p template-retail-react-app % npm run test ./app/utils/sf-payments-utils.test.js
npm warn Unknown user config "always-auth" (//nexus-proxy.repo.local.sfdc.net/nexus/content/groups/npm-all/:always-auth). This will stop working in the next major version of npm.
npm warn Unknown user config "always-auth" (//nexus-proxy.repo.local.sfdc.net/nexus/content/repositories/npmjs-internal/:always-auth). This will stop working in the next major version of npm.
npm warn Unknown user config "always-auth". This will stop working in the next major version of npm.
npm warn Unknown user config "email". This will stop working in the next major version of npm.

> @salesforce/retail-react-app@8.2.0-dev test
> pwa-kit-dev test ./app/utils/sf-payments-utils.test.js

Browserslist: browsers data (caniuse-lite) is 11 months old. Please run:
  npx update-browserslist-db@latest
  Why you should do it regularly: https://github.com/browserslist/update-db#readme
 PASS  app/utils/sf-payments-utils.test.js
  sf-payments-utils
    getSFPaymentsInstrument
      ✓ returns undefined when basketOrOrder is undefined
      ✓ returns undefined when basketOrOrder is null (1 ms)
      ✓ returns undefined when paymentInstruments is undefined
      ✓ returns undefined when paymentInstruments is empty
      ✓ returns undefined when no Salesforce Payments instruments exist
      ✓ returns first Salesforce Payments instrument
      ✓ returns first Salesforce Payments instrument when multiple exist
      ✓ returns first Salesforce Payments instrument from mixed array
      ✓ works with basket object
      ✓ works with order object (1 ms)
      ✓ maintains original payment instrument properties
    buildTheme
      default theme structure
        ✓ returns theme object with all required properties
        ✓ returns correct design tokens
        ✓ returns correct rules configuration
        ✓ returns default express buttons configuration
      custom options
        ✓ applies custom expressButtonLayout
        ✓ applies custom expressButtonLabels (1 ms)
        ✓ applies both custom options together
        ✓ handles partial expressButtonLabels override
        ✓ handles empty options object
        ✓ handles null options
        ✓ handles undefined options
      theme immutability
        ✓ multiple calls return independent objects
        ✓ different options create different themes
      express button specific configurations
        ✓ buttonColors are always static
        ✓ buttonShape is always pill
        ✓ buttonHeight is always 44
        ✓ supports all valid layout options
        ✓ supports all button label types
      design token properties
        ✓ font-family uses system font stack
        ✓ color tokens are valid hex values
        ✓ spacing tokens use pixel units
        ✓ border-radius tokens use pixel units
      rules configuration
        ✓ input rules include focus state
        ✓ input rules include invalid state
        ✓ button rules include border-radius
        ✓ formLabel rules include typography settings
        ✓ error rules use error color
    transformAddressDetails
      ✓ transforms complete addresses with full names
      ✓ handles single word name in billing address (1 ms)
      ✓ handles missing name in shipping address
      ✓ handles missing line2
      ✓ handles missing phone
      ✓ handles international address
      ✓ uses shipping as billing when billing details are incomplete (PayPal case)
      ✓ uses shipping as billing when billing details missing name
    transformShippingMethods
      ✓ transforms shipping methods with numeric price
      ✓ transforms shipping methods with string price
      ✓ sorts selected method to top when sortSelected is true
      ✓ does not sort when sortSelected is false (1 ms)
      ✓ handles no selected ID
      ✓ uses basket currency for all methods
    getSelectedShippingMethodId
      ✓ returns shipping method ID from basket shipment
      ✓ returns default when basket has no shipments
      ✓ returns default when basket has empty shipments array
      ✓ returns default when shipping method is null
      ✓ returns default when shipping method ID is undefined
    isShippingMethodValid
      ✓ returns true when current shipping method is in applicable methods
      ✓ returns false when current shipping method is not in applicable methods
      ✓ returns false when current shipping method is undefined
      ✓ returns false when applicable methods is empty
    isPayPalPaymentMethodType
      ✓ returns true for paypal payment method type
      ✓ returns true for venmo payment method type
      ✓ returns false for card payment method type
    createPaymentInstrumentBody
      ✓ creates payment instrument body with all parameters
      ✓ uses default zoneId when not provided (1 ms)
      ✓ uses default zoneId when undefined
      ✓ creates body for PayPal payment
      ✓ creates body for Venmo payment
      ✓ creates body for card payment
      ✓ handles decimal amounts
      ✓ handles zero amount
      ✓ includes shippingPreference when provided
      ✓ includes gateway and gatewayProperties.stripe.setup_future_usage when storePaymentMethod is true
      ✓ includes gateway and gatewayProperties.stripe.setup_future_usage as off_session when futureUsageOffSession is true
      ✓ does not include gatewayProperties when storePaymentMethod is false and futureUsageOffSession is false
    getClientSecret
      ✓ returns clientSecret from gatewayProperties.stripe structure
      ✓ returns undefined when structure is missing or invalid

Lint:

rvishwanathbhat@rvishwa-ltmux7p template-retail-react-app % npm run lint
npm warn Unknown user config "always-auth" (//nexus-proxy.repo.local.sfdc.net/nexus/content/groups/npm-all/:always-auth). This will stop working in the next major version of npm.
npm warn Unknown user config "always-auth" (//nexus-proxy.repo.local.sfdc.net/nexus/content/repositories/npmjs-internal/:always-auth). This will stop working in the next major version of npm.
npm warn Unknown user config "always-auth". This will stop working in the next major version of npm.
npm warn Unknown user config "email". This will stop working in the next major version of npm.

> @salesforce/retail-react-app@8.2.0-dev lint
> pwa-kit-dev lint "**/*.{js,jsx}"


/Users/rvishwanathbhat/dev/git-repos/pwa-kit/packages/template-retail-react-app/app/components/passwordless-login/index.jsx
  23:5  warning  'setLoginType' is assigned a value but never used  @typescript-eslint/no-unused-vars

/Users/rvishwanathbhat/dev/git-repos/pwa-kit/packages/template-retail-react-app/app/components/shopper-agent/index.jsx
    8:9   warning  'useLocation' is defined but never used        @typescript-eslint/no-unused-vars
  160:20  warning  'buildUrl' is assigned a value but never used  @typescript-eslint/no-unused-vars

/Users/rvishwanathbhat/dev/git-repos/pwa-kit/packages/template-retail-react-app/app/hooks/use-auth-modal.test.js
  146:1  warning  Disabled test  jest/no-disabled-tests

/Users/rvishwanathbhat/dev/git-repos/pwa-kit/packages/template-retail-react-app/app/hooks/use-miaw.test.js
  8:8  warning  'React' is defined but never used  @typescript-eslint/no-unused-vars

/Users/rvishwanathbhat/dev/git-repos/pwa-kit/packages/template-retail-react-app/app/pages/cart/index.jsx
  133:9   warning  'findOrCreateDeliveryShipment' is assigned a value but never used  @typescript-eslint/no-unused-vars
  880:11  warning  'allQualifyingProducts' is assigned a value but never used         @typescript-eslint/no-unused-vars

/Users/rvishwanathbhat/dev/git-repos/pwa-kit/packages/template-retail-react-app/app/pages/cart/index.test.js
  331:1  warning  Disabled test suite  jest/no-disabled-tests

/Users/rvishwanathbhat/dev/git-repos/pwa-kit/packages/template-retail-react-app/app/pages/cart/partials/select-bonus-products-card.test.jsx
  114:1  warning  Disabled test suite  jest/no-disabled-tests

/Users/rvishwanathbhat/dev/git-repos/pwa-kit/packages/template-retail-react-app/app/pages/checkout/confirmation.test.js
  9:26  warning  'within' is defined but never used  @typescript-eslint/no-unused-vars

✖ 10 problems (0 errors, 10 warnings)

Types of Changes

  • Bug fix (non-breaking change that fixes an issue)
  • New feature (non-breaking change that adds functionality)
  • Documentation update
  • Breaking change (could cause existing functionality to not work as expected)
  • Other changes (non-breaking changes that does not fit any of the above)

Breaking changes include:

  • Removing a public function or component or prop
  • Adding a required argument to a function
  • Changing the data type of a function parameter or return value
  • Adding a new peer dependency to package.json

Changes

  • (change1)

How to Test-Drive This PR

  • (step1)

Checklists

General

  • Changes are covered by test cases
  • CHANGELOG.md updated with a short description of changes (not required for documentation updates)

Accessibility Compliance

You must check off all items in one of the follow two lists:

  • There are no changes to UI

or...

Localization

  • Changes include a UI text update in the Retail React App (which requires translation)

@rasbhat rasbhat requested a review from a team as a code owner January 9, 2026 17:24
@salesforce-cla
Copy link

salesforce-cla bot commented Jan 9, 2026

Thanks for the contribution! It looks like @rvishwanathbhat is an internal user so signing the CLA is not required. However, we need to confirm this.

@cc-prodsec
Copy link
Collaborator

cc-prodsec commented Jan 9, 2026

Snyk checks have passed. No issues have been found so far.

Status Scanner Critical High Medium Low Total (0)
Open Source Security 0 0 0 0 0 issues
Licenses 0 0 0 0 0 issues

💻 Catch issues earlier using the plugins for VS Code, JetBrains IDEs, Visual Studio, and Eclipse.

statusCode,
errorMessage,
errorDetails,
requestBody: JSON.parse(JSON.stringify(requestBody)),
Copy link
Collaborator

Choose a reason for hiding this comment

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

How can we be sure it's safe to log the entire request body? If we keep this, let's please not do parse(stringify()).

Copy link
Author

Choose a reason for hiding this comment

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

Removed the Request body. Modified to log only error details.


const account = findPaymentAccount(paymentMethodSetAccounts, paymentMethodType)
if (!account) {
return paymentMethodType === 'card' ? 'stripe' : null
Copy link
Collaborator

Choose a reason for hiding this comment

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

I don't understand the need for these fallbacks. Also, can we put constants in a file to use here instead of repeating strings?

Copy link
Author

Choose a reason for hiding this comment

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

Removed these, now just returning null if an account isn't found.

@jeffraab-sfdc
Copy link
Collaborator

@rasbhat I saw the save as default checkbox in the screenshot. Since we don't support default SPM yet, should we suppress that checkbox?

@rasbhat
Copy link
Author

rasbhat commented Jan 9, 2026

@rasbhat I saw the save as default checkbox in the screenshot. Since we don't support default SPM yet, should we suppress that checkbox?

Makes sense, I've disabled the save as default checkbox.

*/
export const PARTIAL_HYDRATION_ENABLED = false

// Constants for Salesforce Payments
Copy link
Collaborator

Choose a reason for hiding this comment

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

Would these be better added to use-sf-payments? I honestly don't know.

Copy link
Author

Choose a reason for hiding this comment

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

Ah, that seems to be a hooks specific. Let me see if I can move it to payment specific file

const handlePaymentButtonApprove = async (event) => {
try {
const updatedOrder = await createAndUpdateOrder()
if (event?.detail?.savePaymentMethod !== undefined) {
Copy link
Collaborator

Choose a reason for hiding this comment

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

Is the idea here only to overwrite savePaymentMethodRef.current if we get event.detail.savePaymentMethod and the value is a boolean? If there are reasons to sometimes not get a savePaymentMethod value in this event, or not even be passed an event at all, it would be good to memorialize those reasons in a comment here.

Copy link
Author

Choose a reason for hiding this comment

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

Added a comment regarding the same

paymentMethodType.current,
zoneId
zoneId,
undefined,
Copy link
Collaborator

Choose a reason for hiding this comment

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

It's unusual to pass undefined like this. Would null suffice?

Copy link
Author

Choose a reason for hiding this comment

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

Yeah null should do. Updated it accordingly

paymentElement.addEventListener('sfp:paymentapprove', handlePaymentButtonApprove)
paymentElement.addEventListener('sfp:paymentcancel', handlePaymentButtonCancel)
paymentElement.addEventListener('sfp:savepaymentmethodchange', (event) => {
savePaymentMethodRef.current = event.detail?.savePaymentMethod === true
Copy link
Collaborator

Choose a reason for hiding this comment

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

This will set savePaymentMethodRef.current to false if the event has no detail or detail has no savePaymentMethod. Is that intentional? Are there sfp.savepaymentmethodchange events which will be missing those data?

Copy link
Collaborator

Choose a reason for hiding this comment

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

BTW I didn't recognize sfp:savepaymentmethodchange from the ecom SDK, and when I searched I didn't find it. Where did you see an event being fired with this name?

Copy link
Author

Choose a reason for hiding this comment

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

I was trying to have a listener on any changes in the payment method, buthandlePaymentMethodSelected is already doing the job by updating savePaymentMethodRef.current from evt.detail.savePaymentMethodForFutureUse, removed this redundant one. In handlePaymentMethodSelected, we only update when value is explicitly provided or preserve the previous value if not.


try {
// Update order payment instrument to create payment
const paymentInstrumentBody = createPaymentInstrumentBody(

Choose a reason for hiding this comment

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

We've added quite a bit to this component why do we not have test coverage? use-f-payments.js should also probably have unit test. Have you ran the necessary CI commands like test coverage and linting? Since we are working on feature branch I'm afraid that we are pushing stuff that would normally be gated.

Copy link
Author

Choose a reason for hiding this comment

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

All the changes we have are in event handlers that are triggered by SDK events (eg: sfp:paymentapprove). Testing this might require dispatching SDK events, and I wasn't able to come across any tests in codebase that did it. The existing tests in sf-payment-sheet.test.js as well didn't cover any of these functions.
I've covered all utility function tests.
I've run lint on all files. Attaching it in the description.

Choose a reason for hiding this comment

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

This is concerning. We have changes here regarding confirming a payment yet existing tests aren't updated and no new tests are added. If what you say is true then our feature branch likely doesn't have sufficient coverage to be merged. CC @amittapalli

Copy link
Contributor

@amittapalli amittapalli Jan 15, 2026

Choose a reason for hiding this comment

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

:( We are behind in coverage for sure. Limited tests were added for things during the development phase. Let me create a WI to track this. We start slow and fix the low hanging fruits first and increase coverage.
Thing is the PWA team has also suggested refactoring some of these components that have grown over time and hard to understand. No decisions have been made since refactoring can also create further instability. If any refactoring is done, the tests need to be rewritten anyway.

And due to the heavy dependency on SDK, events etc. testing some of the scenarios will require more time, hence we need separate WIs to go over this carefully

Copy link
Collaborator

Choose a reason for hiding this comment

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

Since SF Next is the path forward I recommend against heavy refactoring. Whether or not we choose to refactor, we need test coverage for many reasons. IMO it can come soon after feature delivery since it's a form of tech debt to pay down, but since we could find subtle bugs in the process it's not something we should delay for very long. We just have to balance our priorities.

Copy link
Author

Choose a reason for hiding this comment

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

Added test file to test SDK events sf-payments-sheet.events.test.js

  1. Increased sf-payments-sheet.js coverage to 75%
  2. Increased the sf-payments-utils.test.js coverage to 100%.
  3. Increased use-sf-payments.js coverage to 95%.


await waitFor(() => {
const checkbox = screen.queryByRole('checkbox', {name: /same as shipping/i})
if (checkbox) {

Choose a reason for hiding this comment

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

so if checkbox is falsy for some reason wouldn't we get a false positive?

Copy link
Author

Choose a reason for hiding this comment

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

Makes sense, Changed from conditional assertion to explicit assertion, so the test will fail if the checkbox exists unexpectedly.

const updateCall = mockUpdatePaymentInstrument.mock.calls[0]
const requestBody = updateCall[0].body
expect(requestBody.paymentReferenceRequest.gatewayProperties.stripe.setupFutureUsage).toBe('on_session')
expect(querySelectorSpy).toHaveBeenCalledWith('.sfpp-save-payment-method-for-future-use input[type="checkbox"]')

Choose a reason for hiding this comment

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

While I like the effort to push this test to more of an integration style test I think we should keep these more as unit tests. IMO this test gives us the assurance we need by firing the event, calling confirm and validating the API request. If you feel this adds more reliable assurance then feel free to keep it but I think the checkbox is the responsibility of the SDK and not this component.

Copy link
Author

Choose a reason for hiding this comment

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

Refactored tests : removed DOM checks, focused assertions on behavior rather than implementation details.

Choose a reason for hiding this comment

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

I see you removed some tests such as confirmPayment reads checkbox state for shouldSavePaymentMethod. To be clear I think the tests I was referring to were necessary and good tests. I was just saying that we didn't need the part regarding the SFP controlled checkbox. That is reaching outside the boundary of this component and complicates the test. However, it make perfect sense to make sure that confirm is being called with the correct arguments particularly after these events have been fired.

Copy link
Author

Choose a reason for hiding this comment

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

Makes sense. I took out the test previously as they were emulating integration tests and having to read state of checkbox as you mentioned. Added back the confirmpayment behavior tests for when the events are fired.

describe('onRequiresPayButtonChange callback', () => {
test('renders successfully with callback provided', () => {
const mockOnRequiresPayButtonChange = jest.fn()
test('confirmPayment sets setup_future_usage in paymentIntent when shouldSavePaymentMethod is true', async () => {

Choose a reason for hiding this comment

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

This test and previous one appear to have a lot of duplicated code. Opportunity to DRY it up and make it more readable

Copy link
Author

Choose a reason for hiding this comment

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

Refactored to remove duplication

futureUsageOffSession = false,
paymentMethods = null,
paymentMethodSetAccounts = null,
isPostRequest = false

Choose a reason for hiding this comment

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

There are a lot of arguments to this function. Would it be more consumable to accept an object instead?

Copy link
Author

Choose a reason for hiding this comment

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

Makes sense, refactored to accept a single object parameter.

@sf-mkosak sf-mkosak self-requested a review January 16, 2026 01:32
Copy link

@sf-mkosak sf-mkosak left a comment

Choose a reason for hiding this comment

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

Thanks for adding tests. Have a couple comments you should consider.

// Update order payment instrument to create payment
updatedOrder = await createAndUpdateOrder()
const checkbox = containerElementRef.current?.querySelector(
'.sfpp-save-payment-method-for-future-use input[type="checkbox"]'
Copy link
Collaborator

Choose a reason for hiding this comment

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

If I'm reading this right, this is reaching into the DOM rendered by the SDK to pull out the value. This code must not depend on the DOM in that way and will need to change. In SFRA we were able to implement all necessary work for SPMs using events and promises etc., so there should be no reason PWA needs to depend on internals in this way. Have a look at plugin_salesforcepayments client side JS if you need examples of that.

Copy link
Author

@rasbhat rasbhat Jan 16, 2026

Choose a reason for hiding this comment

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

Removed the DOM dependency and used savePaymentMethodRef.current to get the value.

Copy link
Collaborator

Choose a reason for hiding this comment

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

I have to head OOO so I don't have time to review the changes, but I re-requested myself to remove the request changes blocker. @sf-mkosak you had made a similar comment so please have a look at the latest when you get a chance. Thanks!

@jeffraab-sfdc jeffraab-sfdc self-requested a review January 16, 2026 17:01
@rasbhat rasbhat force-pushed the rvishwanathbhat/save-payment-method-for-future-use branch from d73d5bb to 3bdc9f2 Compare January 16, 2026 19:51
@rasbhat rasbhat merged commit 00a30d4 into t/team404/sfp-on-pwa Jan 19, 2026
8 of 16 checks passed
@rasbhat rasbhat deleted the rvishwanathbhat/save-payment-method-for-future-use branch January 19, 2026 22:35
rasbhat added a commit that referenced this pull request Mar 5, 2026
…/save-payment-method-for-future-use

W-20394105: Save payment method for future use
rasbhat added a commit that referenced this pull request Mar 5, 2026
…/save-payment-method-for-future-use

W-20394105: Save payment method for future use
rasbhat added a commit that referenced this pull request Mar 5, 2026
…/save-payment-method-for-future-use

W-20394105: Save payment method for future use
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.

5 participants