diff --git a/changelog/fix-6497-advanced-fraud-settings-empty b/changelog/fix-6497-advanced-fraud-settings-empty new file mode 100644 index 00000000000..98e38c0b807 --- /dev/null +++ b/changelog/fix-6497-advanced-fraud-settings-empty @@ -0,0 +1,4 @@ +Significance: patch +Type: fix + +Add notice when no rules are enabled in advanced fraud settings diff --git a/changelog/fix-8702-fix-refunds-view-report-link b/changelog/fix-8702-fix-refunds-view-report-link new file mode 100644 index 00000000000..9cc71e9ee48 --- /dev/null +++ b/changelog/fix-8702-fix-refunds-view-report-link @@ -0,0 +1,5 @@ +Significance: patch +Type: fix +Comment: The PR fixes the correct landing links on `View report` for the Refunds tile in Payment activity widget. Changes are behind a feature flag. + + diff --git a/changelog/fix-8705-charges-view-report b/changelog/fix-8705-charges-view-report new file mode 100644 index 00000000000..756e6912a52 --- /dev/null +++ b/changelog/fix-8705-charges-view-report @@ -0,0 +1,5 @@ +Significance: patch +Type: fix +Comment: Behind fPayment Activity Card feature flag, fix View Report link of Charges tile. + + diff --git a/changelog/fix-8732-qit-warning b/changelog/fix-8732-qit-warning new file mode 100644 index 00000000000..a82881258c8 --- /dev/null +++ b/changelog/fix-8732-qit-warning @@ -0,0 +1,5 @@ +Significance: patch +Type: fix +Comment: Fix QIT warning for discouraged function. + + diff --git a/changelog/fix-8742-merchant-timezone-payment-activity b/changelog/fix-8742-merchant-timezone-payment-activity new file mode 100644 index 00000000000..08dc0462916 --- /dev/null +++ b/changelog/fix-8742-merchant-timezone-payment-activity @@ -0,0 +1,5 @@ +Significance: patch +Type: dev +Comment: Adding some fixes to the Payment Activity component, reporting controller aand associated classes. + + diff --git a/changelog/fix-update-request-classes-docs b/changelog/fix-update-request-classes-docs new file mode 100644 index 00000000000..ea03df09449 --- /dev/null +++ b/changelog/fix-update-request-classes-docs @@ -0,0 +1,5 @@ +Significance: patch +Type: dev +Comment: Updated the parent link in request classes documentation + + diff --git a/changelog/terminal-payment-subscription-support b/changelog/terminal-payment-subscription-support new file mode 100644 index 00000000000..bc5408bb9d4 --- /dev/null +++ b/changelog/terminal-payment-subscription-support @@ -0,0 +1,4 @@ +Significance: minor +Type: add + +Support for starting auto-renewing subscriptions for In-Person Payments. diff --git a/client/components/payment-activity/payment-activity-data.tsx b/client/components/payment-activity/payment-activity-data.tsx index 788f678dce0..06b085a1589 100644 --- a/client/components/payment-activity/payment-activity-data.tsx +++ b/client/components/payment-activity/payment-activity-data.tsx @@ -43,6 +43,15 @@ const searchTermsForViewReportLink = { 'dispute_reversal', 'card_reader_fee', ], + + charge: [ 'charge', 'payment' ], + + refunds: [ + 'refund', + 'refund_failure', + 'payment_refund', + 'payment_failure_refund', + ], }; const getSearchParams = ( searchTerms: string[] ) => { @@ -56,9 +65,10 @@ const getSearchParams = ( searchTerms: string[] ) => { }; const PaymentActivityData: React.FC = () => { - const { paymentActivityData, isLoading } = usePaymentActivityData( - getDateRange() - ); + const { paymentActivityData, isLoading } = usePaymentActivityData( { + ...getDateRange(), + timezone: moment( new Date() ).format( 'Z' ), + } ); const totalPaymentVolume = paymentActivityData?.total_payment_volume ?? 0; const charges = paymentActivityData?.charges ?? 0; @@ -153,7 +163,15 @@ const PaymentActivityData: React.FC = () => { page: 'wc-admin', path: '/payments/transactions', filter: 'advanced', - type_is: 'charge', + 'date_between[0]': moment( + getDateRange().date_start + ).format( 'YYYY-MM-DD' ), + 'date_between[1]': moment( + getDateRange().date_end + ).format( 'YYYY-MM-DD' ), + ...getSearchParams( + searchTermsForViewReportLink.charge + ), } ) } tracksSource="charges" isLoading={ isLoading } @@ -167,13 +185,15 @@ const PaymentActivityData: React.FC = () => { page: 'wc-admin', path: '/payments/transactions', filter: 'advanced', - type_is: 'refund', 'date_between[0]': moment( getDateRange().date_start ).format( 'YYYY-MM-DD' ), 'date_between[1]': moment( getDateRange().date_end ).format( 'YYYY-MM-DD' ), + ...getSearchParams( + searchTermsForViewReportLink.refunds + ), } ) } tracksSource="refunds" isLoading={ isLoading } diff --git a/client/components/payment-activity/test/__snapshots__/index.test.tsx.snap b/client/components/payment-activity/test/__snapshots__/index.test.tsx.snap index a45a33735e0..8ffa02adb61 100644 --- a/client/components/payment-activity/test/__snapshots__/index.test.tsx.snap +++ b/client/components/payment-activity/test/__snapshots__/index.test.tsx.snap @@ -136,7 +136,7 @@ exports[`PaymentActivity component should render 1`] = `

View report @@ -165,7 +165,7 @@ exports[`PaymentActivity component should render 1`] = `

