View Subscription actions. * @param WC_Subscription $subscription The subscription object. * * @return array The subscription actions. */ public function update_subscription_change_payment_button( $actions, $subscription ) { if ( $this->does_subscription_need_payment_updated( $subscription ) ) { // Override any existing button on $actions['change_payment_method'] to show "Update Card" button. $actions['change_payment_method'] = [ 'url' => $this->get_subscription_update_payment_url( $subscription ), 'name' => __( 'Update payment method', 'woocommerce-payments' ), ]; } return $actions; } /** * Updates the 'Pay' link displayed on the My Account > Orders or from a subscriptions related orders table, to make sure customers are directed to update their card. * * @param array $actions Order actions. * @param WC_Order $order The WC Order object. * * @return array The order actions. */ public function update_order_pay_button( $actions, $order ) { // If the order isn't payable, there's nothing to update. if ( ! isset( $actions['pay'] ) || ! function_exists( 'wcs_get_subscriptions_for_order' ) ) { return $actions; } // If there isn't an invoice linked to this order, there's nothing to update. if ( ! WC_Payments_Invoice_Service::get_order_invoice_id( $order ) ) { return $actions; } $subscriptions = wcs_get_subscriptions_for_order( $order, [ 'order_type' => 'any' ] ); $subscription = ! empty( $subscriptions ) ? array_pop( $subscriptions ) : null; // If we couldn't locate the subscription, we can assume this is a WCPay subscription by the fact the order has an invoice ID. // As a failsafe remove the 'pay' action for this order as that's not the accepted flow for WCPay subscriptions. if ( ! $subscription ) { unset( $actions['pay'] ); return $actions; } // Only alter the pay action if the subscription needs a new payment method. if ( $this->does_subscription_need_payment_updated( $subscription ) ) { $actions['pay']['url'] = $this->get_subscription_update_payment_url( $subscription ); } return $actions; } /** * Filters subscription `can_be_updated_to( 'new-payment-method' )` calls to allow customers to update their subscription's payment method. * * @param bool $can_update Whether the subscription's payment method can be updated. * @param WC_Subscription $subscription The WC Subscription object. * * @return bool Whether the subscription's payment method can be updated. */ public function can_update_payment_method( bool $can_update, WC_Subscription $subscription ) { return $this->does_subscription_need_payment_updated( $subscription ) ? true : $can_update; } /** * Redirects customers to update their payment method rather than pay for a WC Pay Subscription's failed order. */ public function redirect_pay_for_order_to_update_payment_method() { global $wp; // Note: There is no nonce verification for the "pay for order" action - the URL is long living. if ( ! isset( $_GET['pay_for_order'], $_GET['key'] ) || ! empty( $_GET['change_payment_method'] ) || ( ! isset( $_GET['order_id'] ) && ! isset( $wp->query_vars['order-pay'] ) ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended return; } if ( ! function_exists( 'wcs_get_subscriptions_for_order' ) ) { return; } $order_id = ( isset( $wp->query_vars['order-pay'] ) ) ? absint( $wp->query_vars['order-pay'] ) : absint( $_GET['order_id'] ); // phpcs:ignore WordPress.Security.NonceVerification.Recommended $order_key = wc_clean( wp_unslash( $_GET['key'] ) ); // phpcs:ignore WordPress.Security.NonceVerification.Recommended $order = wc_get_order( $order_id ); if ( ! $order || ! hash_equals( $order->get_order_key(), $order_key ) || ! current_user_can( 'pay_for_order', $order->get_id() ) ) { return; } // Check if the order is linked to a billing invoice. $invoice_id = WC_Payments_Invoice_Service::get_order_invoice_id( $order ); if ( $invoice_id ) { $subscriptions = wcs_get_subscriptions_for_order( $order, [ 'order_type' => 'any' ] ); if ( ! empty( $subscriptions ) ) { $subscription = array_pop( $subscriptions ); if ( $subscription && current_user_can( 'edit_shop_subscription_payment_method', $subscription->get_id() ) && $this->does_subscription_need_payment_updated( $subscription ) ) { wp_safe_redirect( $this->get_subscription_update_payment_url( $subscription ) ); exit; } } } } /** * Modifies the change payment method page title (and page breadcrumbs) when updating card details for WC Pay subscriptions. * * @param string $title The default page title. * @param WC_Subscription $subscription The WC Subscription object. * * @return string The page title. */ public function change_payment_method_page_title( string $title, WC_Subscription $subscription ) { if ( $this->does_subscription_need_payment_updated( $subscription ) ) { $title = __( 'Update payment details', 'woocommerce-payments' ); } return $title; } /** * Modifies the message shown on the change payment method page. * * @param string $message The default customer notice shown on the change payment method page. * @param WC_Subscription $subscription The Subscription. * * @return string The customer notice shown on the change payment method page. */ public function change_payment_method_page_notice( string $message, WC_Subscription $subscription ) { if ( $this->does_subscription_need_payment_updated( $subscription ) ) { $message = __( "Your subscription's last renewal failed payment. Please update your payment details so we can reattempt payment.", 'woocommerce-payments' ); } return $message; } /** * Checks if a subscription needs to update it's WCPay payment method. * * @param WC_Subscription $subscription The WC Subscription object. * @return bool Whether the subscription's last order failed and needs a new updated payment method. */ private function does_subscription_need_payment_updated( $subscription ) { // We're only interested in WC Pay subscriptions that are on hold due to a failed payment. if ( ! is_a( $subscription, 'WC_Subscription' ) || ! $subscription->has_status( 'on-hold' ) || ! WC_Payments_Subscription_Service::is_wcpay_subscription( $subscription ) ) { return false; } $last_order = $subscription->get_last_order( 'all', 'any' ); return $last_order && $last_order->has_status( 'failed' ) && WC_Payments_Invoice_Service::get_pending_invoice_id( $subscription ); } /** * Generates the URL for the WC Pay Subscription's update payment method screen. * * @param WC_Subscription $subscription The WC Subscription object. * @return string The update payment method */ private function get_subscription_update_payment_url( $subscription ) { return add_query_arg( // nosemgrep: audit.php.wp.security.xss.query-arg -- no user input is used in this URL. [ 'change_payment_method' => $subscription->get_id(), '_wpnonce' => wp_create_nonce(), ], $subscription->get_checkout_payment_url() ); } /** * Modifies the change payment method form submit button to include language about retrying payment if there's a failed order. * * @param string $button_text The change subscription payment method button text. * @return string The change subscription payment method button text. */ public function change_payment_method_form_submit_text( $button_text ) { if ( isset( $_GET['change_payment_method'] ) && function_exists( 'wcs_get_subscription' ) ) { // phpcs:ignore WordPress.Security.NonceVerification $subscription = wcs_get_subscription( wc_clean( wp_unslash( $_GET['change_payment_method'] ) ) ); // phpcs:ignore WordPress.Security.NonceVerification if ( $subscription && $this->does_subscription_need_payment_updated( $subscription ) ) { $button_text = __( 'Update and retry payment', 'woocommerce-payments' ); } } return $button_text; } }