diff --git a/changelog/dynamic-subscription-payment-methods-5508 b/changelog/dynamic-subscription-payment-methods-5508 new file mode 100644 index 00000000000..535726c4980 --- /dev/null +++ b/changelog/dynamic-subscription-payment-methods-5508 @@ -0,0 +1,4 @@ +Significance: minor +Type: add + +When editing subscriptions, load payment methods whenever the customer is changed. diff --git a/client/subscription-edit-page.js b/client/subscription-edit-page.js deleted file mode 100644 index a230ab2d430..00000000000 --- a/client/subscription-edit-page.js +++ /dev/null @@ -1,57 +0,0 @@ -/* global wcpaySubscriptionEdit */ - -const addOption = ( select, value, text ) => { - const option = document.createElement( 'option' ); - option.value = value; - option.text = text; - select.appendChild( option ); - return option; -}; - -const addWCPayCards = ( { - gateway, - table, - metaKey, - tokens, - defaultOptionText, -} ) => { - const paymentMethodInputId = `_payment_method_meta[${ gateway }][${ table }][${ metaKey }]`; - const paymentMethodInput = document.getElementById( paymentMethodInputId ); - const validTokenId = tokens.some( - ( token ) => token.tokenId.toString() === paymentMethodInput.value - ); - - // Abort if the input doesn't exist or is already a select element - if ( ! paymentMethodInput || paymentMethodInput.tagName === 'SELECT' ) { - return; - } - - const paymentMethodSelect = document.createElement( 'select' ); - paymentMethodSelect.id = paymentMethodInputId; - paymentMethodSelect.name = paymentMethodInputId; - - // Add placeholder option if no token matches the existing token ID. - if ( ! validTokenId ) { - const defaultOption = addOption( - paymentMethodSelect, - '', - defaultOptionText - ); - defaultOption.disabled = true; - defaultOption.selected = true; - } - - tokens.forEach( ( token ) => { - addOption( paymentMethodSelect, token.tokenId, token.displayName ); - } ); - - if ( validTokenId ) { - paymentMethodSelect.value = paymentMethodInput.value; - } - - const formField = paymentMethodInput.parentElement; - formField.insertBefore( paymentMethodSelect, paymentMethodInput ); - paymentMethodInput.remove(); -}; - -addWCPayCards( wcpaySubscriptionEdit ); diff --git a/client/subscription-edit-page/__tests__/index.test.tsx b/client/subscription-edit-page/__tests__/index.test.tsx new file mode 100644 index 00000000000..e264b819176 --- /dev/null +++ b/client/subscription-edit-page/__tests__/index.test.tsx @@ -0,0 +1,532 @@ +/* eslint-disable prettier/prettier */ +/** @format */ +/** + * External dependencies + */ +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import React from 'react'; + +/** + * Internal dependencies + */ +import { PaymentMethodSelect } from '../index'; +import UserTokenCache from '../user-token-cache'; +import type { Token } from '../types'; + +// Mock jQuery +const mockJQuery = jest.fn(); +const mockOn = jest.fn(); +const mockOff = jest.fn(); +( global as any ).jQuery = mockJQuery; +mockJQuery.mockReturnValue( { on: mockOn, off: mockOff } ); + +// Mock fetch +const mockFetch = jest.fn(); +global.fetch = mockFetch; + +// Mock @wordpress/i18n +jest.mock( '@wordpress/i18n', () => ( { + // eslint-disable-next-line @typescript-eslint/naming-convention + __: ( text: string ) => text, +} ) ); + +describe( 'PaymentMethodSelect Component', () => { + const mockTokens: Token[] = [ + { tokenId: 1, displayName: 'Visa •••• 1234' }, + { tokenId: 2, displayName: 'Mastercard •••• 5678' }, + { tokenId: 3, displayName: 'Amex •••• 9012' }, + ]; + + let cache: UserTokenCache; + let mockOnChange: jest.Mock; + + beforeEach( () => { + jest.clearAllMocks(); + cache = new UserTokenCache(); + mockOnChange = jest.fn(); + } ); + + describe( 'Rendering States', () => { + test( 'renders select with tokens', () => { + cache.add( 1, mockTokens ); + + render( + + ); + + const select = screen.getByRole( 'combobox' ); + expect( select ).toBeInTheDocument(); + expect( select ).toHaveAttribute( 'name', 'payment_method' ); + expect( select ).toHaveValue( '1' ); + + mockTokens.forEach( ( token ) => { + expect( + screen.getByText( token.displayName ) + ).toBeInTheDocument(); + } ); + } ); + + test( 'renders loading state', () => { + cache.startLoading( 1 ); + + render( + + ); + + expect( screen.getByText( 'Loading…' ) ).toBeInTheDocument(); + } ); + + test( 'renders error state', () => { + cache.startLoading( 1 ); + cache.loadingFailed( 1, 'Failed to fetch user tokens' ); + + render( + + ); + + expect( + screen.getByText( 'Failed to fetch user tokens' ) + ).toBeInTheDocument(); + } ); + + test( 'renders no customer selected message', () => { + render( + + ); + + expect( + screen.getByText( 'Please select a customer first' ) + ).toBeInTheDocument(); + } ); + + test( 'renders no customer selected message for undefined userId', () => { + render( + + ); + + expect( + screen.getByText( 'Please select a customer first' ) + ).toBeInTheDocument(); + } ); + + test( 'renders placeholder when no tokens match value', () => { + cache.add( 1, mockTokens ); + + render( + + ); + + expect( + screen.getByText( 'Please select a payment method' ) + ).toBeInTheDocument(); + } ); + + test( 'renders empty token list', () => { + cache.add( 1, [] ); + + render( + + ); + + const select = screen.getByRole( 'combobox' ); + expect( select ).toBeInTheDocument(); + } ); + } ); + + describe( 'User Interaction', () => { + test( 'calls onChange when user selects a payment method', async () => { + cache.add( 1, mockTokens ); + + render( + + ); + + const select = screen.getByRole( 'combobox' ) as HTMLSelectElement; + + await userEvent.selectOptions( select, '2' ); + + expect( mockOnChange ).toHaveBeenCalledWith( 2 ); + } ); + + test( 'placeholder option is disabled', () => { + cache.add( 1, mockTokens ); + + render( + + ); + + const placeholderOption = screen.getByText( + 'Please select a payment method' + ) as HTMLOptionElement; + + expect( placeholderOption ).toHaveAttribute( 'disabled' ); + expect( placeholderOption ).toHaveAttribute( 'value', '0' ); + } ); + } ); + + describe( 'Value Display', () => { + test( 'displays correct initial value', () => { + cache.add( 1, mockTokens ); + + render( + + ); + + const select = screen.getByRole( 'combobox' ) as HTMLSelectElement; + expect( select.value ).toBe( '2' ); + } ); + + test( 'updates when value prop changes', () => { + cache.add( 1, mockTokens ); + + const { rerender } = render( + + ); + + let select = screen.getByRole( 'combobox' ) as HTMLSelectElement; + expect( select.value ).toBe( '1' ); + + rerender( + + ); + + select = screen.getByRole( 'combobox' ) as HTMLSelectElement; + expect( select.value ).toBe( '2' ); + } ); + } ); +} ); + +describe( 'UserTokenCache', () => { + let cache: UserTokenCache; + const mockTokens: Token[] = [ + { tokenId: 1, displayName: 'Visa •••• 1234' }, + { tokenId: 2, displayName: 'Mastercard •••• 5678' }, + ]; + + beforeEach( () => { + cache = new UserTokenCache(); + } ); + + describe( 'add()', () => { + test( 'adds user with tokens to cache', () => { + cache.add( 1, mockTokens ); + + expect( cache.hasEntry( 1 ) ).toBe( true ); + const entry = cache.getUserEntry( 1 ); + expect( entry ).toEqual( { + userId: 1, + tokens: mockTokens, + loading: false, + loadingError: null, + } ); + } ); + + test( 'adds user with empty tokens', () => { + cache.add( 1, [] ); + + expect( cache.hasEntry( 1 ) ).toBe( true ); + const entry = cache.getUserEntry( 1 ); + expect( entry?.tokens ).toEqual( [] ); + } ); + + test( 'can add multiple users', () => { + cache.add( 1, mockTokens ); + cache.add( 2, [] ); + + expect( cache.hasEntry( 1 ) ).toBe( true ); + expect( cache.hasEntry( 2 ) ).toBe( true ); + } ); + } ); + + describe( 'startLoading()', () => { + test( 'adds user in loading state', () => { + cache.startLoading( 1 ); + + const entry = cache.getUserEntry( 1 ); + expect( entry ).toEqual( { + userId: 1, + loading: true, + loadingError: null, + tokens: [], + } ); + } ); + + test( 'adds loading state for new user', () => { + cache.add( 1, mockTokens ); + cache.startLoading( 2 ); + + expect( cache.hasEntry( 1 ) ).toBe( true ); + expect( cache.hasEntry( 2 ) ).toBe( true ); + expect( cache.getUserEntry( 2 )?.loading ).toBe( true ); + } ); + } ); + + describe( 'tokensLoaded()', () => { + test( 'updates loading entry with tokens', () => { + cache.startLoading( 1 ); + cache.tokensLoaded( 1, mockTokens ); + + const entry = cache.getUserEntry( 1 ); + expect( entry ).toEqual( { + userId: 1, + tokens: mockTokens, + loading: false, + loadingError: null, + } ); + } ); + + test( 'does not affect other users', () => { + cache.add( 1, mockTokens ); + cache.startLoading( 2 ); + cache.tokensLoaded( 2, [] ); + + const user1Entry = cache.getUserEntry( 1 ); + expect( user1Entry?.tokens ).toEqual( mockTokens ); + } ); + + test( 'clears loading state', () => { + cache.startLoading( 1 ); + expect( cache.getUserEntry( 1 )?.loading ).toBe( true ); + + cache.tokensLoaded( 1, mockTokens ); + expect( cache.getUserEntry( 1 )?.loading ).toBe( false ); + } ); + } ); + + describe( 'loadingFailed()', () => { + test( 'sets error message', () => { + cache.startLoading( 1 ); + cache.loadingFailed( 1, 'Network error' ); + + const entry = cache.getUserEntry( 1 ); + expect( entry ).toEqual( { + userId: 1, + tokens: [], + loading: false, + loadingError: 'Network error', + } ); + } ); + + test( 'clears loading state', () => { + cache.startLoading( 1 ); + cache.loadingFailed( 1, 'Error' ); + + expect( cache.getUserEntry( 1 )?.loading ).toBe( false ); + } ); + + test( 'preserves existing tokens', () => { + cache.startLoading( 1 ); + cache.loadingFailed( 1, 'Error' ); + + expect( cache.getUserEntry( 1 )?.tokens ).toEqual( [] ); + } ); + } ); + + describe( 'hasEntry()', () => { + test( 'returns true when user exists', () => { + cache.add( 1, mockTokens ); + + expect( cache.hasEntry( 1 ) ).toBe( true ); + } ); + + test( 'returns false when user does not exist', () => { + expect( cache.hasEntry( 999 ) ).toBe( false ); + } ); + + test( 'returns true for loading entries', () => { + cache.startLoading( 1 ); + + expect( cache.hasEntry( 1 ) ).toBe( true ); + } ); + } ); + + describe( 'getUserEntry()', () => { + test( 'returns user entry when exists', () => { + cache.add( 1, mockTokens ); + + const entry = cache.getUserEntry( 1 ); + expect( entry?.userId ).toBe( 1 ); + expect( entry?.tokens ).toEqual( mockTokens ); + } ); + + test( 'returns undefined when user does not exist', () => { + const entry = cache.getUserEntry( 999 ); + + expect( entry ).toBeUndefined(); + } ); + } ); + + describe( 'userHasToken()', () => { + test( 'returns true when user has token', () => { + cache.add( 1, mockTokens ); + + expect( cache.userHasToken( 1, 1 ) ).toBe( true ); + expect( cache.userHasToken( 1, 2 ) ).toBe( true ); + } ); + + test( 'returns false when user does not have token', () => { + cache.add( 1, mockTokens ); + + expect( cache.userHasToken( 1, 999 ) ).toBe( false ); + } ); + + test( 'returns false when user does not exist', () => { + expect( cache.userHasToken( 999, 1 ) ).toBe( false ); + } ); + + test( 'returns false for user with empty tokens', () => { + cache.add( 1, [] ); + + expect( cache.userHasToken( 1, 1 ) ).toBe( false ); + } ); + } ); + + describe( 'subscribe()', () => { + test( 'calls subscriber when cache updates', () => { + const subscriber = jest.fn(); + cache.subscribe( subscriber ); + + cache.add( 1, mockTokens ); + + expect( subscriber ).toHaveBeenCalledTimes( 1 ); + } ); + + test( 'calls all subscribers on update', () => { + const subscriber1 = jest.fn(); + const subscriber2 = jest.fn(); + + cache.subscribe( subscriber1 ); + cache.subscribe( subscriber2 ); + + cache.add( 1, mockTokens ); + + expect( subscriber1 ).toHaveBeenCalledTimes( 1 ); + expect( subscriber2 ).toHaveBeenCalledTimes( 1 ); + } ); + + test( 'calls subscribers on startLoading', () => { + const subscriber = jest.fn(); + cache.subscribe( subscriber ); + + cache.startLoading( 1 ); + + expect( subscriber ).toHaveBeenCalled(); + } ); + + test( 'calls subscribers on tokensLoaded', () => { + const subscriber = jest.fn(); + cache.subscribe( subscriber ); + + cache.startLoading( 1 ); + subscriber.mockClear(); + + cache.tokensLoaded( 1, mockTokens ); + + expect( subscriber ).toHaveBeenCalled(); + } ); + + test( 'calls subscribers on loadingFailed', () => { + const subscriber = jest.fn(); + cache.subscribe( subscriber ); + + cache.startLoading( 1 ); + subscriber.mockClear(); + + cache.loadingFailed( 1, 'Error' ); + + expect( subscriber ).toHaveBeenCalled(); + } ); + } ); + + describe( 'getCache()', () => { + test( 'returns current cache state', () => { + cache.add( 1, mockTokens ); + cache.add( 2, [] ); + + const cacheState = cache.getCache(); + + expect( cacheState ).toHaveLength( 2 ); + expect( cacheState[ 0 ].userId ).toBe( 1 ); + expect( cacheState[ 1 ].userId ).toBe( 2 ); + } ); + + test( 'returns empty array for new cache', () => { + const cacheState = cache.getCache(); + + expect( cacheState ).toEqual( [] ); + } ); + } ); +} ); diff --git a/client/subscription-edit-page/index.tsx b/client/subscription-edit-page/index.tsx new file mode 100644 index 00000000000..c564d874fba --- /dev/null +++ b/client/subscription-edit-page/index.tsx @@ -0,0 +1,280 @@ +/* eslint-disable prettier/prettier */ +/* global jQuery */ + +/** + * External dependencies + */ +import React from 'react'; +import { createRoot } from 'react-dom/client'; +import { __ } from '@wordpress/i18n'; + +// TypeScript declaration for jQuery +declare const jQuery: ( + selector: any +) => { + on: ( event: string, handler: () => void ) => void; + off: ( event: string, handler: () => void ) => void; +}; + +/** + * Internal dependencies + */ +import type { + PaymentMethodSelectProps, + WCPayPMSelectorData, + FetchUserTokensResponse, +} from './types'; +import UserTokenCache from './user-token-cache'; + +/** + * Add a listener to the customer select. + * + * This could be a shorter method, but because the customer select + * element uses select2, it does not emit the typical `change` event. + * + * @param {(userId: number) => void} callback The callback to call when the customer is changed. + * @return {() => void} The cleanup function. + */ +const addCustomerSelectListener = ( + callback: ( userId: number ) => void +): ( () => void ) => { + const customerUserSelect = document.getElementById( + 'customer_user' + ) as HTMLSelectElement | null; + + if ( ! customerUserSelect ) { + return (): void => { + // No-op cleanup function when element is not found + }; + } + + // Wrap in an internal callback to load the select's value. + const internalCallback = () => + callback( parseInt( customerUserSelect.value, 10 ) || 0 ); + + // Add the listner with the right technique, as select2 does not emit + onChange( parseInt( event.target.value, 10 ) ) + } + > + { options } + + ); +}; + +/** + * Setup the payment method select for a given element. + * + * @param {HTMLSpanElement} element The where the payment method select should be rendered. + * @param {UserTokenCache} cache The cache of user tokens. + * @return {void} + */ +const setupPaymentSelector = ( + element: HTMLSpanElement, + cache: UserTokenCache +): void => { + const data = JSON.parse( + element.getAttribute( 'data-wcpay-pm-selector' ) || '{}' + ) as WCPayPMSelectorData; + + // Use the values from the data instead of input to ensure correct types. + let userId = data.userId ?? 0; + let value = data.value ?? 0; + + // Initial population. + if ( userId ) { + cache.add( userId, data.tokens ?? [] ); + } + + // In older Subscriptions versions, there was just a simple input. + const input = element.querySelector( 'select,input' ) as + | HTMLSelectElement + | HTMLInputElement + | null; + if ( ! input ) { + return; + } + + const root = createRoot( element ); + const render = () => { + root.render( + { + value = newValue; + render(); + } } + /> + ); + }; + + render(); + cache.subscribe( render ); + addCustomerSelectListener( async ( newUserId ) => { + // Once the customer is changed, the selected payment method is lost. + value = 0; + userId = newUserId; + render(); + + // Looaded, loading, or errored out, we do not need to load anything. + if ( cache.hasEntry( userId ) ) { + return; + } + + cache.startLoading( userId ); + + try { + const response = await fetchUserTokens( + userId, + data.ajaxUrl, + data.nonce + ); + if ( undefined === response ) { + throw new Error( + __( + 'Failed to fetch user tokens. Please reload the page and try again.', + 'woocommerce-payments' + ) + ); + } + cache.tokensLoaded( userId, response.tokens ); + } catch ( error ) { + cache.loadingFailed( + userId, + error instanceof Error + ? error.message + : __( 'Unknown error', 'woocommerce-payments' ) + ); + } + } ); +}; + +/** + * Initializes all payment method dropdown elements on the page. + * + * Creates a shared cache for user tokens and sets up payment method + * selectors for all elements with the .wcpay-subscription-payment-method class. + * + * @return {void} + */ +const addPaymentMethodDropdowns = (): void => { + // Use a centralized cache for user tokens. + const cache = new UserTokenCache(); + + // There should be a single element on the page, but still make sure to iterate over all of them. + document + .querySelectorAll( '.wcpay-subscription-payment-method' ) + .forEach( ( element ) => { + setupPaymentSelector( element as HTMLSpanElement, cache ); + } ); +}; + +addPaymentMethodDropdowns(); diff --git a/client/subscription-edit-page/types.d.ts b/client/subscription-edit-page/types.d.ts new file mode 100644 index 00000000000..f3522a64d33 --- /dev/null +++ b/client/subscription-edit-page/types.d.ts @@ -0,0 +1,63 @@ +/** + * Internal dependencies + */ +import UserTokenCache from './user-token-cache'; + +/** + * Props for the WCPayPaymentMethodElement component + */ +export interface PaymentMethodElementProps { + element: HTMLSpanElement; +} + +/** + * Token represents a payment method token for a user + */ +export interface Token { + tokenId: number; + displayName: string; +} + +/** + * CachedUserDataItem represents cached token data for a specific user + */ +export interface CachedUserDataItem { + userId: number; + loading: boolean; + loadingError: string | null; + tokens: Token[]; +} + +/** + * CachedUserData is an array of cached user token data + */ +export type CachedUserData = CachedUserDataItem[]; + +/** + * Props for the PaymentMethodSelect component + */ +export interface PaymentMethodSelectProps { + inputName: string; + value: number; + userId: number; + cache: UserTokenCache; + onChange: ( value: number ) => void; +} + +/** + * Data structure from the wcpayPmSelector dataset attribute + */ +export interface WCPayPMSelectorData { + value: number; + userId: number; + tokens: Token[]; + ajaxUrl: string; + nonce: string; +} + +/** + * Response structure from the fetchUserTokens API call + */ +export interface FetchUserTokensResponse { + tokens: Token[]; +} diff --git a/client/subscription-edit-page/user-token-cache.ts b/client/subscription-edit-page/user-token-cache.ts new file mode 100644 index 00000000000..f645e04d387 --- /dev/null +++ b/client/subscription-edit-page/user-token-cache.ts @@ -0,0 +1,134 @@ +/** + * Internal dependencies + */ +import type { CachedUserData, CachedUserDataItem, Token } from './types'; + +export default class UserTokenCache { + private cache: CachedUserData = []; + private callbacks: ( () => void )[] = []; + + public subscribe( callback: () => void ) { + this.callbacks.push( callback ); + } + + public getCache(): CachedUserData { + return this.cache; + } + + private updateCache(): void { + this.cache = [ ...this.cache ]; + this.callbacks.forEach( ( callback ) => callback() ); + } + + /** + * Generates the initial user-token cache in a proper format. + * + * @param userId Initial user ID. + * @param tokens The pre-loaded tokens. + */ + public add( userId: number, tokens: Token[] ): void { + this.cache.push( { + userId, + tokens, + loading: false, + loadingError: null, + } ); + this.updateCache(); + } + + /** + * Add a new entry for a new user in the cache. + * The new entry can only land in a loading state. + * + * @param userId The user ID. + */ + public startLoading( userId: number ): void { + this.cache.push( { + userId, + loading: true, + loadingError: null, + tokens: [], + } ); + this.updateCache(); + } + + /** + * Update the cached data for a user when the tokens are loaded. + * + * @param userId The user ID. + * @param tokens The loaded tokens. + */ + public tokensLoaded( userId: number, tokens: Token[] ): void { + this.cache = this.cache.map( ( userData ) => { + if ( userData.userId !== userId ) { + return userData; + } + + return { + ...userData, + tokens, + loading: false, + loadingError: null, + }; + } ); + this.updateCache(); + } + + /** + * Update the cached data for a user when loading the tokens for a user failed. + * + * @param userId The user ID. + * @param errorMessage The error message. + */ + public loadingFailed( userId: number, errorMessage: string ): void { + this.cache = this.cache.map( ( userData ) => { + if ( userData.userId !== userId ) { + return userData; + } + + return { + ...userData, + loading: false, + loadingError: errorMessage, + }; + } ); + this.updateCache(); + } + + /** + * Check if the cached data for a user contains tokens. + * + * @param userId The user ID. + * @return True if the cached data for the user contains tokens, false otherwise. + */ + public hasEntry( userId: number ): boolean { + return this.cache.some( ( userData ) => userData.userId === userId ); + } + + /** + * Get the user entry from the cached data. + * + * @param userId The user ID. + * @return The user entry. + */ + public getUserEntry = ( + userId: number + ): CachedUserDataItem | undefined => { + return this.cache.find( ( userData ) => userData.userId === userId ); + }; + + /** + * Check if a user has a specific token. + * + * @param userId The user ID. + * @param tokenId The token ID. + * @return True if the user has the token, false otherwise. + */ + public userHasToken = ( userId: number, tokenId: number ): boolean => { + return ( + this.getUserEntry( userId )?.tokens.some( + ( token ) => token.tokenId === tokenId + ) ?? false + ); + }; +} diff --git a/includes/compat/subscriptions/trait-wc-payment-gateway-wcpay-subscriptions.php b/includes/compat/subscriptions/trait-wc-payment-gateway-wcpay-subscriptions.php index 08ae6a11de3..dddfd4569db 100644 --- a/includes/compat/subscriptions/trait-wc-payment-gateway-wcpay-subscriptions.php +++ b/includes/compat/subscriptions/trait-wc-payment-gateway-wcpay-subscriptions.php @@ -215,11 +215,7 @@ public function maybe_init_subscriptions_hooks() { add_filter( 'woocommerce_subscription_note_old_payment_method_title', [ $this, 'get_specific_old_payment_method_title' ], 10, 3 ); add_filter( 'woocommerce_subscription_note_new_payment_method_title', [ $this, 'get_specific_new_payment_method_title' ], 10, 3 ); - // TODO: Remove admin payment method JS hack for Subscriptions <= 3.0.7 when we drop support for those versions. - // Enqueue JS hack when Subscriptions does not provide the meta input filter. - if ( $this->is_subscriptions_plugin_active() && version_compare( $this->get_subscriptions_plugin_version(), '3.0.7', '<=' ) ) { - add_action( 'woocommerce_admin_order_data_after_billing_address', [ $this, 'add_payment_method_select_to_subscription_edit' ] ); - } + add_action( 'woocommerce_admin_order_data_after_billing_address', [ $this, 'add_payment_method_select_to_subscription_edit' ] ); /* * WC subscriptions hooks into the "template_redirect" hook with priority 100. @@ -233,6 +229,9 @@ public function maybe_init_subscriptions_hooks() { // Update subscriptions token when user sets a default payment method. add_filter( 'woocommerce_subscriptions_update_subscription_token', [ $this, 'update_subscription_token' ], 10, 3 ); add_filter( 'woocommerce_subscriptions_update_payment_via_pay_shortcode', [ $this, 'update_payment_method_for_subscriptions' ], 10, 3 ); + + // AJAX handler for fetching payment tokens when customer changes. + add_action( 'wp_ajax_wcpay_get_user_payment_tokens', [ $this, 'ajax_get_user_payment_tokens' ] ); } /** @@ -586,19 +585,8 @@ public function add_payment_method_select_to_subscription_edit( $order ) { if ( ! wcs_is_subscription( $order ) ) { return; } - WC_Payments::register_script_with_dependencies( 'WCPAY_SUBSCRIPTION_EDIT_PAGE', 'dist/subscription-edit-page' ); - wp_localize_script( - 'WCPAY_SUBSCRIPTION_EDIT_PAGE', - 'wcpaySubscriptionEdit', - [ - 'gateway' => $this->id, - 'table' => self::$payment_method_meta_table, - 'metaKey' => self::$payment_method_meta_key, - 'tokens' => $this->get_user_formatted_tokens_array( $order->get_user_id() ), - 'defaultOptionText' => __( 'Please select a payment method', 'woocommerce-payments' ), - ] - ); + WC_Payments::register_script_with_dependencies( 'WCPAY_SUBSCRIPTION_EDIT_PAGE', 'dist/subscription-edit-page' ); wp_set_script_translations( 'WCPAY_SUBSCRIPTION_EDIT_PAGE', 'woocommerce-payments' ); @@ -686,6 +674,37 @@ public function maybe_hide_auto_renew_toggle_for_manual_subscriptions( $allcaps, return $allcaps; } + /** + * AJAX handler to fetch payment tokens for a user. + * + * @return void + */ + public function ajax_get_user_payment_tokens() { + check_ajax_referer( 'wcpay-subscription-edit', 'nonce' ); + + if ( ! current_user_can( 'manage_woocommerce' ) ) { + wp_send_json_error( [ 'message' => __( 'You do not have permission to perform this action.', 'woocommerce-payments' ) ], 403 ); + return; + } + + $user_id = isset( $_POST['user_id'] ) ? absint( $_POST['user_id'] ) : 0; + + if ( $user_id <= 0 ) { + wp_send_json_success( [ 'tokens' => [] ] ); + return; + } + + // Verify user exists. + $user = get_user_by( 'id', $user_id ); + if ( ! $user ) { + wp_send_json_error( [ 'message' => __( 'Invalid user ID.', 'woocommerce-payments' ) ], 400 ); + return; + } + + $tokens = $this->get_user_formatted_tokens_array( $user_id ); + wp_send_json_success( [ 'tokens' => $tokens ] ); + } + /** * Outputs a select element to be used for the Subscriptions payment meta token selection. * @@ -694,23 +713,61 @@ public function maybe_hide_auto_renew_toggle_for_manual_subscriptions( $allcaps, * @param string $field_value The field_value to be selected by default. */ public function render_custom_payment_meta_input( $subscription, $field_id, $field_value ) { - $tokens = $this->get_user_formatted_tokens_array( $subscription->get_user_id() ); - $is_valid_value = false; + // Make sure that we are either working with integers or null. + $field_value = ctype_digit( $field_value ) + ? absint( $field_value ) + : ( + is_int( $field_value ) + ? $field_value + : null + ); - foreach ( $tokens as $token ) { - $is_valid_value = $is_valid_value || (int) $field_value === $token['tokenId']; - } + $user_id = $subscription->get_user_id(); + $disabled = false; + $selected = null; + $options = []; + $prepared_data = [ + 'value' => $field_value, + 'userId' => $user_id, + 'tokens' => [], + 'ajaxUrl' => admin_url( 'admin-ajax.php' ), + 'nonce' => wp_create_nonce( 'wcpay-subscription-edit' ), + ]; - echo ''; + if ( $user_id > 0 ) { + $tokens = $this->get_user_formatted_tokens_array( $user_id ); + foreach ( $tokens as $token ) { + $options[ $token['tokenId'] ] = $token['displayName']; + if ( $field_value === $token['tokenId'] || ( ! $field_value && $token['isDefault'] ) ) { + $selected = $token['tokenId']; + } + } + + $prepared_data['tokens'] = $tokens; + + if ( empty( $options ) ) { + $options[0] = __( 'No payment methods found for customer', 'woocommerce-payments' ); + $disabled = true; + } + } else { + $options[0] = __( 'Please select a customer first', 'woocommerce-payments' ); + $selected = 0; + $disabled = true; + } + ?> + + + + add_payment_token( $tokens[0] ); $subscription->add_payment_token( $tokens[1] ); - $this->expectOutputString( - '' - ); - + ob_start(); $this->wcpay_gateway->render_custom_payment_meta_input( $subscription, 'field_id', strval( $tokens[0]->get_id() ) ); + $output = ob_get_clean(); + + // Check that the output contains the wrapper span with class. + $this->assertStringContainsString( 'class="wcpay-subscription-payment-method"', $output ); + $this->assertStringContainsString( 'data-wcpay-pm-selector=', $output ); + // Check that the select element is present. + $this->assertStringContainsString( '' . - '' . - '' . - '' . - '' - ); + // Use a numeric token ID that doesn't exist to trigger the placeholder. + $invalid_token_id = 99999; + + ob_start(); + $this->wcpay_gateway->render_custom_payment_meta_input( $subscription, 'field_id', $invalid_token_id ); + $output = ob_get_clean(); - $this->wcpay_gateway->render_custom_payment_meta_input( $subscription, 'field_id', 'invalid_value' ); + // Check for the wrapper span and data attributes. + $this->assertStringContainsString( 'class="wcpay-subscription-payment-method"', $output ); + // Check that the placeholder option is present when value is invalid numeric. + $this->assertStringContainsString( 'Please select a payment method', $output ); + // Check that both tokens are present as options. + $this->assertStringContainsString( 'value="' . $tokens[0]->get_id() . '"', $output ); + $this->assertStringContainsString( 'value="' . $tokens[1]->get_id() . '"', $output ); } public function test_render_custom_payment_meta_input_multiple_tokens() { @@ -847,24 +876,16 @@ public function test_render_custom_payment_meta_input_multiple_tokens() { $subscription->add_payment_token( $token ); } - $this->expectOutputString( - '' - ); - + ob_start(); $this->wcpay_gateway->render_custom_payment_meta_input( $subscription, 'field_id', '' ); + $output = ob_get_clean(); + + // Check for the wrapper span and data attributes. + $this->assertStringContainsString( 'class="wcpay-subscription-payment-method"', $output ); + // Check that all tokens are present as options. + foreach ( $tokens as $token ) { + $this->assertStringContainsString( 'value="' . $token->get_id() . '"', $output ); + } } @@ -877,15 +898,40 @@ public function test_render_custom_payment_meta_input_empty_value() { $subscription->add_payment_token( $tokens[0] ); $subscription->add_payment_token( $tokens[1] ); - $this->expectOutputString( - '' - ); + ob_start(); + $this->wcpay_gateway->render_custom_payment_meta_input( $subscription, 'field_id', '' ); + $output = ob_get_clean(); + + // Check for the wrapper span and data attributes. + $this->assertStringContainsString( 'class="wcpay-subscription-payment-method"', $output ); + // Check that both tokens are present as options. + $this->assertStringContainsString( 'value="' . $tokens[0]->get_id() . '"', $output ); + $this->assertStringContainsString( 'value="' . $tokens[1]->get_id() . '"', $output ); + } + + public function test_render_custom_payment_meta_input_no_customer() { + $subscription = WC_Helper_Order::create_order( 0 ); // User ID 0 means no customer. + ob_start(); $this->wcpay_gateway->render_custom_payment_meta_input( $subscription, 'field_id', '' ); + $output = ob_get_clean(); + + // Check that the disabled message is shown. + $this->assertStringContainsString( 'Please select a customer first', $output ); + $this->assertStringContainsString( 'disabled', $output ); + } + + public function test_render_custom_payment_meta_input_no_payment_methods() { + $subscription = WC_Helper_Order::create_order( self::USER_ID ); + // Don't add any payment tokens. + + ob_start(); + $this->wcpay_gateway->render_custom_payment_meta_input( $subscription, 'field_id', '' ); + $output = ob_get_clean(); + + // Check that the disabled message is shown when customer has no payment methods. + $this->assertStringContainsString( 'No payment methods found for customer', $output ); + $this->assertStringContainsString( 'disabled', $output ); } public function test_adds_custom_payment_meta_input_using_filter() { @@ -896,11 +942,9 @@ public function test_adds_custom_payment_meta_input_using_filter() { $this->assertTrue( has_action( 'woocommerce_subscription_payment_meta_input_' . WC_Payment_Gateway_WCPay::GATEWAY_ID . '_wc_order_tokens_token' ) ); } - public function test_adds_custom_payment_meta_input_fallback_until_subs_3_0_7() { + public function test_adds_custom_payment_meta_input_for_all_versions() { remove_all_actions( 'woocommerce_admin_order_data_after_billing_address' ); - WC_Subscriptions::$version = '3.0.7'; - $mock_payment_method = $this->getMockBuilder( CC_Payment_Method::class ) ->setConstructorArgs( [ $this->mock_token_service ] ) ->onlyMethods( [ 'is_subscription_item_in_cart' ] ) @@ -932,34 +976,6 @@ public function test_adds_custom_payment_meta_input_fallback_until_subs_3_0_7() $this->assertTrue( has_action( 'woocommerce_admin_order_data_after_billing_address' ) ); } - public function test_does_not_add_custom_payment_meta_input_fallback_for_subs_3_0_8() { - remove_all_actions( 'woocommerce_admin_order_data_after_billing_address' ); - - $mock_payment_method = $this->getMockBuilder( CC_Payment_Method::class ) - ->setConstructorArgs( [ $this->mock_token_service ] ) - ->onlyMethods( [ 'is_subscription_item_in_cart' ] ) - ->getMock(); - - WC_Subscriptions::$version = '3.0.8'; - new \WC_Payment_Gateway_WCPay( - $this->mock_api_client, - $this->mock_wcpay_account, - $this->mock_customer_service, - $this->mock_token_service, - $this->mock_action_scheduler_service, - $mock_payment_method, - [ 'card' => $mock_payment_method ], - $this->order_service, - $this->mock_dpps, - $this->mock_localization_service, - $this->mock_fraud_service, - $this->mock_duplicates_detection_service, - $this->mock_session_rate_limiter - ); - - $this->assertFalse( has_action( 'woocommerce_admin_order_data_after_billing_address' ) ); - } - public function test_add_payment_method_select_to_subscription_edit_when_subscription() { $subscription = WC_Helper_Order::create_order( self::USER_ID ); $this->mock_wcs_is_subscription( true ); @@ -1093,6 +1109,94 @@ public function test_update_subscription_token_not_wcpay() { $this->assertSame( $updated, false ); } + public function test_ajax_get_user_payment_tokens_success() { + $tokens = [ + WC_Helper_Token::create_token( self::PAYMENT_METHOD_ID . '_1', self::USER_ID ), + WC_Helper_Token::create_token( self::PAYMENT_METHOD_ID . '_2', self::USER_ID ), + ]; + + // Set up the AJAX request. + $_POST['user_id'] = self::USER_ID; + $_POST['nonce'] = wp_create_nonce( 'wcpay-subscription-edit' ); + $_REQUEST['nonce'] = $_POST['nonce']; + + // Mock the current user as admin. + wp_set_current_user( self::USER_ID ); + $user = wp_get_current_user(); + $user->add_cap( 'manage_woocommerce' ); + + // Prevent wp_die() from terminating the test. + add_filter( 'wp_doing_ajax', '__return_true' ); + add_filter( 'wp_die_ajax_handler', [ $this, 'get_ajax_wp_die_handler' ] ); + + // Capture the JSON output. + ob_start(); + $this->wcpay_gateway->ajax_get_user_payment_tokens(); + $output = ob_get_clean(); + + remove_filter( 'wp_doing_ajax', '__return_true' ); + remove_filter( 'wp_die_ajax_handler', [ $this, 'get_ajax_wp_die_handler' ] ); + + $response = json_decode( $output, true ); + + $this->assertTrue( $response['success'] ); + $this->assertIsArray( $response['data']['tokens'] ); + $this->assertCount( 2, $response['data']['tokens'] ); + } + + public function test_ajax_get_user_payment_tokens_no_user() { + $_POST['user_id'] = 0; + $_POST['nonce'] = wp_create_nonce( 'wcpay-subscription-edit' ); + $_REQUEST['nonce'] = $_POST['nonce']; + + wp_set_current_user( self::USER_ID ); + $user = wp_get_current_user(); + $user->add_cap( 'manage_woocommerce' ); + + // Prevent wp_die() from terminating the test. + add_filter( 'wp_doing_ajax', '__return_true' ); + add_filter( 'wp_die_ajax_handler', [ $this, 'get_ajax_wp_die_handler' ] ); + + ob_start(); + $this->wcpay_gateway->ajax_get_user_payment_tokens(); + $output = ob_get_clean(); + + remove_filter( 'wp_doing_ajax', '__return_true' ); + remove_filter( 'wp_die_ajax_handler', [ $this, 'get_ajax_wp_die_handler' ] ); + + $response = json_decode( $output, true ); + + $this->assertTrue( $response['success'] ); + $this->assertIsArray( $response['data']['tokens'] ); + $this->assertCount( 0, $response['data']['tokens'] ); + } + + public function test_ajax_get_user_payment_tokens_invalid_user() { + $_POST['user_id'] = 99999; // Non-existent user. + $_POST['nonce'] = wp_create_nonce( 'wcpay-subscription-edit' ); + $_REQUEST['nonce'] = $_POST['nonce']; + + wp_set_current_user( self::USER_ID ); + $user = wp_get_current_user(); + $user->add_cap( 'manage_woocommerce' ); + + // Prevent wp_die() from terminating the test. + add_filter( 'wp_doing_ajax', '__return_true' ); + add_filter( 'wp_die_ajax_handler', [ $this, 'get_ajax_wp_die_handler' ] ); + + ob_start(); + $this->wcpay_gateway->ajax_get_user_payment_tokens(); + $output = ob_get_clean(); + + remove_filter( 'wp_doing_ajax', '__return_true' ); + remove_filter( 'wp_die_ajax_handler', [ $this, 'get_ajax_wp_die_handler' ] ); + + $response = json_decode( $output, true ); + + $this->assertFalse( $response['success'] ); + $this->assertStringContainsString( 'Invalid user ID', $response['data']['message'] ); + } + private function mock_wcs_get_subscriptions_for_order( $subscriptions ) { WC_Subscriptions::set_wcs_get_subscriptions_for_order( function ( $order ) use ( $subscriptions ) { diff --git a/tests/unit/test-class-wc-payment-gateway-wcpay.php b/tests/unit/test-class-wc-payment-gateway-wcpay.php index 57ae6c2f995..3ecdd72d9ce 100644 --- a/tests/unit/test-class-wc-payment-gateway-wcpay.php +++ b/tests/unit/test-class-wc-payment-gateway-wcpay.php @@ -39,7 +39,6 @@ use WCPay\WooPay\WooPay_Utilities; use WCPay\Session_Rate_Limiter; use WCPay\PaymentMethods\Configs\Registry\PaymentMethodDefinitionRegistry; -use WC_Subscriptions; // Need to use WC_Mock_Data_Store. require_once __DIR__ . '/helpers/class-wc-mock-wc-data-store.php'; diff --git a/webpack/shared.js b/webpack/shared.js index 7d8288adcad..f7f79f6ce93 100644 --- a/webpack/shared.js +++ b/webpack/shared.js @@ -20,7 +20,7 @@ module.exports = { cart: './client/cart/index.js', checkout: './client/checkout/classic/event-handlers.js', 'express-checkout': './client/express-checkout/index.js', - 'subscription-edit-page': './client/subscription-edit-page.js', + 'subscription-edit-page': './client/subscription-edit-page/index.tsx', tos: './client/tos/index.tsx', 'multi-currency': './includes/multi-currency/client/index.js', 'multi-currency-switcher-block':