View report diff --git a/client/data/payment-activity/test/hooks.test.ts b/client/data/payment-activity/test/hooks.test.ts index 03d1d2834d7..4cd6114f69a 100644 --- a/client/data/payment-activity/test/hooks.test.ts +++ b/client/data/payment-activity/test/hooks.test.ts @@ -36,6 +36,7 @@ describe( 'usePaymentActivityData', () => { const result = usePaymentActivityData( { date_start: '2021-01-01', date_end: '2021-01-31', + timezone: 'UTC', } ); expect( result ).toEqual( { diff --git a/client/data/payment-activity/test/resolver.test.ts b/client/data/payment-activity/test/resolver.test.ts index 8142b49b7c9..a9a93977180 100644 --- a/client/data/payment-activity/test/resolver.test.ts +++ b/client/data/payment-activity/test/resolver.test.ts @@ -15,12 +15,13 @@ import { getPaymentActivityData } from '../resolvers'; const query = { date_start: '2020-04-29T04:00:00', date_end: '2020-04-29T03:59:59', + timezone: '+2:30', }; describe( 'getPaymentActivityData resolver', () => { const successfulResponse: any = { amount: 3000 }; const expectedQueryString = - 'date_start=2020-04-29T04%3A00%3A00&date_end=2020-04-29T03%3A59%3A59'; + 'date_start=2020-04-29T04%3A00%3A00&date_end=2020-04-29T03%3A59%3A59&timezone=%2B2%3A30'; const errorResponse = new Error( 'Error retrieving payment activity data.' ); diff --git a/client/data/payment-activity/types.d.ts b/client/data/payment-activity/types.d.ts index ef388d2b899..76690dc459c 100644 --- a/client/data/payment-activity/types.d.ts +++ b/client/data/payment-activity/types.d.ts @@ -39,5 +39,5 @@ export interface PaymentActivityQuery { /** The date range end datetime used to calculate transaction data, e.g. 2024-04-29T16:19:29 */ date_end: string; /** The timezone used to calculate the transaction data date range, e.g. 'UTC' */ - timezone?: string; + timezone: string; } diff --git a/client/settings/fraud-protection/advanced-settings/index.tsx b/client/settings/fraud-protection/advanced-settings/index.tsx index 3c910898159..f18b70a624d 100644 --- a/client/settings/fraud-protection/advanced-settings/index.tsx +++ b/client/settings/fraud-protection/advanced-settings/index.tsx @@ -70,20 +70,28 @@ const observerEventMapping: Record< string, string > = { }; const Breadcrumb = () => ( -

- - { 'WooPayments' } - -  >  - { __( 'Advanced fraud protection', 'woocommerce-payments' ) } -

+ <> +

+ + { 'WooPayments' } + +  >  + { __( 'Advanced fraud protection', 'woocommerce-payments' ) } +

+

+ { __( + 'At least one risk filter needs to be enabled for advanced protection.', + 'woocommerce-payments' + ) } +

+ ); const SaveFraudProtectionSettingsButton: React.FC = ( { children } ) => { @@ -154,42 +162,66 @@ const FraudProtectionAdvancedSettingsPage: React.FC = () => { .every( Boolean ); }; + const checkAnyRuleFilterEnabled = ( + settings: ProtectionSettingsUI + ): boolean => { + return Object.values( settings ).some( ( setting ) => setting.enabled ); + }; + const handleSaveSettings = () => { - if ( validateSettings( protectionSettingsUI ) ) { - if ( ProtectionLevel.ADVANCED !== currentProtectionLevel ) { - updateProtectionLevel( ProtectionLevel.ADVANCED ); - dispatch( 'core/notices' ).createSuccessNotice( + if ( ! validateSettings( protectionSettingsUI ) ) { + window.scrollTo( { + top: 0, + } ); + return; + } + + if ( ! checkAnyRuleFilterEnabled( protectionSettingsUI ) ) { + if ( ProtectionLevel.BASIC === currentProtectionLevel ) { + dispatch( 'core/notices' ).createErrorNotice( __( - 'Current protection level is set to "advanced".', + 'At least one risk filter needs to be enabled for advanced protection.', 'woocommerce-payments' ) ); + return; } - const settings = writeRuleset( protectionSettingsUI ); + updateProtectionLevel( ProtectionLevel.BASIC ); + dispatch( 'core/notices' ).createErrorNotice( + __( + 'Current protection level is set to "basic". At least one risk filter needs to be enabled for advanced protection.', + 'woocommerce-payments' + ) + ); + } else if ( ProtectionLevel.ADVANCED !== currentProtectionLevel ) { + updateProtectionLevel( ProtectionLevel.ADVANCED ); + dispatch( 'core/notices' ).createSuccessNotice( + __( + 'Current protection level is set to "advanced".', + 'woocommerce-payments' + ) + ); + } - // Persist the AVS verification setting until the account cache is updated locally. - if ( - wcpaySettings?.accountStatus?.fraudProtection - ?.declineOnAVSFailure - ) { - wcpaySettings.accountStatus.fraudProtection.declineOnAVSFailure = settings.some( - ( setting ) => setting.key === 'avs_verification' - ); - } + const settings = writeRuleset( protectionSettingsUI ); - updateAdvancedFraudProtectionSettings( settings ); + // Persist the AVS verification setting until the account cache is updated locally. + if ( + wcpaySettings?.accountStatus?.fraudProtection?.declineOnAVSFailure + ) { + wcpaySettings.accountStatus.fraudProtection.declineOnAVSFailure = settings.some( + ( setting ) => setting.key === 'avs_verification' + ); + } - saveSettings(); + updateAdvancedFraudProtectionSettings( settings ); - recordEvent( 'wcpay_fraud_protection_advanced_settings_saved', { - settings: JSON.stringify( settings ), - } ); - } else { - window.scrollTo( { - top: 0, - } ); - } + saveSettings(); + + recordEvent( 'wcpay_fraud_protection_advanced_settings_saved', { + settings: JSON.stringify( settings ), + } ); }; // Hack to make "Payments > Settings" the active selected menu item. diff --git a/client/settings/fraud-protection/advanced-settings/test/__snapshots__/index.test.tsx.snap b/client/settings/fraud-protection/advanced-settings/test/__snapshots__/index.test.tsx.snap index abd09b5d4c7..dffbf33c6b1 100644 --- a/client/settings/fraud-protection/advanced-settings/test/__snapshots__/index.test.tsx.snap +++ b/client/settings/fraud-protection/advanced-settings/test/__snapshots__/index.test.tsx.snap @@ -66,6 +66,11 @@ Object {  >  Advanced fraud protection +

+ At least one risk filter needs to be enabled for advanced protection. +

@@ -1066,6 +1071,11 @@ Object {  >  Advanced fraud protection +

+ At least one risk filter needs to be enabled for advanced protection. +

@@ -2149,6 +2159,11 @@ Object {  >  Advanced fraud protection +

+ At least one risk filter needs to be enabled for advanced protection. +

@@ -3006,6 +3021,11 @@ Object {  >  Advanced fraud protection +

+ At least one risk filter needs to be enabled for advanced protection. +

@@ -3926,6 +3946,11 @@ Object {  >  Advanced fraud protection +

+ At least one risk filter needs to be enabled for advanced protection. +

+

+ At least one risk filter needs to be enabled for advanced protection. +

+

+ At least one risk filter needs to be enabled for advanced protection. +

+

+ At least one risk filter needs to be enabled for advanced protection. +

( { dispatch: jest.fn( () => ( { setIsMatching: jest.fn(), createSuccessNotice: jest.fn(), + createErrorNotice: jest.fn(), onLoad: jest.fn(), } ) ), registerStore: jest.fn(), @@ -438,4 +439,45 @@ describe( 'Advanced fraud protection settings', () => { expect( protectionLevelState.updateState.mock.calls.length ).toBe( 0 ); expect( protectionLevelState.updateState.mock.calls ).toEqual( [] ); } ); + test( 'does not update protection level to advanced when no risk rules are enabled', async () => { + const protectionLevelState = { + state: 'standard', + updateState: jest.fn( ( level ) => { + protectionLevelState.state = level; + } ), + }; + mockUseCurrentProtectionLevel.mockReturnValue( [ + protectionLevelState.state, + protectionLevelState.updateState, + ] ); + mockUseSettings.mockReturnValue( { + settings: { + advanced_fraud_protection_settings: defaultSettings, + }, + isSaving: false, + saveSettings: jest.fn(), + isLoading: false, + } ); + mockUseAdvancedFraudProtectionSettings.mockReturnValue( [ + defaultSettings, + jest.fn(), + ] ); + container = render( +
+
+
+
+ +
+ ); + const [ saveButton ] = await container.findAllByText( 'Save Changes' ); + saveButton.click(); + await waitFor( () => { + expect( mockUseSettings().saveSettings.mock.calls.length ).toBe( + 1 + ); + } ); + + expect( protectionLevelState.state ).toBe( 'basic' ); + } ); } ); diff --git a/client/settings/fraud-protection/style.scss b/client/settings/fraud-protection/style.scss index 70d75d9f0b5..1981d4b3559 100644 --- a/client/settings/fraud-protection/style.scss +++ b/client/settings/fraud-protection/style.scss @@ -43,7 +43,7 @@ } &-header-breadcrumb { margin-top: 0; - margin-bottom: 24px; + margin-bottom: 8px; @media screen and ( min-width: 961px ) { margin-top: -16px; } @@ -261,6 +261,10 @@ ); cursor: pointer; } + &-advanced-settings-notice { + margin-top: 0; + margin-bottom: 16px; + } } .components-modal__header { diff --git a/includes/admin/class-wc-rest-payments-orders-controller.php b/includes/admin/class-wc-rest-payments-orders-controller.php index 093004d37b2..10068abaf83 100644 --- a/includes/admin/class-wc-rest-payments-orders-controller.php +++ b/includes/admin/class-wc-rest-payments-orders-controller.php @@ -47,6 +47,13 @@ class WC_REST_Payments_Orders_Controller extends WC_Payments_REST_Controller { */ private $order_service; + /** + * WC_Payments_Token instance for working with customer tokens + * + * @var WC_Payments_Token_Service + */ + private $token_service; + /** * WC_Payments_REST_Controller constructor. * @@ -54,12 +61,14 @@ class WC_REST_Payments_Orders_Controller extends WC_Payments_REST_Controller { * @param WC_Payment_Gateway_WCPay $gateway WooCommerce Payments payment gateway. * @param WC_Payments_Customer_Service $customer_service Customer class instance. * @param WC_Payments_Order_Service $order_service Order Service class instance. + * @param WC_Payments_Token_Service $token_service Token Service class instance. */ - public function __construct( WC_Payments_API_Client $api_client, WC_Payment_Gateway_WCPay $gateway, WC_Payments_Customer_Service $customer_service, WC_Payments_Order_Service $order_service ) { + public function __construct( WC_Payments_API_Client $api_client, WC_Payment_Gateway_WCPay $gateway, WC_Payments_Customer_Service $customer_service, WC_Payments_Order_Service $order_service, WC_Payments_Token_Service $token_service ) { parent::__construct( $api_client ); $this->gateway = $gateway; $this->customer_service = $customer_service; $this->order_service = $order_service; + $this->token_service = $token_service; } /** @@ -217,6 +226,30 @@ public function capture_terminal_payment( WP_REST_Request $request ) { } // Store receipt generation URL for mobile applications in order meta-data. $order->add_meta_data( 'receipt_url', get_rest_url( null, sprintf( '%s/payments/readers/receipts/%s', $this->namespace, $intent->get_id() ) ) ); + + // Add payment method for future subscription payments. + $generated_card = $intent->get_charge()->get_payment_method_details()[ Payment_Method::CARD_PRESENT ]['generated_card'] ?? null; + // If we don't get a generated card, e.g. because a digital wallet was used, we can still return that the initial payment was successful. + // The subscription will not be activated and customers will need to provide a new payment method for renewals. + if ( $generated_card ) { + $has_subscriptions = function_exists( 'wcs_order_contains_subscription' ) && + function_exists( 'wcs_get_subscriptions_for_order' ) && + function_exists( 'wcs_is_manual_renewal_required' ) && + wcs_order_contains_subscription( $order_id ); + if ( $has_subscriptions ) { + $token = $this->token_service->add_payment_method_to_user( $generated_card, $order->get_user() ); + $this->gateway->add_token_to_order( $order, $token ); + foreach ( wcs_get_subscriptions_for_order( $order ) as $subscription ) { + $subscription->set_payment_method( WC_Payment_Gateway_WCPay::GATEWAY_ID ); + // Where the setting doesn't force manual renewals, we should turn them off, because we have an auto-renewal token now. + if ( ! wcs_is_manual_renewal_required() ) { + $subscription->set_requires_manual_renewal( false ); + } + $subscription->save(); + } + } + } + // Actualize order status. $this->order_service->mark_terminal_payment_completed( $order, $intent_id, $result['status'] ); @@ -307,15 +340,14 @@ public function capture_authorization( WP_REST_Request $request ) { /** * Returns customer id from order. Create or update customer if needed. - * Use-cases: It was used by older versions of our Mobile apps in their workflows. - * - * @deprecated 3.9.0 + * Use-cases: + * - It was used by older versions of our mobile apps to add the customer details to Payment Intents. + * - It is used by the apps to set customer details on Payment Intents for an order containing subscriptions. Required for capturing renewal payments off session. * * @param WP_REST_Request $request Full data about the request. * @return WP_REST_Response|WP_Error Response object on success, or WP_Error object on failure. */ public function create_customer( $request ) { - wc_deprecated_function( __FUNCTION__, '3.9.0' ); try { $order_id = $request['order_id']; diff --git a/includes/admin/class-wc-rest-woopay-session-controller.php b/includes/admin/class-wc-rest-woopay-session-controller.php index bc9caeb0763..e35f6e32f24 100644 --- a/includes/admin/class-wc-rest-woopay-session-controller.php +++ b/includes/admin/class-wc-rest-woopay-session-controller.php @@ -9,8 +9,6 @@ use WCPay\WooPay\WooPay_Session; use Automattic\Jetpack\Connection\Rest_Authentication; -use Automattic\WooCommerce\StoreApi\Utilities\JsonWebToken; -use WCPay\Exceptions\Rest_Request_Exception; use WCPay\Logger; /** @@ -56,13 +54,6 @@ public function register_routes() { */ public function get_session_data( WP_REST_Request $request ): WP_REST_Response { try { - $payload = $this->validated_cart_token_payload( $request->get_header( 'cart_token' ) ); - $user_id = (int) $payload->user_id ?? null; - - if ( is_int( $user_id ) && $user_id > 0 ) { - wp_set_current_user( $user_id ); - } - // phpcs:ignore /** * @psalm-suppress UndefinedClass @@ -70,10 +61,8 @@ public function get_session_data( WP_REST_Request $request ): WP_REST_Response { $response = WooPay_Session::get_init_session_request( null, null, null, $request ); return rest_ensure_response( $response ); - } catch ( Rest_Request_Exception $e ) { - $error_code = $e->getCode() === 400 ? 'rest_invalid_param' : 'wcpay_server_error'; - $error = new WP_Error( $error_code, $e->getMessage(), [ 'status' => $e->getCode() ] ); - + } catch ( Exception $e ) { + $error = new WP_Error( 'wcpay_server_error', $e->getMessage(), [ 'status' => 400 ] ); Logger::log( 'Error validating cart token from WooPay request: ' . $e->getMessage() ); return rest_convert_error_to_response( $error ); @@ -89,31 +78,6 @@ public function check_permission() { return $this->is_request_from_woopay() && $this->has_valid_request_signature(); } - /** - * Validates the cart token and returns its payload. - * - * @param string|null $cart_token The cart token to validate. - * - * @return object The validated cart token. - * - * @throws Rest_Request_Exception If the cart token is invalid, missing, or cannot be validated. - */ - public function validated_cart_token_payload( $cart_token ): object { - if ( ! $cart_token ) { - throw new Rest_Request_Exception( 'Missing cart token.', 400 ); - } - - if ( ! class_exists( JsonWebToken::class ) ) { - throw new Rest_Request_Exception( 'Cannot validate cart token.', 500 ); - } - - if ( ! JsonWebToken::validate( $cart_token, '@' . wp_salt() ) ) { - throw new Rest_Request_Exception( 'Invalid cart token.', 400 ); - } - - return JsonWebToken::get_parts( $cart_token )->payload; - } - /** * Returns true if the request that's currently being processed is signed with the blog token. * diff --git a/includes/class-wc-payments-token-service.php b/includes/class-wc-payments-token-service.php index 37fa25ced0e..6a8f9e21d0a 100644 --- a/includes/class-wc-payments-token-service.php +++ b/includes/class-wc-payments-token-service.php @@ -81,6 +81,14 @@ public function add_token_to_user( $payment_method, $user ) { $token->set_gateway_id( $gateway_id ); $token->set_email( $payment_method[ Payment_Method::LINK ]['email'] ); break; + case Payment_Method::CARD_PRESENT: + $token = new WC_Payment_Token_CC(); + $token->set_gateway_id( CC_Payment_Gateway::GATEWAY_ID ); + $token->set_expiry_month( $payment_method[ Payment_Method::CARD_PRESENT ]['exp_month'] ); + $token->set_expiry_year( $payment_method[ Payment_Method::CARD_PRESENT ]['exp_year'] ); + $token->set_card_type( strtolower( $payment_method[ Payment_Method::CARD_PRESENT ]['brand'] ) ); + $token->set_last4( $payment_method[ Payment_Method::CARD_PRESENT ]['last4'] ); + break; default: $token = new WC_Payment_Token_CC(); $token->set_gateway_id( CC_Payment_Gateway::GATEWAY_ID ); diff --git a/includes/class-wc-payments.php b/includes/class-wc-payments.php index 72c90d3a635..cc6637c30a3 100644 --- a/includes/class-wc-payments.php +++ b/includes/class-wc-payments.php @@ -992,7 +992,7 @@ public static function init_rest_api() { $conn_tokens_controller->register_routes(); include_once WCPAY_ABSPATH . 'includes/admin/class-wc-rest-payments-orders-controller.php'; - $orders_controller = new WC_REST_Payments_Orders_Controller( self::$api_client, self::get_gateway(), self::$customer_service, self::$order_service ); + $orders_controller = new WC_REST_Payments_Orders_Controller( self::$api_client, self::get_gateway(), self::$customer_service, self::$order_service, self::$token_service ); $orders_controller->register_routes(); include_once WCPAY_ABSPATH . 'includes/admin/class-wc-rest-payments-fraud-outcomes-controller.php'; diff --git a/includes/core/server/request/class-add-account-tos-agreement.md b/includes/core/server/request/class-add-account-tos-agreement.md index 5b97bee0993..b25dc68ede2 100644 --- a/includes/core/server/request/class-add-account-tos-agreement.md +++ b/includes/core/server/request/class-add-account-tos-agreement.md @@ -1,6 +1,6 @@ # `Add_Account_Tos_Agreement` request class -[ℹ️ This document is a part of __WooCommerce Payments Server Requests__](../requests.md) +[ℹ️ This document is a part of __WooCommerce Payments Server Requests__](../README.md) ## Description diff --git a/includes/core/server/request/class-cancel-intention.md b/includes/core/server/request/class-cancel-intention.md index 0f22a295fae..9c4c1e449e5 100644 --- a/includes/core/server/request/class-cancel-intention.md +++ b/includes/core/server/request/class-cancel-intention.md @@ -1,6 +1,6 @@ # `Cancel_Intention` request class -[ℹ️ This document is a part of __WooCommerce Payments Server Requests__](../requests.md) +[ℹ️ This document is a part of __WooCommerce Payments Server Requests__](../README.md) ## Description diff --git a/includes/core/server/request/class-capture-intention.md b/includes/core/server/request/class-capture-intention.md index 785b9012bd9..0b217e0cbe8 100644 --- a/includes/core/server/request/class-capture-intention.md +++ b/includes/core/server/request/class-capture-intention.md @@ -1,6 +1,6 @@ # `Capture_Intention` request class -[ℹ️ This document is a part of __WooCommerce Payments Server Requests__](../requests.md) +[ℹ️ This document is a part of __WooCommerce Payments Server Requests__](../README.md) ## Description diff --git a/includes/core/server/request/class-create-and-confirm-intention.md b/includes/core/server/request/class-create-and-confirm-intention.md index e6e2c390f00..809c2af99a6 100644 --- a/includes/core/server/request/class-create-and-confirm-intention.md +++ b/includes/core/server/request/class-create-and-confirm-intention.md @@ -1,6 +1,6 @@ # `Create_and_Confirm_Intention` request class -[ℹ️ This document is a part of __WooCommerce Payments Server Requests__](../requests.md) +[ℹ️ This document is a part of __WooCommerce Payments Server Requests__](../README.md) ## Description diff --git a/includes/core/server/request/class-create-and-confirm-setup-intention.md b/includes/core/server/request/class-create-and-confirm-setup-intention.md index e50bf1fd471..e1a4de75f80 100644 --- a/includes/core/server/request/class-create-and-confirm-setup-intention.md +++ b/includes/core/server/request/class-create-and-confirm-setup-intention.md @@ -1,6 +1,6 @@ # `Create_and_Confirm_Setup_Intention` request class -[ℹ️ This document is a part of __WooCommerce Payments Server Requests__](../requests.md) +[ℹ️ This document is a part of __WooCommerce Payments Server Requests__](../README.md) ## Description diff --git a/includes/core/server/request/class-create-intention.md b/includes/core/server/request/class-create-intention.md index 0ac4f7c397b..fc7c16fbf60 100644 --- a/includes/core/server/request/class-create-intention.md +++ b/includes/core/server/request/class-create-intention.md @@ -1,6 +1,6 @@ # `Create_Intention` request class -[ℹ️ This document is a part of __WooCommerce Payments Server Requests__](../requests.md) +[ℹ️ This document is a part of __WooCommerce Payments Server Requests__](../README.md) ## Description diff --git a/includes/core/server/request/class-create-setup-intention.md b/includes/core/server/request/class-create-setup-intention.md index 877ec791a65..a8aae0547ff 100644 --- a/includes/core/server/request/class-create-setup-intention.md +++ b/includes/core/server/request/class-create-setup-intention.md @@ -1,6 +1,6 @@ # `Create_Setup_Intention` request class -[ℹ️ This document is a part of __WooCommerce Payments Server Requests__](../requests.md) +[ℹ️ This document is a part of __WooCommerce Payments Server Requests__](../README.md) ## Description diff --git a/includes/core/server/request/class-get-account-capital-link.md b/includes/core/server/request/class-get-account-capital-link.md index 2360ba3a0a9..861fd5f6c30 100644 --- a/includes/core/server/request/class-get-account-capital-link.md +++ b/includes/core/server/request/class-get-account-capital-link.md @@ -1,6 +1,6 @@ # `Get_Account_Capital_Link` request class -[ℹ️ This document is a part of __WooCommerce Payments Server Requests__](../requests.md) +[ℹ️ This document is a part of __WooCommerce Payments Server Requests__](../README.md) ## Description diff --git a/includes/core/server/request/class-get-account-login-data.md b/includes/core/server/request/class-get-account-login-data.md index 3f3675207e4..d5e306138e7 100644 --- a/includes/core/server/request/class-get-account-login-data.md +++ b/includes/core/server/request/class-get-account-login-data.md @@ -1,6 +1,6 @@ # `Get_Account_Login_Data` request class -[ℹ️ This document is a part of __WooCommerce Payments Server Requests__](../requests.md) +[ℹ️ This document is a part of __WooCommerce Payments Server Requests__](../README.md) ## Description diff --git a/includes/core/server/request/class-get-account.md b/includes/core/server/request/class-get-account.md index 48606f63de9..ef329de1011 100644 --- a/includes/core/server/request/class-get-account.md +++ b/includes/core/server/request/class-get-account.md @@ -1,6 +1,6 @@ # `Get_Account` request class -[ℹ️ This document is a part of __WooCommerce Payments Server Requests__](../requests.md). +[ℹ️ This document is a part of __WooCommerce Payments Server Requests__](../README.md). ## Description diff --git a/includes/core/server/request/class-get-charge.md b/includes/core/server/request/class-get-charge.md index bc9173a41e1..adecfb612e2 100644 --- a/includes/core/server/request/class-get-charge.md +++ b/includes/core/server/request/class-get-charge.md @@ -1,6 +1,6 @@ # `Get_Charge` request class -[ℹ️ This document is a part of __WooCommerce Payments Server Requests__](../requests.md) +[ℹ️ This document is a part of __WooCommerce Payments Server Requests__](../README.md) ## Description diff --git a/includes/core/server/request/class-get-intention.md b/includes/core/server/request/class-get-intention.md index f7b71e8c091..90331d78c43 100644 --- a/includes/core/server/request/class-get-intention.md +++ b/includes/core/server/request/class-get-intention.md @@ -1,6 +1,6 @@ # `Get_Intention` request class -[ℹ️ This document is a part of __WooCommerce Payments Server Requests__](../requests.md) +[ℹ️ This document is a part of __WooCommerce Payments Server Requests__](../README.md) ## Description diff --git a/includes/core/server/request/class-get-reporting-payment-activity.md b/includes/core/server/request/class-get-reporting-payment-activity.md new file mode 100644 index 00000000000..53f676f6d59 --- /dev/null +++ b/includes/core/server/request/class-get-reporting-payment-activity.md @@ -0,0 +1,38 @@ +# `Get_Reporting_Payment_Activity` request class + +[ℹ️ This document is a part of __WooCommerce Payments Server Requests__](../README.md) + +## Description + +The `WCPay\Core\Server\Request\Get_Reporting_Payment_Activity` class is used to construct the request for retrieving payment activity. + +## Parameters + +| Parameter | Setter | Immutable | Required | Default value | +|-------------|-------------------------------------------|:---------:|:--------:|:-------------:| +| `date_start`| `set_date_start( string $date_start )` | No | Yes | - | +| `date_end` | `set_date_end( string $date_end )` | No | Yes | - | +| `timezone` | `set_timezone( string $timezone )` | No | Yes | - | + +The `date_start` and `date_end` parameters should be in the 'YYYY-MM-DDT00:00:00' format. +The `timezone` parameter can be passed as an offset or as a [timezone name](https://www.php.net/manual/en/timezones.php). + +## Filter + +When using this request, provide the following filter and arguments: + +- Name: `wcpay_get_payment_activity` + +## Example: + +```php +$request = Get_Reporting_Payment_Activity::create(); +$request->set_date_start( $date_start ); +$request->set_date_end( $date_end ); +$request->set_timezone( $timezone ); +$request->send(); +``` + +## Exceptions + +- `Invalid_Request_Parameter_Exception` - Thrown when the provided date or timezone is not in expected format. \ No newline at end of file diff --git a/includes/core/server/request/class-get-reporting-payment-activity.php b/includes/core/server/request/class-get-reporting-payment-activity.php index d9be6cf33eb..f8697a198e8 100644 --- a/includes/core/server/request/class-get-reporting-payment-activity.php +++ b/includes/core/server/request/class-get-reporting-payment-activity.php @@ -16,7 +16,6 @@ */ class Get_Reporting_Payment_Activity extends Request { - const REQUIRED_PARAMS = [ 'date_start', 'date_end', @@ -50,32 +49,52 @@ public function get_method(): string { /** * Sets the start date for the payment activity data. * - * @param string|null $date_start The start date in the format 'YYYY-MM-DDT00:00:00' or null. + * @param string $date_start The start date in the format 'YYYY-MM-DDT00:00:00'. * @return void + * + * @throws Invalid_Request_Parameter_Exception Exception if the date is not in valid format. */ - public function set_date_start( ?string $date_start ) { - // TBD - validation. + public function set_date_start( string $date_start ) { + $this->validate_date( $date_start, 'Y-m-d\TH:i:s' ); $this->set_param( 'date_start', $date_start ); } /** * Sets the end date for the payment activity data. * - * @param string|null $date_end The end date in the format 'YYYY-MM-DDT00:00:00' or null. + * @param string $date_end The end date in the format 'YYYY-MM-DDT00:00:00'. * @return void + * + * @throws Invalid_Request_Parameter_Exception Exception if the date is not in valid format. */ public function set_date_end( string $date_end ) { - // TBD - validation. + $this->validate_date( $date_end, 'Y-m-d\TH:i:s' ); $this->set_param( 'date_end', $date_end ); } /** * Sets the timezone for the reporting data. * - * @param string|null $timezone The timezone to set or null. + * @param string $timezone The timezone to set. * @return void + * + * @throws Invalid_Request_Parameter_Exception Exception if the timezone is not in valid format. */ - public function set_timezone( ?string $timezone ) { - $this->set_param( 'timezone', $timezone ?? 'UTC' ); + public function set_timezone( string $timezone ) { + try { + new \DateTimeZone( $timezone ); + } catch ( \Exception $e ) { + throw new Invalid_Request_Parameter_Exception( + esc_html( + sprintf( + // Translators: %s is a provided timezone. + __( '%s is not a valid timezone.', 'woocommerce-payments' ), + $timezone, + ) + ), + 'wcpay_core_invalid_request_parameter_invalid_timezone' + ); + } + $this->set_param( 'timezone', $timezone ); } } diff --git a/includes/core/server/request/class-get-request.md b/includes/core/server/request/class-get-request.md index b591cb98cba..b439ec2e612 100644 --- a/includes/core/server/request/class-get-request.md +++ b/includes/core/server/request/class-get-request.md @@ -1,7 +1,7 @@ # `Get_Request` request class -[ℹ️ This document is a part of __WooCommerce Payments Server Requests__](../requests.md) +[ℹ️ This document is a part of __WooCommerce Payments Server Requests__](../README.md) ## Description diff --git a/includes/core/server/request/class-get-setup-intention.md b/includes/core/server/request/class-get-setup-intention.md index 5b42c8ad4fe..ab3ed78e07e 100644 --- a/includes/core/server/request/class-get-setup-intention.md +++ b/includes/core/server/request/class-get-setup-intention.md @@ -1,6 +1,6 @@ # `Get_Setup_Intention` request class -[ℹ️ This document is a part of __WooCommerce Payments Server Requests__](../requests.md) +[ℹ️ This document is a part of __WooCommerce Payments Server Requests__](../README.md) ## Description diff --git a/includes/core/server/request/class-list-authorizations.md b/includes/core/server/request/class-list-authorizations.md index ba1b070ef65..5802476a3bd 100644 --- a/includes/core/server/request/class-list-authorizations.md +++ b/includes/core/server/request/class-list-authorizations.md @@ -1,6 +1,6 @@ # `List_Authorizations` request class -[ℹ️ This document is a part of __WooCommerce Payments Server Requests__](../requests.md) +[ℹ️ This document is a part of __WooCommerce Payments Server Requests__](../README.md) ## Description diff --git a/includes/core/server/request/class-list-charge-refunds.md b/includes/core/server/request/class-list-charge-refunds.md index 485f808d4af..4d9b73873a7 100644 --- a/includes/core/server/request/class-list-charge-refunds.md +++ b/includes/core/server/request/class-list-charge-refunds.md @@ -1,6 +1,6 @@ # `List_Charge_Refunds` request class -[ℹ️ This document is a part of __WooCommerce Payments Server Requests__](../requests.md) +[ℹ️ This document is a part of __WooCommerce Payments Server Requests__](../README.md) ## Description diff --git a/includes/core/server/request/class-list-deposits.md b/includes/core/server/request/class-list-deposits.md index 9e25eacc72c..e3560134f77 100644 --- a/includes/core/server/request/class-list-deposits.md +++ b/includes/core/server/request/class-list-deposits.md @@ -1,6 +1,6 @@ # `List_Deposits` request class -[ℹ️ This document is a part of __WooCommerce Payments Server Requests__](../requests.md) +[ℹ️ This document is a part of __WooCommerce Payments Server Requests__](../README.md) ## Description diff --git a/includes/core/server/request/class-list-disputes.md b/includes/core/server/request/class-list-disputes.md index 83e720fda2a..2b35a520e04 100644 --- a/includes/core/server/request/class-list-disputes.md +++ b/includes/core/server/request/class-list-disputes.md @@ -1,6 +1,6 @@ # `List_Disputes` request class -[ℹ️ This document is a part of __WooCommerce Payments Server Requests__](../requests.md) +[ℹ️ This document is a part of __WooCommerce Payments Server Requests__](../README.md) ## Description diff --git a/includes/core/server/request/class-list-documents.md b/includes/core/server/request/class-list-documents.md index 207a2eb00fd..17d06b911c3 100644 --- a/includes/core/server/request/class-list-documents.md +++ b/includes/core/server/request/class-list-documents.md @@ -1,6 +1,6 @@ # `List_Documents` request class -[ℹ️ This document is a part of __WooCommerce Payments Server Requests__](../requests.md) +[ℹ️ This document is a part of __WooCommerce Payments Server Requests__](../README.md) ## Description diff --git a/includes/core/server/request/class-list-fraud-outcome-transactions.md b/includes/core/server/request/class-list-fraud-outcome-transactions.md index 7e7b48d49f0..2c24e506414 100644 --- a/includes/core/server/request/class-list-fraud-outcome-transactions.md +++ b/includes/core/server/request/class-list-fraud-outcome-transactions.md @@ -1,6 +1,6 @@ # `List_Fraud_Outcome_Transactions` request class -[ℹ️ This document is a part of __WooCommerce Payments Server Requests__](../requests.md) +[ℹ️ This document is a part of __WooCommerce Payments Server Requests__](../README.md) ## Description diff --git a/includes/core/server/request/class-list-transactions.md b/includes/core/server/request/class-list-transactions.md index d791bcc322d..9c5bbbeaff2 100644 --- a/includes/core/server/request/class-list-transactions.md +++ b/includes/core/server/request/class-list-transactions.md @@ -1,6 +1,6 @@ # `List_Transactions` request class -[ℹ️ This document is a part of __WooCommerce Payments Server Requests__](../requests.md) +[ℹ️ This document is a part of __WooCommerce Payments Server Requests__](../README.md) ## Description diff --git a/includes/core/server/request/class-refund-charge.md b/includes/core/server/request/class-refund-charge.md index 7ea32059739..36610499e43 100644 --- a/includes/core/server/request/class-refund-charge.md +++ b/includes/core/server/request/class-refund-charge.md @@ -1,6 +1,6 @@ # `Refund_Charge` request class -[ℹ️ This document is a part of __WooCommerce Payments Server Requests__](../requests.md) +[ℹ️ This document is a part of __WooCommerce Payments Server Requests__](../README.md) ## Description diff --git a/includes/core/server/request/class-update-account.md b/includes/core/server/request/class-update-account.md index 759fed59df2..ac44ba13b62 100644 --- a/includes/core/server/request/class-update-account.md +++ b/includes/core/server/request/class-update-account.md @@ -1,6 +1,6 @@ # `Update_Account` request class -[ℹ️ This document is a part of __WooCommerce Payments Server Requests__](../requests.md) +[ℹ️ This document is a part of __WooCommerce Payments Server Requests__](../README.md) ## Description diff --git a/includes/core/server/request/class-update-intention.md b/includes/core/server/request/class-update-intention.md index cdc37f87aca..2a28b99e342 100644 --- a/includes/core/server/request/class-update-intention.md +++ b/includes/core/server/request/class-update-intention.md @@ -1,6 +1,6 @@ # `Update_Intention` request class -[ℹ️ This document is a part of __WooCommerce Payments Server Requests__](../requests.md) +[ℹ️ This document is a part of __WooCommerce Payments Server Requests__](../README.md) ## Description diff --git a/includes/core/server/request/class-woopay-create-and-confirm-intention.md b/includes/core/server/request/class-woopay-create-and-confirm-intention.md index 225cf21e01c..2b307abba5a 100644 --- a/includes/core/server/request/class-woopay-create-and-confirm-intention.md +++ b/includes/core/server/request/class-woopay-create-and-confirm-intention.md @@ -1,6 +1,6 @@ # `WooPay_Create_and_Confirm_Intention` request class -[ℹ️ This document is a part of __WooCommerce Payments Server Requests__](../requests.md) +[ℹ️ This document is a part of __WooCommerce Payments Server Requests__](../README.md) ## Description diff --git a/includes/core/server/request/class-woopay-create-and-confirm-setup-intention.md b/includes/core/server/request/class-woopay-create-and-confirm-setup-intention.md index 04904419e1a..0e0ca45cee9 100644 --- a/includes/core/server/request/class-woopay-create-and-confirm-setup-intention.md +++ b/includes/core/server/request/class-woopay-create-and-confirm-setup-intention.md @@ -1,6 +1,6 @@ # `WooPay_Create_and_Confirm_Setup_Intention` request class -[ℹ️ This document is a part of __WooCommerce Payments Server Requests__](../requests.md) +[ℹ️ This document is a part of __WooCommerce Payments Server Requests__](../README.md) ## Description diff --git a/includes/core/server/request/class-woopay-create-intent.md b/includes/core/server/request/class-woopay-create-intent.md index 0ae42ef421f..6e4d4314a44 100644 --- a/includes/core/server/request/class-woopay-create-intent.md +++ b/includes/core/server/request/class-woopay-create-intent.md @@ -1,6 +1,6 @@ # `WooPay_Create_Intent` request class -[ℹ️ This document is a part of __WooCommerce Payments Server Requests__](../requests.md) +[ℹ️ This document is a part of __WooCommerce Payments Server Requests__](../README.md) ## Description diff --git a/includes/woopay/class-woopay-session.php b/includes/woopay/class-woopay-session.php index 63559005498..7e04124c6cf 100644 --- a/includes/woopay/class-woopay-session.php +++ b/includes/woopay/class-woopay-session.php @@ -47,6 +47,9 @@ class WooPay_Session { '@^\/wc\/store(\/v[\d]+)?\/checkout\/(?P[\d]+)@', '@^\/wc\/store(\/v[\d]+)?\/checkout$@', '@^\/wc\/store(\/v[\d]+)?\/order\/(?P[\d]+)@', + // The route below is not a Store API route. However, this REST endpoint is used by WooPay to indirectly reach the Store API. + // By adding it to this list, we're able to identify the user and load the correct session for this route. + '@^\/wc\/v3\/woopay\/session$@', ]; /** diff --git a/psalm-baseline.xml b/psalm-baseline.xml index 17038230df6..b358e46babb 100644 --- a/psalm-baseline.xml +++ b/psalm-baseline.xml @@ -100,4 +100,11 @@ $stripe_billing_migrator + + + wcs_get_subscriptions_for_order( $order ) + wcs_is_manual_renewal_required() + wcs_order_contains_subscription( $order_id ) + + diff --git a/tests/unit/admin/test-class-wc-rest-payments-orders-controller.php b/tests/unit/admin/test-class-wc-rest-payments-orders-controller.php index e53b8383e8c..c0aded3df5e 100644 --- a/tests/unit/admin/test-class-wc-rest-payments-orders-controller.php +++ b/tests/unit/admin/test-class-wc-rest-payments-orders-controller.php @@ -44,6 +44,11 @@ class WC_REST_Payments_Orders_Controller_Test extends WCPAY_UnitTestCase { */ private $order_service; + /** + * @var WC_Payments_Token_Service|MockObject + */ + private $mock_token_service; + /** * @var string */ @@ -68,6 +73,7 @@ public function set_up() { $this->mock_api_client = $this->createMock( WC_Payments_API_Client::class ); $this->mock_gateway = $this->createMock( WC_Payment_Gateway_WCPay::class ); $this->mock_customer_service = $this->createMock( WC_Payments_Customer_Service::class ); + $this->mock_token_service = $this->createMock( WC_Payments_Token_Service::class ); $this->order_service = $this->getMockBuilder( 'WC_Payments_Order_Service' ) ->setConstructorArgs( [ $this->mock_api_client ] ) ->setMethods( [ 'attach_intent_info_to_order' ] ) @@ -77,7 +83,8 @@ public function set_up() { $this->mock_api_client, $this->mock_gateway, $this->mock_customer_service, - $this->order_service + $this->order_service, + $this->mock_token_service ); } @@ -868,9 +875,6 @@ public function test_capture_authorization_not_found() { $this->assertSame( 404, $data['status'] ); } - /** - * @expectedDeprecated create_customer - */ public function test_create_customer_invalid_order_id() { $request = new WP_REST_Request( 'POST' ); $request->set_body_params( @@ -887,9 +891,6 @@ public function test_create_customer_invalid_order_id() { $this->assertEquals( 404, $data['status'] ); } - /** - * @expectedDeprecated create_customer - */ public function test_create_customer_from_order_guest_without_customer_id() { $order = WC_Helper_Order::create_order( 0 ); $customer_data = WC_Payments_Customer_Service::map_customer_data( $order ); @@ -956,9 +957,6 @@ function ( $argument ) { ); } - /** - * @expectedDeprecated create_customer - */ public function test_create_customer_from_order_guest_with_customer_id() { $order = WC_Helper_Order::create_order( 0 ); $customer_data = WC_Payments_Customer_Service::map_customer_data( $order ); @@ -1009,9 +1007,6 @@ function ( $argument ) { $this->assertSame( 'cus_guest', $result_order->get_meta( '_stripe_customer_id' ) ); } - /** - * @expectedDeprecated create_customer - */ public function test_create_customer_from_order_non_guest_with_customer_id() { $order = WC_Helper_Order::create_order(); $customer_data = WC_Payments_Customer_Service::map_customer_data( $order ); @@ -1053,9 +1048,6 @@ public function test_create_customer_from_order_non_guest_with_customer_id() { $this->assertSame( 'cus_exist', $result_order->get_meta( '_stripe_customer_id' ) ); } - /** - * @expectedDeprecated create_customer - */ public function test_create_customer_from_order_with_invalid_status() { $order = WC_Helper_Order::create_order(); $order->set_status( Order_Status::COMPLETED ); @@ -1088,9 +1080,6 @@ public function test_create_customer_from_order_with_invalid_status() { $this->assertEquals( 400, $data['status'] ); } - /** - * @expectedDeprecated create_customer - */ public function test_create_customer_from_order_non_guest_with_customer_id_from_order_meta() { $order = WC_Helper_Order::create_order(); $customer_data = WC_Payments_Customer_Service::map_customer_data( $order ); @@ -1133,9 +1122,6 @@ public function test_create_customer_from_order_non_guest_with_customer_id_from_ $this->assertSame( 'cus_exist', $result_order->get_meta( '_stripe_customer_id' ) ); } - /** - * @expectedDeprecated create_customer - */ public function test_create_customer_from_order_non_guest_without_customer_id() { $order = WC_Helper_Order::create_order(); $customer_data = WC_Payments_Customer_Service::map_customer_data( $order ); @@ -1737,4 +1723,268 @@ private function create_charge_object() { return new WC_Payments_API_Charge( $this->mock_charge_id, 1500, $created ); } + + public function test_capture_terminal_payment_with_subscription_product_sets_generated_card_on_user() { + $order = $this->create_mock_order(); + + $subscription = new WC_Subscription(); + $subscription->set_parent( $order ); + $this->mock_wcs_order_contains_subscription( true ); + $this->mock_wcs_get_subscriptions_for_order( [ $subscription ] ); + $this->mock_wcs_is_manual_renewal_required( false ); + + $generated_card_id = 'pm_generatedCardId'; + + $mock_intent = WC_Helper_Intention::create_intention( + [ + 'charge' => [ + 'payment_method_details' => [ + 'type' => 'card_present', + 'card_present' => [ + 'generated_card' => $generated_card_id, + ], + ], + ], + 'metadata' => [ + 'order_id' => $order->get_id(), + ], + 'status' => Intent_Status::REQUIRES_CAPTURE, + ] + ); + + $request = $this->mock_wcpay_request( Get_Intention::class, 1, $this->mock_intent_id ); + + $request->expects( $this->once() ) + ->method( 'format_response' ) + ->willReturn( $mock_intent ); + + $this->mock_gateway + ->expects( $this->once() ) + ->method( 'capture_charge' ) + ->with( $this->isInstanceOf( WC_Order::class ) ) + ->willReturn( + [ + 'status' => Intent_Status::SUCCEEDED, + 'id' => $this->mock_intent_id, + ] + ); + + $this->order_service + ->expects( $this->once() ) + ->method( 'attach_intent_info_to_order' ) + ->with( + $this->isInstanceOf( WC_Order::class ), + $mock_intent, + ); + + $this->mock_token_service + ->expects( $this->once() ) + ->method( 'add_payment_method_to_user' ) + ->with( + $generated_card_id, + $this->isInstanceOf( WP_User::class ) + ); + + $request = new WP_REST_Request( 'POST' ); + $request->set_body_params( + [ + 'order_id' => $order->get_id(), + 'payment_intent_id' => $this->mock_intent_id, + ] + ); + + $response = $this->controller->capture_terminal_payment( $request ); + $this->assertEquals( 200, $response->get_status() ); + $this->assertEquals( 'woocommerce_payments', $subscription->get_payment_method() ); + } + + /** + * @dataProvider provider_capture_terminal_payment_with_subscription_product_sets_manual_renewal + */ + public function test_capture_terminal_payment_with_subscription_product_sets_manual_renewal( bool $manual_renewal_required_setting, bool $initial_subscription_manual_renewal, bool $expected_subscription_manual_renewal ) { + $order = $this->create_mock_order(); + + $subscription = new WC_Subscription(); + $subscription->set_parent( $order ); + $subscription->set_requires_manual_renewal( $initial_subscription_manual_renewal ); + $this->mock_wcs_order_contains_subscription( true ); + $this->mock_wcs_get_subscriptions_for_order( [ $subscription ] ); + $this->mock_wcs_is_manual_renewal_required( $manual_renewal_required_setting ); + + $generated_card_id = 'pm_generatedCardId'; + + $mock_intent = WC_Helper_Intention::create_intention( + [ + 'charge' => [ + 'payment_method_details' => [ + 'type' => 'card_present', + 'card_present' => [ + 'generated_card' => $generated_card_id, + ], + ], + ], + 'metadata' => [ + 'order_id' => $order->get_id(), + ], + 'status' => Intent_Status::REQUIRES_CAPTURE, + ] + ); + + $request = $this->mock_wcpay_request( Get_Intention::class, 1, $this->mock_intent_id ); + + $request->expects( $this->once() ) + ->method( 'format_response' ) + ->willReturn( $mock_intent ); + + $this->mock_gateway + ->expects( $this->once() ) + ->method( 'capture_charge' ) + ->with( $this->isInstanceOf( WC_Order::class ) ) + ->willReturn( + [ + 'status' => Intent_Status::SUCCEEDED, + 'id' => $this->mock_intent_id, + ] + ); + + $this->order_service + ->expects( $this->once() ) + ->method( 'attach_intent_info_to_order' ) + ->with( + $this->isInstanceOf( WC_Order::class ), + $mock_intent, + ); + + $this->mock_token_service + ->expects( $this->once() ) + ->method( 'add_payment_method_to_user' ) + ->with( + $generated_card_id, + $this->isInstanceOf( WP_User::class ) + ); + + $request = new WP_REST_Request( 'POST' ); + $request->set_body_params( + [ + 'order_id' => $order->get_id(), + 'payment_intent_id' => $this->mock_intent_id, + ] + ); + + $response = $this->controller->capture_terminal_payment( $request ); + $this->assertEquals( 200, $response->get_status() ); + $this->assertEquals( $expected_subscription_manual_renewal, $subscription->is_manual() ); + } + + /** + * bool $manual_renewal_required_setting + * bool $initial_subscription_manual_renewal + * bool $expected_subscription_manual_renewal + */ + public function provider_capture_terminal_payment_with_subscription_product_sets_manual_renewal(): array { + return [ + [ true, true, true ], + [ false, true, false ], + [ true, false, false ], // even if manual_renewal_required, we won't set it to manual_renewal if it started as automatic. + [ false, false, false ], + ]; + } + + /** + * Cleanup after all tests. + */ + public static function tear_down_after_class() { + WC_Subscriptions::set_wcs_order_contains_subscription( null ); + WC_Subscriptions::set_wcs_get_subscriptions_for_order( null ); + WC_Subscriptions::set_wcs_is_manual_renewal_required( null ); + parent::tear_down_after_class(); + } + + private function mock_wcs_order_contains_subscription( $value ) { + WC_Subscriptions::set_wcs_order_contains_subscription( + function ( $order ) use ( $value ) { + return $value; + } + ); + } + + private function mock_wcs_get_subscriptions_for_order( $value ) { + WC_Subscriptions::set_wcs_get_subscriptions_for_order( + function ( $order ) use ( $value ) { + return $value; + } + ); + } + + private function mock_wcs_is_manual_renewal_required( $value ) { + WC_Subscriptions::set_wcs_is_manual_renewal_required( + function () use ( $value ) { + return $value; + } + ); + } + + public function test_capture_terminal_payment_with_subscription_product_returns_success_even_if_no_generated_card() { + $order = $this->create_mock_order(); + + $subscription = new WC_Subscription(); + $subscription->set_parent( $order ); + $this->mock_wcs_order_contains_subscription( true ); + $this->mock_wcs_get_subscriptions_for_order( [ $subscription ] ); + + $mock_intent = WC_Helper_Intention::create_intention( + [ + 'charge' => [ + 'payment_method_details' => [ + 'type' => 'card_present', + 'card_present' => [], + ], + ], + 'metadata' => [ + 'order_id' => $order->get_id(), + ], + 'status' => Intent_Status::REQUIRES_CAPTURE, + ] + ); + + $request = $this->mock_wcpay_request( Get_Intention::class, 1, $this->mock_intent_id ); + + $request->expects( $this->once() ) + ->method( 'format_response' ) + ->willReturn( $mock_intent ); + + $this->mock_gateway + ->expects( $this->once() ) + ->method( 'capture_charge' ) + ->with( $this->isInstanceOf( WC_Order::class ) ) + ->willReturn( + [ + 'status' => Intent_Status::SUCCEEDED, + 'id' => $this->mock_intent_id, + ] + ); + + $this->order_service + ->expects( $this->once() ) + ->method( 'attach_intent_info_to_order' ) + ->with( + $this->isInstanceOf( WC_Order::class ), + $mock_intent, + ); + + $this->mock_token_service + ->expects( $this->never() ) + ->method( 'add_payment_method_to_user' ); + + $request = new WP_REST_Request( 'POST' ); + $request->set_body_params( + [ + 'order_id' => $order->get_id(), + 'payment_intent_id' => $this->mock_intent_id, + ] + ); + + $response = $this->controller->capture_terminal_payment( $request ); + $this->assertSame( 200, $response->status ); + } } diff --git a/tests/unit/helpers/class-wc-helper-subscriptions.php b/tests/unit/helpers/class-wc-helper-subscriptions.php index 3d361a6446d..a2c99a77071 100644 --- a/tests/unit/helpers/class-wc-helper-subscriptions.php +++ b/tests/unit/helpers/class-wc-helper-subscriptions.php @@ -90,6 +90,13 @@ function wcs_get_orders_with_meta_query( $args ) { return ( WC_Subscriptions::$wcs_get_orders_with_meta_query )( $args ); } +function wcs_is_manual_renewal_required() { + if ( ! WC_Subscriptions::$wcs_is_manual_renewal_required ) { + return; + } + return ( WC_Subscriptions::$wcs_is_manual_renewal_required )(); +} + /** * Class WC_Subscriptions. * @@ -187,6 +194,13 @@ class WC_Subscriptions { */ public static $wcs_order_contains_renewal = null; + /** + * wcs_is_manual_renewal_required mock. + * + * @var function + */ + public static $wcs_is_manual_renewal_required = null; + public static function set_wcs_order_contains_subscription( $function ) { self::$wcs_order_contains_subscription = $function; } @@ -234,4 +248,8 @@ public static function wcs_order_contains_renewal( $function ) { public static function is_duplicate_site() { return false; } + + public static function set_wcs_is_manual_renewal_required( $function ) { + self::$wcs_is_manual_renewal_required = $function; + } }