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..f2e56e90193 --- /dev/null +++ b/client/subscription-edit-page/__tests__/index.test.tsx @@ -0,0 +1,545 @@ +/** @format */ +/** + * External dependencies + */ +import { render, screen, waitFor, act } from '@testing-library/react'; +import React from 'react'; + +// Mock jQuery before importing the component +const mockOn = jest.fn(); +const mockOff = jest.fn(); +const mockJQuery = jest.fn( () => ( { on: mockOn, off: mockOff } ) ); +( global as any ).jQuery = mockJQuery; + +// Mock @wordpress/i18n +jest.mock( '@wordpress/i18n', () => ( { + // eslint-disable-next-line @typescript-eslint/naming-convention + __: ( text: string ) => text, +} ) ); + +/** + * Internal dependencies + */ +import { + PaymentMethodSelect, + fetchUserTokens, + clearTokenCache, +} from '../index'; +import type { Token } from '../types'; + +describe( 'PaymentMethodSelect Component', () => { + const mockTokens: Token[] = [ + { tokenId: 1, displayName: 'Visa •••• 1234', isDefault: true }, + { tokenId: 2, displayName: 'Mastercard •••• 5678', isDefault: false }, + { tokenId: 3, displayName: 'Amex •••• 9012', isDefault: false }, + ]; + + const tokensWithoutDefault: Token[] = [ + { tokenId: 1, displayName: 'Visa •••• 1234', isDefault: false }, + { tokenId: 2, displayName: 'Mastercard •••• 5678', isDefault: false }, + ]; + + beforeEach( () => { + jest.clearAllMocks(); + clearTokenCache(); + document.body.innerHTML = ''; + } ); + + describe( 'Initial Rendering States', () => { + test( 'renders "please select customer" message when userId is 0', () => { + render( + + ); + + expect( + screen.getByText( 'Please select a customer first' ) + ).toBeInTheDocument(); + } ); + + test( 'renders "please select customer" message when userId is negative', () => { + render( + + ); + + expect( + screen.getByText( 'Please select a customer first' ) + ).toBeInTheDocument(); + } ); + + test( 'renders placeholder when userId > 0 but cache is empty', () => { + // When userId > 0 but cache has no tokens, component shows + // empty select with placeholder (not loading state) + // because the component only fetches on customer select change + render( + + ); + + expect( + screen.getByText( 'Please select a payment method' ) + ).toBeInTheDocument(); + } ); + } ); + + describe( 'Loading State', () => { + test( 'shows loading when customer select change triggers fetch', async () => { + // Mock fetch to never resolve during initial check + global.fetch = jest.fn( + () => + new Promise( () => { + // Never resolves - simulates slow network + } ) + ); + + document.body.innerHTML = + ''; + + render( + + ); + + // Initially shows "select customer" since userId is 0 + expect( + screen.getByText( 'Please select a customer first' ) + ).toBeInTheDocument(); + + // Trigger customer selection change + const select = document.getElementById( + 'customer_user' + ) as HTMLSelectElement; + select.value = '1'; + + await act( async () => { + select.dispatchEvent( new Event( 'change' ) ); + } ); + + // Now should show loading (fetch is pending) + expect( screen.getByText( 'Loading…' ) ).toBeInTheDocument(); + } ); + + test( 'shows tokens after fetch resolves', async () => { + global.fetch = jest.fn().mockResolvedValue( { + ok: true, + json: () => Promise.resolve( { data: { tokens: mockTokens } } ), + } ); + + document.body.innerHTML = + ''; + + render( + + ); + + // Trigger customer selection change + const select = document.getElementById( + 'customer_user' + ) as HTMLSelectElement; + select.value = '1'; + + await act( async () => { + select.dispatchEvent( new Event( 'change' ) ); + } ); + + // Should show tokens after fetch resolves + await waitFor( () => { + expect( + screen.getByText( 'Visa •••• 1234' ) + ).toBeInTheDocument(); + } ); + } ); + } ); + + describe( 'Error State', () => { + test( 'shows error message when fetch fails', async () => { + global.fetch = jest.fn().mockResolvedValue( { + ok: false, + } ); + + document.body.innerHTML = + ''; + + render( + + ); + + // Trigger customer selection change + const select = document.getElementById( + 'customer_user' + ) as HTMLSelectElement; + select.value = '1'; + + await act( async () => { + select.dispatchEvent( new Event( 'change' ) ); + } ); + + await waitFor( () => { + expect( + screen.getByText( 'Failed to fetch user tokens' ) + ).toBeInTheDocument(); + } ); + } ); + } ); + + describe( 'Token Rendering', () => { + test( 'renders tokens after customer select change', async () => { + global.fetch = jest.fn().mockResolvedValue( { + ok: true, + json: () => Promise.resolve( { data: { tokens: mockTokens } } ), + } ); + + document.body.innerHTML = + ''; + + render( + + ); + + // Trigger customer selection change + const select = document.getElementById( + 'customer_user' + ) as HTMLSelectElement; + select.value = '1'; + + await act( async () => { + select.dispatchEvent( new Event( 'change' ) ); + } ); + + await waitFor( () => { + expect( + screen.getByText( 'Visa •••• 1234' ) + ).toBeInTheDocument(); + } ); + + expect( + screen.getByText( 'Mastercard •••• 5678' ) + ).toBeInTheDocument(); + expect( screen.getByText( 'Amex •••• 9012' ) ).toBeInTheDocument(); + } ); + + test( 'auto-selects default token after fetch', async () => { + global.fetch = jest.fn().mockResolvedValue( { + ok: true, + json: () => Promise.resolve( { data: { tokens: mockTokens } } ), + } ); + + document.body.innerHTML = + ''; + + render( + + ); + + // Trigger customer selection change + const customerSelect = document.getElementById( + 'customer_user' + ) as HTMLSelectElement; + customerSelect.value = '1'; + + await act( async () => { + customerSelect.dispatchEvent( new Event( 'change' ) ); + } ); + + await waitFor( () => { + // Use getAllByRole since there are two comboboxes (customer_user and payment_method) + const selects = screen.getAllByRole( 'combobox' ); + const paymentSelect = selects.find( + ( s ) => s.getAttribute( 'name' ) === 'payment_method' + ) as HTMLSelectElement; + // Default token (tokenId: 1) should be auto-selected + expect( paymentSelect.value ).toBe( '1' ); + } ); + + // Placeholder should not be shown + expect( + screen.queryByText( 'Please select a payment method' ) + ).not.toBeInTheDocument(); + } ); + + test( 'shows placeholder when no default token', async () => { + global.fetch = jest.fn().mockResolvedValue( { + ok: true, + json: () => + Promise.resolve( { + data: { tokens: tokensWithoutDefault }, + } ), + } ); + + document.body.innerHTML = + ''; + + render( + + ); + + // Trigger customer selection change + const customerSelect = document.getElementById( + 'customer_user' + ) as HTMLSelectElement; + customerSelect.value = '1'; + + await act( async () => { + customerSelect.dispatchEvent( new Event( 'change' ) ); + } ); + + await waitFor( () => { + expect( + screen.getByText( 'Please select a payment method' ) + ).toBeInTheDocument(); + } ); + } ); + + test( 'placeholder option is disabled', async () => { + global.fetch = jest.fn().mockResolvedValue( { + ok: true, + json: () => + Promise.resolve( { + data: { tokens: tokensWithoutDefault }, + } ), + } ); + + document.body.innerHTML = + ''; + + render( + + ); + + // Trigger customer selection change + const customerSelect = document.getElementById( + 'customer_user' + ) as HTMLSelectElement; + customerSelect.value = '1'; + + await act( async () => { + customerSelect.dispatchEvent( new Event( 'change' ) ); + } ); + + await waitFor( () => { + const placeholderOption = screen.getByText( + 'Please select a payment method' + ) as HTMLOptionElement; + expect( placeholderOption ).toHaveAttribute( 'disabled' ); + expect( placeholderOption ).toHaveAttribute( 'value', '0' ); + } ); + } ); + + test( 'uses initial value when provided', () => { + // When initialValue is provided and cache is empty, + // the component renders with the initial value + render( + + ); + + const select = screen.getByRole( 'combobox' ) as HTMLSelectElement; + // Since cache is empty, select will have no options but defaultValue is 2 + expect( select ).toHaveAttribute( 'name', 'payment_method' ); + } ); + } ); + + describe( 'Cache Behavior', () => { + test( 'does not fetch again when cache already has tokens', async () => { + const fetchMock = jest.fn().mockResolvedValue( { + ok: true, + json: () => Promise.resolve( { data: { tokens: mockTokens } } ), + } ); + global.fetch = fetchMock; + + document.body.innerHTML = + ''; + + render( + + ); + + // First customer selection - should fetch + const customerSelect = document.getElementById( + 'customer_user' + ) as HTMLSelectElement; + customerSelect.value = '1'; + + await act( async () => { + customerSelect.dispatchEvent( new Event( 'change' ) ); + } ); + + await waitFor( () => { + expect( + screen.getByText( 'Visa •••• 1234' ) + ).toBeInTheDocument(); + } ); + + expect( fetchMock ).toHaveBeenCalledTimes( 1 ); + + // Change to a different user to reset state + customerSelect.value = '2'; + + await act( async () => { + customerSelect.dispatchEvent( new Event( 'change' ) ); + } ); + + // This should fetch for user 2 + await waitFor( () => { + expect( fetchMock ).toHaveBeenCalledTimes( 2 ); + } ); + + // Change back to user 1 - should NOT fetch again (cached) + customerSelect.value = '1'; + + await act( async () => { + customerSelect.dispatchEvent( new Event( 'change' ) ); + } ); + + // Tokens should be available immediately from cache + expect( screen.getByText( 'Visa •••• 1234' ) ).toBeInTheDocument(); + + // Fetch should not have been called again + expect( fetchMock ).toHaveBeenCalledTimes( 2 ); + } ); + } ); +} ); + +describe( 'fetchUserTokens', () => { + const originalFetch = global.fetch; + + afterEach( () => { + global.fetch = originalFetch; + } ); + + test( 'sends correct request parameters', async () => { + let capturedUrl = ''; + let capturedOptions: RequestInit | undefined; + + global.fetch = jest.fn().mockImplementation( ( url, options ) => { + capturedUrl = url; + capturedOptions = options; + return Promise.resolve( { + ok: true, + json: () => Promise.resolve( { data: { tokens: [] } } ), + } ); + } ); + + await fetchUserTokens( 123, 'http://test.com/ajax', 'test-nonce' ); + + expect( capturedUrl ).toBe( 'http://test.com/ajax' ); + expect( capturedOptions?.method ).toBe( 'POST' ); + + const formData = capturedOptions?.body as FormData; + expect( formData.get( 'action' ) ).toBe( + 'wcpay_get_user_payment_tokens' + ); + expect( formData.get( 'nonce' ) ).toBe( 'test-nonce' ); + expect( formData.get( 'user_id' ) ).toBe( '123' ); + } ); + + test( 'returns tokens on successful response', async () => { + const mockTokens: Token[] = [ + { tokenId: 1, displayName: 'Visa •••• 1234', isDefault: true }, + ]; + + global.fetch = jest.fn().mockResolvedValue( { + ok: true, + json: () => Promise.resolve( { data: { tokens: mockTokens } } ), + } ); + + const result = await fetchUserTokens( + 1, + 'http://test.com/ajax', + 'nonce' + ); + + expect( result ).toEqual( mockTokens ); + } ); + + test( 'throws error when response is not ok', async () => { + global.fetch = jest.fn().mockResolvedValue( { + ok: false, + } ); + + await expect( + fetchUserTokens( 1, 'http://test.com/ajax', 'nonce' ) + ).rejects.toThrow( 'Failed to fetch user tokens' ); + } ); + + test( 'throws error when response data is undefined', async () => { + global.fetch = jest.fn().mockResolvedValue( { + ok: true, + json: () => Promise.resolve( {} ), + } ); + + await expect( + fetchUserTokens( 1, 'http://test.com/ajax', 'nonce' ) + ).rejects.toThrow( + 'Failed to fetch user tokens. Please reload the page and try again.' + ); + } ); +} ); diff --git a/client/subscription-edit-page/index.tsx b/client/subscription-edit-page/index.tsx new file mode 100644 index 00000000000..437113e7e52 --- /dev/null +++ b/client/subscription-edit-page/index.tsx @@ -0,0 +1,279 @@ +/* global jQuery */ + +/** + * External dependencies + */ +import React, { useState, useEffect } from 'react'; +import { createRoot } from 'react-dom/client'; +import { __ } from '@wordpress/i18n'; + +/** + * Internal dependencies + */ +import type { + PaymentMethodSelectProps, + WCPayPMSelectorData, + Token, +} from './types'; + +/** + * Cache for all tokens, may be shared between multiple selects. + */ +const cachedTokens = new Map< number, Token[] >(); + +/** + * Clears the token cache. Exported for testing purposes. + */ +export const clearTokenCache = (): void => { + cachedTokens.clear(); +}; + +/** + * Fetch the tokens for a user from the back-end. + * + * @param {number} userId The user ID. + * @param {string} ajaxUrl The AJAX URL. + * @param {string} nonce The nonce. + * @return {Promise} The tokens for the user. + * @throws {Error} If the tokens cannot be fetched or the response is invalid. + */ +export const fetchUserTokens = async ( + userId: number, + ajaxUrl: string, + nonce: string +): Promise< Token[] > => { + const formData = new FormData(); + formData.append( 'action', 'wcpay_get_user_payment_tokens' ); + formData.append( 'nonce', nonce ); + formData.append( 'user_id', userId.toString() ); + + const response = await fetch( ajaxUrl, { + method: 'POST', + body: formData, + } ); + if ( ! response.ok ) { + throw new Error( + __( 'Failed to fetch user tokens', 'woocommerce-payments' ) + ); + } + + const result = await response.json(); + const data = result.data as { tokens: Token[] }; + + if ( undefined === data ) { + throw new Error( + __( + 'Failed to fetch user tokens. Please reload the page and try again.', + 'woocommerce-payments' + ) + ); + } + + return data.tokens; +}; + +/** + * 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. + * + * @return {() => void} The cleanup function. + */ +export const addCustomerSelectListener = ( + callback: ( userId: number ) => void +): ( () => void ) => { + const element = document.getElementById( 'customer_user' ); + const customerUserSelect = + element instanceof HTMLSelectElement ? element : null; + + if ( ! customerUserSelect ) { + return (): void => { + // No-op cleanup function when an element is not found. + }; + } + + // Wrap in an internal callback to load the select's value and tokens. + const internalCallback = async () => { + callback( parseInt( customerUserSelect.value, 10 ) || 0 ); + }; + + // Add the listener with the right technique, as select2 does not emit + + + ); + } + + if ( isLoading ) { + return <>{ __( 'Loading…', 'woocommerce-payments' ) }; + } + + if ( loadingError ) { + return { loadingError }; + } + + return ( + // eslint-disable-next-line + + ); +}; + +/** + * Setup the payment method select for a given element. + * + * @param {HTMLSpanElement} element The where the payment method select should be rendered. + */ +const setupPaymentSelector = ( element: HTMLSpanElement ): 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. + const userId = data.userId ?? 0; + const value = data.value ?? 0; + + if ( userId ) { + // Initial cache population. + cachedTokens.set( userId, data.tokens ?? [] ); + } + + // In older Subscriptions versions, there was just a simple input. + const input = element.querySelector( 'select,input' ); + if ( + ! input || + ! ( + input instanceof HTMLSelectElement || + input instanceof HTMLInputElement + ) + ) { + return; + } + + const root = createRoot( element ); + root.render( + + ); +}; + +/** + * Initializes all payment method dropdown elements on the page. + * + * @return {void} + */ +const addPaymentMethodDropdowns = (): void => { + document + .querySelectorAll< HTMLSpanElement >( + '.wcpay-subscription-payment-method' + ) + .forEach( ( element ) => { + setupPaymentSelector( element ); + } ); +}; + +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..26958909f37 --- /dev/null +++ b/client/subscription-edit-page/types.d.ts @@ -0,0 +1,42 @@ +/** + * Minimal jQuery type declaration for select2 event handling. + */ +declare global { + const jQuery: ( + selector: HTMLElement + ) => { + on: ( event: string, handler: () => void ) => void; + off: ( event: string, handler: () => void ) => void; + }; +} + +/** + * Token represents a payment method token for a user + */ +export interface Token { + tokenId: number; + displayName: string; + isDefault: boolean; +} + +/** + * Props for the PaymentMethodSelect component + */ +export interface PaymentMethodSelectProps { + inputName: string; + initialValue: number; + initialUserId: number; + nonce: string; + ajaxUrl: string; +} + +/** + * Data structure from the wcpayPmSelector dataset attribute + */ +export interface WCPayPMSelectorData { + value: number; + userId: number; + tokens: Token[]; + ajaxUrl: string; + nonce: string; +} 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 f7b2133e1e9..8cbd5adb5d9 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':