payments_api_client = $api_client; $this->customer_service = $customer_service; $this->product_service = $product_service; $this->invoice_service = $invoice_service; /** * When a store is in staging mode, we don't want any subscription updates or purchases to be sent to the server. * * Sending these requests from staging sites can have unintended consequences for the live store. For example, * Subscriptions which renew on the staging site will lead to pausing the shared subscription record at Stripe * and that will result in inexplicable paused subscriptions and missed renewal payments for the live site. */ if ( WC_Payments_Subscriptions::is_duplicate_site() ) { return; } if ( WC_Payments_Features::should_use_stripe_billing() ) { add_action( 'woocommerce_checkout_subscription_created', [ $this, 'create_subscription' ] ); add_action( 'woocommerce_renewal_order_payment_complete', [ $this, 'create_subscription_for_manual_renewal' ] ); add_action( 'woocommerce_subscription_payment_method_updated', [ $this, 'maybe_create_subscription_from_update_payment_method' ], 10, 2 ); } if ( class_exists( 'WC_Subscription' ) ) { // Save the new token on the WCPay subscription when it's added to a WC subscription. add_action( 'woocommerce_payment_token_added_to_order', [ $this, 'update_wcpay_subscription_payment_method' ], 10, 3 ); add_action( 'woocommerce_subscription_status_cancelled', [ $this, 'cancel_subscription' ] ); add_action( 'woocommerce_subscription_status_expired', [ $this, 'cancel_subscription' ] ); add_action( 'woocommerce_subscription_status_on-hold', [ $this, 'handle_subscription_status_on_hold' ] ); add_action( 'woocommerce_subscription_status_pending-cancel', [ $this, 'set_pending_cancel_for_subscription' ] ); add_action( 'woocommerce_subscription_status_pending-cancel_to_active', [ $this, 'reactivate_subscription' ] ); add_action( 'woocommerce_subscription_status_on-hold_to_active', [ $this, 'reactivate_subscription' ] ); add_filter( 'woocommerce_subscription_payment_gateway_supports', [ $this, 'prevent_wcpay_subscription_changes' ], 10, 3 ); add_filter( 'woocommerce_order_actions', [ $this, 'prevent_wcpay_manual_renewal' ], 11, 1 ); add_action( 'woocommerce_payments_changed_subscription_payment_method', [ $this, 'maybe_attempt_payment_for_subscription' ], 10, 2 ); add_action( 'woocommerce_admin_order_data_after_billing_address', [ $this, 'show_wcpay_subscription_id' ] ); add_action( 'woocommerce_subscription_payment_method_updated_from_' . WC_Payment_Gateway_WCPay::GATEWAY_ID, [ $this, 'maybe_cancel_subscription' ], 10, 2 ); add_action( 'wcs_renewal_order_items', [ $this, 'check_wcpay_mode_for_subscription' ], 10, 3 ); } } /** * Checks if the WC subscription has a first payment date that is in the future. * * @param WC_Subscription $subscription WC subscription to check if first payment is now or delayed. * * @return bool Whether the first payment is delayed. */ public static function has_delayed_payment( WC_Subscription $subscription ) { $trial_end = $subscription->get_time( 'trial_end' ); $has_sync = false; if ( ! class_exists( 'WC_Subscriptions_Synchroniser' ) ) { return $has_sync; } if ( WC_Subscriptions_Synchroniser::is_syncing_enabled() && WC_Subscriptions_Synchroniser::subscription_contains_synced_product( $subscription ) ) { $has_sync = true; foreach ( $subscription->get_items() as $item ) { $synced_payment_date = WC_Subscriptions_Synchroniser::calculate_first_payment_date( $item->get_product(), 'timestamp' ); // Check if the subscription starts from today because in those cases we don't need a dynamic trial period to align to the payment date. if ( WC_Subscriptions_Synchroniser::is_today( $synced_payment_date ) ) { $has_sync = false; break; } } } return $has_sync || $trial_end > time(); } /** * Gets the WC subscription associated with a WCPay subscription ID. * * @param string $wcpay_subscription_id WCPay subscription ID. * * @return WC_Subscription|bool The WC subscription or false if it can't be found. */ public static function get_subscription_from_wcpay_subscription_id( string $wcpay_subscription_id ) { if ( ! function_exists( 'wcs_get_subscriptions' ) ) { return false; } $subscriptions = wcs_get_subscriptions( [ 'subscriptions_per_page' => 1, 'meta_query' => [ // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query [ 'key' => self::SUBSCRIPTION_ID_META_KEY, 'value' => $wcpay_subscription_id, ], ], ] ); return empty( $subscriptions ) ? false : reset( $subscriptions ); } /** * Gets the WCPay subscription ID from a WC subscription. * * @param WC_Subscription $subscription WC Subscription. * * @return string */ public static function get_wcpay_subscription_id( WC_Subscription $subscription ) { return $subscription->get_meta( self::SUBSCRIPTION_ID_META_KEY, true ); } /** * Gets the WCPay subscription item ID from a WC subscription item. * * @param WC_Order_Item $item WC Item. * * @return string */ public static function get_wcpay_subscription_item_id( WC_Order_Item $item ) { return $item->get_meta( self::SUBSCRIPTION_ITEM_ID_META_KEY, true ); } /** * Gets the WCPay subscription discount IDs from a WC subscription. * * @param WC_Subscription $subscription WC Subscription. * * @return array */ public static function get_wcpay_discount_ids( WC_Subscription $subscription ) { return $subscription->get_meta( self::SUBSCRIPTION_DISCOUNT_IDS_META_KEY, true ); } /** * Sets Stripe discount ids on WC subscription. * * @param WC_Subscription $subscription The WC Subscription object. * @param array $discounts The WCPay discount data. * * @return void */ public static function set_wcpay_discount_ids( WC_Subscription $subscription, array $discounts ) { $subscription->update_meta_data( self::SUBSCRIPTION_DISCOUNT_IDS_META_KEY, $discounts ); $subscription->save(); } /** * Determines if a given WC subscription is a WCPay subscription. * * On duplicate sites (staging or dev environments) all WCPay Subscrptions are disabled and so return false. * This is to avoid dev environments interacting with WCPay Subscriptions and communicating on behalf of the live store. * * @param WC_Subscription $subscription WC Subscription object. * * @return bool */ public static function is_wcpay_subscription( WC_Subscription $subscription ): bool { return ! WC_Payments_Subscriptions::is_duplicate_site() && WC_Payment_Gateway_WCPay::GATEWAY_ID === $subscription->get_payment_method() && (bool) self::get_wcpay_subscription_id( $subscription ); } /** * Formats item data. * * @param string $currency The item's currency. * @param string $wcpay_product_id The item's Stripe product id. * @param float $unit_amount The item's unit amount. * @param string $interval The item's interval. Optional. * @param int $interval_count The item's interval count. Optional. * * @return array Structured invoice item array. */ public static function format_item_price_data( string $currency, string $wcpay_product_id, float $unit_amount, string $interval = '', int $interval_count = 0 ): array { $data = [ 'currency' => $currency, 'product' => $wcpay_product_id, // We cannot use WC_Payments_Utils::prepare_amount() here because it returns an int but 'unit_amount_decimal' supports multiple decimal places even though it is cents (fractions of a cent). 'unit_amount_decimal' => round( $unit_amount, wc_get_rounding_precision() ), ]; // Convert the amount to cents if it's not in a zero based currency. if ( ! WC_Payments_Utils::is_zero_decimal_currency( strtolower( $currency ) ) ) { $data['unit_amount_decimal'] *= 100; } if ( $interval && $interval_count ) { $data['recurring'] = [ 'interval' => $interval, 'interval_count' => $interval_count, ]; } return $data; } /** * Prepares discount data used to create a WCPay subscription. * * @param WC_Subscription $subscription The WC subscription used to create the subscription on server. * * @return array WCPay discount item data. */ public static function get_discount_item_data_for_subscription( WC_Subscription $subscription ): array { $data = []; foreach ( $subscription->get_items( 'coupon' ) as $item ) { $code = $item->get_code(); $coupon = new WC_Coupon( $code ); $duration = in_array( $coupon->get_discount_type(), [ 'recurring_fee', 'recurring_percent' ], true ) ? 'forever' : 'once'; $discount = $item->get_discount(); if ( $discount ) { $data[] = [ 'amount_off' => WC_Payments_Utils::prepare_amount( $discount, $subscription->get_currency() ), 'currency' => $subscription->get_currency(), 'duration' => $duration, // Translators: %s Coupon code. 'name' => sprintf( __( 'Coupon - %s', 'woocommerce-payments' ), $code ), ]; } } return $data; } /** * Gets a WCPay subscription from a WC subscription object. * * @param WC_Subscription $subscription The WC subscription to get from server. * * @return array|bool WCPay subscription data, otherwise false. */ public function get_wcpay_subscription( WC_Subscription $subscription ) { $wcpay_subscription_id = self::get_wcpay_subscription_id( $subscription ); if ( ! $wcpay_subscription_id ) { return false; } try { return $this->payments_api_client->get_subscription( $wcpay_subscription_id ); } catch ( API_Exception $e ) { return false; } } /** * Creates a WCPay subscription. * * @param WC_Subscription $subscription The WC order used to create a wcpay subscription on server. * * @return void * * @throws Exception Throws an exception to stop checkout processing and display message to customer. */ public function create_subscription( WC_Subscription $subscription ) { /* * Bail early if the subscription payment method is not WooPayments. * WCPay Subscriptions are not created in the following scenarios: * * - A different payment gateway was used to purchase the subscription (e.g. PayPal). * - The subscription is free (i.e. $0) and payment details were not captured during checkout. */ if ( WC_Payment_Gateway_WCPay::GATEWAY_ID !== $subscription->get_payment_method() ) { return; } $checkout_error_message = __( 'There was a problem creating your subscription. Please try again or contact us for assistance.', 'woocommerce-payments' ); $wcpay_customer_id = $this->customer_service->get_customer_id_for_order( $subscription ); if ( ! $wcpay_customer_id ) { Logger::error( 'There was a problem creating the WooPayments subscription. WooPayments customer ID missing.' ); throw new Exception( $checkout_error_message ); } try { $subscription_data = $this->prepare_wcpay_subscription_data( $wcpay_customer_id, $subscription ); $this->validate_subscription_data( $subscription_data ); $subscription_data['metadata']['subscription_source'] = $this->is_subscriptions_plugin_active() ? 'woo_subscriptions' : 'wcpay_subscriptions'; $response = $this->payments_api_client->create_subscription( $subscription_data ); $this->set_wcpay_subscription_id( $subscription, $response['id'] ); $this->set_wcpay_subscription_item_ids( $subscription, $response['items']['data'] ); if ( isset( $response['discounts'] ) ) { static::set_wcpay_discount_ids( $subscription, $response['discounts'] ); } if ( ! empty( $response['latest_invoice'] ) ) { $this->invoice_service->set_subscription_invoice_id( $subscription, $response['latest_invoice'] ); } } catch ( \Exception $e ) { Logger::log( sprintf( 'There was a problem creating the WooPayments subscription. %s', $e->getMessage() ) ); if ( $e instanceof Amount_Too_Small_Exception ) { throw new Exception( sprintf( // Translators: The %1 placeholder is a currency formatted price string ($0.50). The %2 and %3 placeholders are opening and closing strong HTML tags. __( 'There was a problem creating your subscription. %1$s doesn\'t meet the %2$sminimum recurring amount%3$s this payment method can process.', 'woocommerce-payments' ), wc_price( $subscription->get_total() ), '', '' ) ); } elseif ( $e instanceof Cannot_Combine_Currencies_Exception ) { throw new Exception( sprintf( // Translators: %1$s and %2$s are both currency codes, e.g. `USD` or `EUR`. __( 'The subscription couldn\'t be created because it uses a different currency (%1$s) from your existing subscriptions (%2$s). Please ensure all subscriptions use the same currency.', 'woocommerce-payments' ), $subscription->get_currency(), $e->get_currency() ) ); } throw new Exception( $checkout_error_message ); } } /** * Conditionally creates a WCPay subscription when a subscriber * updates the subscription payment method from their account page. * * @param WC_Subscription $subscription An instance of a WC_Subscription object. * @param string $new_payment_method The ID of the new payment method. * * @return void */ public function maybe_create_subscription_from_update_payment_method( WC_Subscription $subscription, string $new_payment_method ) { // Not changing the subscription payment method to WooPayments, bail. if ( WC_Payment_Gateway_WCPay::GATEWAY_ID !== $new_payment_method ) { return; } // We already have a WCPay subscription ID, bail. if ( (bool) self::get_wcpay_subscription_id( $subscription ) ) { return; } $this->is_creating_subscription_from_update_payment_method = true; $this->create_subscription( $subscription ); } /** * Cancels the WCPay subscription when it's cancelled in WC. * * @param WC_Subscription $subscription The WC subscription that was canceled. * * @return void */ public function cancel_subscription( WC_Subscription $subscription ) { $wcpay_subscription_id = self::get_wcpay_subscription_id( $subscription ); if ( ! $wcpay_subscription_id ) { return; } try { $this->payments_api_client->cancel_subscription( $wcpay_subscription_id ); } catch ( API_Exception $e ) { Logger::log( sprintf( 'There was a problem canceling the subscription on WooPayments server: %s.', $e->getMessage() ) ); } } /** * Handle subscription status change to on-hold. * * @param WC_Subscription $subscription The WC subscription. * * @return void */ public function handle_subscription_status_on_hold( WC_Subscription $subscription ) { // Check if the subscription is a WCPay subscription before proceeding. // In stores that have WC Subscriptions active, or previously had WC S, // this method may be called with regular tokenised subscriptions. if ( ! static::is_wcpay_subscription( $subscription ) ) { return; } $this->suspend_subscription( $subscription ); // Add an order note as a visible record of suspend. $subscription->add_order_note( __( 'Suspended WooPayments Subscription because subscription status changed to on-hold.', 'woocommerce-payments' ) ); // Log that the subscription was suspended. // Include a brief stack trace to help determine where status change originated. // For example, admin user action, or a code interaction with customizations. $e = new Exception(); $trace = $e->getTraceAsString(); Logger::log( sprintf( 'Suspended WooPayments Subscription because subscription status changed to on-hold. WC ID: %d; WooPayments ID: %s; stack: %s', $subscription->get_id(), self::get_wcpay_subscription_id( $subscription ), $trace ) ); } /** * Suspends a WCPay subscription. * * @param WC_Subscription $subscription The WC subscription to suspend. * * @return void */ public function suspend_subscription( WC_Subscription $subscription ) { // Check if the subscription is a WCPay subscription before proceeding. if ( ! static::is_wcpay_subscription( $subscription ) ) { Logger::log( sprintf( 'Aborting WC_Payments_Subscription_Service::suspend_subscription; subscription is a tokenised (non WooPayments) subscription. WC ID: %d.', $subscription->get_id() ) ); return; } $this->update_subscription( $subscription, [ 'pause_collection' => [ 'behavior' => 'void' ] ] ); } /** * Reactivates the WCPay subscription when the WC subscription is activated. * This is done by making a request to server to unset the "cancellation at end of period" value for the WooPayments subscription. * * @param WC_Subscription $subscription The WC subscription that was activated. * * @return void */ public function reactivate_subscription( WC_Subscription $subscription ) { $this->update_subscription( $subscription, [ 'cancel_at_period_end' => 'false', 'pause_collection' => '', ] ); } /** * Marks the WCPay subscription as pending-cancel by setting the "cancellation at end of period" on the WooPayments subscription. * * @param WC_Subscription $subscription The subscription that was set as pending cancel. * * @return void */ public function set_pending_cancel_for_subscription( WC_Subscription $subscription ) { $this->update_subscription( $subscription, [ 'cancel_at_period_end' => 'true' ] ); } /** * When a WC Subscription's payment method has been updated make sure we attach * the new payment method ID to the WCPay subscription. * * If the WCPay subscription's payment method was updated while there's a failed invoice, trigger a retry. * * @param int $subscription_id Post ID (WC subscription ID) that had its payment method updated. * @param int $token_id Payment Token post ID stored in DB. * @param WC_Payment_Token $token Payment Token object. */ public function update_wcpay_subscription_payment_method( int $subscription_id, int $token_id, WC_Payment_Token $token ) { if ( ! function_exists( 'wcs_get_subscription' ) ) { return; } $subscription = wcs_get_subscription( $subscription_id ); if ( $subscription && self::is_wcpay_subscription( $subscription ) ) { $wcpay_subscription_id = static::get_wcpay_subscription_id( $subscription ); $wcpay_payment_method_id = $token->get_token(); if ( $wcpay_subscription_id && $wcpay_payment_method_id ) { try { $this->update_subscription( $subscription, [ 'default_payment_method' => $wcpay_payment_method_id ] ); } catch ( API_Exception $e ) { Logger::error( sprintf( 'There was a problem updating the WooPayments subscription\'s default payment method on server: %s.', $e->getMessage() ) ); return; } } } } /** * Attempts payment for WCPay subscription if needed. * * @param WC_Subscription $subscription WC subscription linked to the WCPay subscription that maybe needs to retry payment. * @param WC_Payment_Token $token The new subscription token to assign to the invoice order. * * @return void */ public function maybe_attempt_payment_for_subscription( $subscription, WC_Payment_Token $token ) { if ( ! function_exists( 'wcs_is_subscription' ) || ! wcs_is_subscription( $subscription ) ) { return; } $wcpay_invoice_id = WC_Payments_Invoice_Service::get_pending_invoice_id( $subscription ); if ( ! $wcpay_invoice_id || ! self::is_wcpay_subscription( $subscription ) ) { return; } $response = $this->payments_api_client->charge_invoice( $wcpay_invoice_id ); // Rather than wait for the Stripe webhook to be received, complete the order now if it was successfully paid. if ( $response && isset( $response['status'] ) && 'paid' === $response['status'] ) { // Remove the pending invoice ID now that we know it has been paid. $this->invoice_service->mark_pending_invoice_paid_for_subscription( $subscription ); $order_id = WC_Payments_Invoice_Service::get_order_id_by_invoice_id( $wcpay_invoice_id ); $order = $order_id ? wc_get_order( $order_id ) : false; if ( $order && $order->needs_payment() ) { // We're about to record a successful payment, temporarily remove the "is request to change payment method" flag as it prevents us from activating the subscrption via WC_Subscription::payment_complete(). $is_change_payment_request = WC_Subscriptions_Change_Payment_Gateway::$is_request_to_change_payment; WC_Subscriptions_Change_Payment_Gateway::$is_request_to_change_payment = false; // We need to store the successful token on the order otherwise WC_Subscriptions_Change_Payment_Gateway::change_failing_payment_method() will override the successful token with the failing one. $order->add_payment_token( $token ); $order->payment_complete(); // Reinstate the "is request to change payment method" flag. WC_Subscriptions_Change_Payment_Gateway::$is_request_to_change_payment = $is_change_payment_request; wc_add_notice( __( "We've successfully collected payment for your subscription using your new payment method.", 'woocommerce-payments' ) ); } } } /** * Whether the subscription supports a given feature. * * @param bool $supported Is feature supported. * @param string $feature Feature flag. * @param WC_Subscription $subscription WC Subscription to check if feature is supported against. * * @return bool */ public function prevent_wcpay_subscription_changes( bool $supported, string $feature, WC_Subscription $subscription ) { $is_stripe_billing = self::is_wcpay_subscription( $subscription ); switch ( $feature ) { case 'subscription_amount_changes': case 'subscription_date_changes': $supported = ! $is_stripe_billing; break; case 'gateway_scheduled_payments': $supported = $is_stripe_billing; break; } if ( $is_stripe_billing ) { $supported = in_array( $feature, $this->supports, true ) || isset( $this->feature_support_exceptions[ $subscription->get_id() ][ $feature ] ); } return $supported; } /** * Remove pending parent and renewal order creation from admin edit subscriptions page. * * @param array $actions Array of available actions. * @return array Array of updated actions. */ public function prevent_wcpay_manual_renewal( array $actions ) { global $theorder; if ( ! function_exists( 'wcs_is_subscription' ) || ! $theorder ) { return $actions; } if ( wcs_is_subscription( $theorder ) && self::is_wcpay_subscription( $theorder ) ) { unset( $actions['wcs_create_pending_parent'], $actions['wcs_create_pending_renewal'], $actions['wcs_process_renewal'] ); } return $actions; } /** * Show WCPay Subscription ID on Edit Subscription page. * * @param WC_Order|WC_Subscription $order The order object. */ public function show_wcpay_subscription_id( WC_Order $order ) { if ( ! function_exists( 'wcs_is_subscription' ) || ! wcs_is_subscription( $order ) || ! self::is_wcpay_subscription( $order ) ) { return; } $wcpay_subscription_id = self::get_wcpay_subscription_id( $order ); if ( ! $wcpay_subscription_id ) { return; } echo '
' . sprintf( /* translators: %s: WooPayments */ esc_html__( '%s Subscription ID', 'woocommerce-payments' ), 'WooPayments' ) . ': ' . esc_html( $wcpay_subscription_id ) . '
'; } /** * Updates a subscription's next payment date to match the WooPayments subscription's payment date. * * @param array $wcpay_subscription The WCPay Subscription data. * @param WC_Subscription $subscription The WC Subscription object. * * @return void */ public function update_dates_to_match_wcpay_subscription( array $wcpay_subscription, WC_Subscription $subscription ) { // Temporarily allow date changes when we're updating dates to match the dates on the WooPayments subscription. $this->set_feature_support_exception( $subscription, 'subscription_date_changes' ); $next_payment_date = gmdate( 'Y-m-d H:i:s', $wcpay_subscription['current_period_end'] ); $subscription->update_dates( [ 'next_payment' => $next_payment_date ] ); $next_payment_time_difference = absint( $wcpay_subscription['current_period_end'] - $subscription->get_time( 'next_payment' ) ); if ( $next_payment_time_difference > 0 && $next_payment_time_difference >= 12 * HOUR_IN_SECONDS ) { $subscription->add_order_note( __( 'The subscription\'s next payment date has been updated to match WooPayments server.', 'woocommerce-payments' ) ); } // Remove the 'subscription_date_changes' exception. $this->clear_feature_support_exception( $subscription, 'subscription_date_changes' ); } /** * Creates a WCPay subscription on successful renewal payment for manual WC subscription. * * @param int $order_id WC Order ID. */ public function create_subscription_for_manual_renewal( int $order_id ) { if ( ! function_exists( 'wcs_get_subscriptions_for_renewal_order' ) ) { return; } $subscriptions = wcs_get_subscriptions_for_renewal_order( $order_id ); foreach ( $subscriptions as $subscription_id => $subscription ) { if ( ! self::get_wcpay_subscription_id( $subscription ) && $subscription->is_manual() ) { $this->create_subscription( $subscription ); } } } /** * Prepares item data used to create a WCPay subscription. * * @param string $wcpay_customer_id WCPay Customer ID to create the subscription for. * @param WC_Subscription $subscription The WC subscription used to create the subscription on server. * * @return array WCPay subscription data */ private function prepare_wcpay_subscription_data( string $wcpay_customer_id, WC_Subscription $subscription ) { $recurring_items = $this->get_recurring_item_data_for_subscription( $subscription ); $one_time_items = $this->get_one_time_item_data_for_subscription( $subscription ); $discount_items = self::get_discount_item_data_for_subscription( $subscription ); $data = [ 'customer' => $wcpay_customer_id, 'items' => $recurring_items, ]; if ( self::has_delayed_payment( $subscription ) ) { $data['trial_end'] = max( $subscription->get_time( 'trial_end' ), $subscription->get_time( 'next_payment' ) ); } if ( ! empty( $one_time_items ) ) { $data['add_invoice_items'] = $one_time_items; } if ( ! empty( $discount_items ) ) { $data['discounts'] = $discount_items; } if ( $this->is_creating_subscription_from_update_payment_method ) { $data['backdate_start_date'] = max( $subscription->get_time( 'start' ), $subscription->get_time( 'last_order_date_created' ), $subscription->get_time( 'last_order_date_paid' ) ); $data['billing_cycle_anchor'] = $subscription->get_time( 'next_payment' ); } return apply_filters( 'wcpay_subscriptions_prepare_subscription_data', $data ); } /** * Gets recurring item data from a subscription needed to create a WCPay subscription. * * @param WC_Subscription $subscription The WC subscription to fetch product data from. * * @return array WCPay recurring item data. */ public function get_recurring_item_data_for_subscription( WC_Subscription $subscription ): array { $data = []; foreach ( $subscription->get_items() as $item ) { $data[] = [ 'metadata' => $this->get_item_metadata( $item ), 'quantity' => $item->get_quantity(), 'price_data' => static::format_item_price_data( $subscription->get_currency(), $this->product_service->get_or_create_wcpay_product_id( $item->get_product() ), $item->get_subtotal() / $item->get_quantity(), $subscription->get_billing_period(), $subscription->get_billing_interval() ), ]; } $additional_items = array_merge( $subscription->get_fees(), $subscription->get_shipping_methods(), $subscription->get_taxes() ); foreach ( $additional_items as $item ) { if ( is_a( $item, 'WC_Order_Item_Tax' ) ) { $item_name = $item->get_label(); $unit_amount = $item->get_tax_total() + $item->get_shipping_tax_total(); } else { $item_name = $item->get_type(); $unit_amount = $item->get_total(); } if ( $unit_amount ) { $data[] = [ 'metadata' => $this->get_item_metadata( $item ), 'price_data' => self::format_item_price_data( $subscription->get_currency(), $this->product_service->get_wcpay_product_id_for_item( $item_name ), $unit_amount, $subscription->get_billing_period(), $subscription->get_billing_interval() ), ]; } } return $data; } /** * Cancels a WCPay subscription when a customer changes their payment method * * @param WC_Subscription $subscription The subscription that was updated. * @param string $new_payment_method The subscription's new payment method ID. */ public function maybe_cancel_subscription( $subscription, $new_payment_method ) { $wcpay_subscription_id = self::get_wcpay_subscription_id( $subscription ); if ( (bool) $wcpay_subscription_id && WC_Payment_Gateway_WCPay::GATEWAY_ID !== $new_payment_method ) { $this->cancel_subscription( $subscription ); // Delete the WCPay Subscription meta but keep a record of it. $subscription->update_meta_data( '_cancelled' . self::SUBSCRIPTION_ID_META_KEY, $wcpay_subscription_id ); $subscription->delete_meta_data( self::SUBSCRIPTION_ID_META_KEY ); $subscription->save(); } } /** * Checks if the original subscription mode matches current WooPayments mode. * * If the original subscription was payed with WooPayments, but in the mode, that doesn't * match the current WooPayments mode, we need to throw an exception, to prevent the renewal * order from being created, as it would fail to be paid. * * @param array $items The items to be added to the renewal order. * @param WC_Order $order Renewal order. * @param WC_Subscription $subscription The original subscription. * @throws Subscription_Mode_Mismatch_Exception * @return array */ public function check_wcpay_mode_for_subscription( array $items, WC_Order $order, WC_Subscription $subscription ): array { $parent_order = $subscription->get_parent(); if ( false !== $parent_order ) { $subscription_mode = $parent_order->get_meta( WC_Payments_Order_Service::WCPAY_MODE_META_KEY ); $current_mode = WC_Payments::mode()->is_test() ? Order_Mode::TEST : Order_Mode::PRODUCTION; if ( is_string( $subscription_mode ) && '' !== $subscription_mode && $subscription_mode !== $current_mode ) { if ( Order_Mode::TEST === $subscription_mode ) { throw new Subscription_Mode_Mismatch_Exception( __( 'Subscription was made when WooPayments was in the test mode and cannot be renewed in the live mode.', 'woocommerce-payments' ) ); } else { throw new Subscription_Mode_Mismatch_Exception( __( 'Subscription was made when WooPayments was in the live mode and cannot be renewed in the test mode.', 'woocommerce-payments' ) ); } } } return $items; } /** * Gets one time item data from a subscription needed to create a WCPay subscription. * * @param WC_Subscription $subscription The WC subscription to fetch item data from. * * @return array WCPay one time item data. */ private function get_one_time_item_data_for_subscription( WC_Subscription $subscription ): array { $data = []; $currency = $subscription->get_currency(); foreach ( $subscription->get_items() as $item ) { $product = $item->get_product(); $sign_up_fee = (float) WC_Subscriptions_Product::get_sign_up_fee( $product ); $one_time_shipping = WC_Subscriptions_Product::needs_one_time_shipping( $product ); if ( $sign_up_fee ) { $wcpay_item_id = $this->product_service->get_wcpay_product_id_for_item( 'sign_up_fee' ); $data[] = [ 'price_data' => self::format_item_price_data( $currency, $wcpay_item_id, $sign_up_fee ), ]; } if ( $one_time_shipping ) { $wcpay_item_id = $this->product_service->get_wcpay_product_id_for_item( 'shipping' ); $shipping = 0; foreach ( $subscription->get_parent()->get_shipping_methods() as $shipping_method ) { $shipping += $shipping_method->get_total(); } $data[] = [ 'price_data' => self::format_item_price_data( $currency, $wcpay_item_id, $shipping ), ]; } } return $data; } /** * Updates a WCPay subscription. * * @param WC_Subscription $subscription The WC subscription that relates to the WCPay subscription that needs updating. * @param array $data Data to update. * * @return array|null Updated wcpay subscription or null if there was an error. */ private function update_subscription( WC_Subscription $subscription, array $data ) { $wcpay_subscription_id = static::get_wcpay_subscription_id( $subscription ); $response = null; if ( ! $wcpay_subscription_id ) { return; } try { $response = $this->payments_api_client->update_subscription( $wcpay_subscription_id, $data ); } catch ( API_Exception $e ) { Logger::log( sprintf( 'There was a problem updating the WooPayments subscription on server: %s', $e->getMessage() ) ); } return $response; } /** * Set the trial end date for the WCPay subscription (this updates both trial end as well as next payment). * * @param WC_Subscription $subscription WC subscription linked to the WCPay subscription that needs updating. * @param int $timestamp Next payment or trial end timestamp in UTC. * * @return void */ private function set_trial_end_for_subscription( WC_Subscription $subscription, int $timestamp ) { $trial_end = 0 === $timestamp ? 'now' : $timestamp; $this->update_subscription( $subscription, [ 'trial_end' => $trial_end ] ); } /** * Sets the WCPay subscription ID meta for WC subscription. * * @param WC_Subscription $subscription WC Subscription to store meta against. * @param string $value WCPay subscription ID meta value. * * @return void */ private function set_wcpay_subscription_id( WC_Subscription $subscription, string $value ) { $subscription->update_meta_data( self::SUBSCRIPTION_ID_META_KEY, $value ); $subscription->save(); } /** * Sets Stripe subscription item ids on WC order items. * * @param WC_Subscription $subscription The WC Subscription object. * @param array $subscription_items The WCPay Subscription data. * * @return void */ private function set_wcpay_subscription_item_ids( WC_Subscription $subscription, array $subscription_items ) { foreach ( $subscription_items as $item ) { $wcpay_subscription_item_id = $item['id']; $subscription_item_id = isset( $item['metadata']['wc_item_id'] ) ? $item['metadata']['wc_item_id'] : false; if ( $subscription_item_id ) { $subscription_item = $subscription->get_item( $subscription_item_id ); $subscription_item->update_meta_data( self::SUBSCRIPTION_ITEM_ID_META_KEY, $wcpay_subscription_item_id ); $subscription_item->save(); } else { Logger::log( sprintf( // Translators: %s Stripe subscription item ID. __( 'Unable to set subscription item ID meta for WooPayments subscription item %s.', 'woocommerce-payments' ), $wcpay_subscription_item_id ) ); } } } /** * Temporarily allows a subscription to bypass a payment gateway feature support flag. * * Use @see WC_Payments_Subscription_Service::clear_feature_support_exception() to clear it. * * @param WC_Subscription $subscription The subscription to set the exception for. * @param string $feature The feature to allow. */ private function set_feature_support_exception( WC_Subscription $subscription, string $feature ) { $this->feature_support_exceptions[ $subscription->get_id() ][ $feature ] = true; } /** * Clears a gateway support flag exception. * * Use @see WC_Payments_Subscription_Service::set_feature_support_exception() to set one. * * @param WC_Subscription $subscription The subscription to remove the exception for. * @param string $feature The feature. */ private function clear_feature_support_exception( WC_Subscription $subscription, string $feature ) { unset( $this->feature_support_exceptions[ $subscription->get_id() ][ $feature ] ); } /** * Generates the metadata for a given WC_Order_Item * * @param WC_Order_Item|WC_Order_Item_Tax $item The order item to generate the metadata for. Can be any order item type including tax, shipping and fees. * @return array Item metadata. */ private function get_item_metadata( WC_Order_Item $item ) { $metadata = [ 'wc_item_id' => $item->get_id() ]; switch ( $item->get_type() ) { case 'tax': $metadata['wc_rate_id'] = $item->get_rate_id(); $metadata['code'] = $item->get_rate_code(); $metadata['rate'] = $item->get_rate_percent(); $metadata['is_compound'] = wc_bool_to_string( $item->is_compound() ); break; case 'shipping': $metadata['method'] = $item->get_name(); break; case 'fee': $metadata['type'] = $item->get_name(); break; } return $metadata; } /** * Validates that the data used to create the WCPay Subscription. * * @param array $subscription_data The data used to create a WCPay subscription. * @throws Exception If the subscription data contains invalid or missing data. */ private function validate_subscription_data( $subscription_data ) { if ( empty( $subscription_data['customer'] ) ) { throw new Exception( 'The "customer" arg is required to create the subscription.' ); } if ( ! isset( $subscription_data['items'] ) ) { throw new Exception( 'The "items" arg is required to create the subscription.' ); } foreach ( $subscription_data['items'] as $item_data ) { $required_price_keys = [ 'currency', 'product', 'recurring' ]; $required_period_keys = [ 'interval', 'interval_count' ]; $errors = []; if ( ! isset( $item_data['price_data']['unit_amount_decimal'] ) ) { $errors[] = 'unit_amount_decimal'; } foreach ( $required_price_keys as $required_key ) { if ( empty( $item_data['price_data'][ $required_key ] ) ) { $errors[] = $required_key; } } foreach ( $required_period_keys as $required_price_key ) { if ( empty( $item_data['price_data']['recurring'][ $required_price_key ] ) ) { $errors[] = $required_price_key; } } if ( ! empty( $errors ) ) { $error_message = count( $errors ) > 1 ? 'The "%s" line item properties are required to create the subscription.' : 'The "%s" line item property is required to create the subscription.'; throw new Exception( sprintf( $error_message, implode( '", "', $errors ) ) ); } $billing_period = $item_data['price_data']['recurring']['interval']; $billing_interval = $item_data['price_data']['recurring']['interval_count']; // Confirm the billing period is valid (no greater than 1 year in length). if ( ! $this->product_service->is_valid_billing_cycle( $billing_period, $billing_interval ) ) { throw new Exception( sprintf( 'The subscription billing period cannot be any longer than one year. A billing period of "every %s %s(s)" was given.', $billing_interval, $billing_period ) ); } } } /** * Determines if the store has any active WCPay subscriptions. * * @return bool True if store has active WCPay subscriptions, otherwise false. */ public static function store_has_active_wcpay_subscriptions() { if ( ! function_exists( 'wcs_get_subscriptions' ) ) { return false; } $active_wcpay_subscriptions = wcs_get_subscriptions( [ 'subscriptions_per_page' => 1, 'subscription_status' => 'active', // Ignoring phpcs warning, we need to search meta. 'meta_query' => [ // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query [ 'key' => self::SUBSCRIPTION_ID_META_KEY, 'compare' => 'EXISTS', ], ], ] ); return ( is_countable( $active_wcpay_subscriptions ) ? count( $active_wcpay_subscriptions ) : 0 ) > 0; } }