get_id()); $this->request_billing_address = true; /** * As the toggle return a string with a int value, * we need to convert this first to int then to bool. */ $this->test_mode = (bool) (int) wu_get_setting("{$id}_sandbox_mode", true); $this->setup_api_keys($id); if (method_exists('Stripe', 'setAppInfo')) { Stripe\Stripe::setAppInfo('WordPress WP Multisite WaaS', wu_get_version(), esc_url(site_url())); } } /** * Setup api keys for stripe. * * @since 2.0.7 * * @param string $id The gateway stripe id. * @return void */ public function setup_api_keys($id = false): void { $id = $id ?: wu_replace_dashes($this->get_id()); if ($this->test_mode) { $this->publishable_key = wu_get_setting("{$id}_test_pk_key", ''); $this->secret_key = wu_get_setting("{$id}_test_sk_key", ''); } else { $this->publishable_key = wu_get_setting("{$id}_live_pk_key", ''); $this->secret_key = wu_get_setting("{$id}_live_sk_key", ''); } if ($this->secret_key && Stripe\Stripe::getApiKey() !== $this->secret_key) { Stripe\Stripe::setApiKey($this->secret_key); Stripe\Stripe::setApiVersion('2019-05-16'); } } /** * Adds additional hooks. * * @since 2.0.0 * @return void */ public function hooks(): void { add_action('wu_after_save_settings', [$this, 'install_webhook'], 10, 3); add_action('wu_after_save_settings', [$this, 'check_keys_status'], 10, 3); add_filter('wu_pre_save_settings', [$this, 'fix_saving_settings'], 10, 3); add_filter('wu_element_get_site_actions', [$this, 'add_site_actions'], 10, 4); /** * We need to check if we should redirect after instantiate the Currents */ add_action('init', [$this, 'maybe_redirect_to_portal'], 11); add_action('wp', [$this, 'maybe_redirect_to_portal'], 11); } /** * Adds Stripe Billing Portal link to the site actions. * * @since 2.1.2 * * @param array $actions The site actions. * @param array $atts The widget attributes. * @param Site $site The current site object. * @param Membership $membership The current membership object. * @return array */ function add_site_actions($actions, $atts, $site, $membership) { $gateway_id = wu_replace_dashes($this->id); if ( ! wu_get_setting("{$gateway_id}_enable_portal")) { return $actions; } $payment_gateway = $membership ? $membership->get_gateway() : false; if (wu_get_isset($atts, 'show_change_payment_method') && in_array($payment_gateway, $this->other_ids, true)) { $s_subscription_id = $membership->get_gateway_subscription_id(); if ( ! empty($s_subscription_id)) { $actions['change_payment_method'] = [ 'label' => __('Change Payment Method', 'wp-ultimo'), 'icon_classes' => 'dashicons-wu-edit wu-align-middle', 'href' => add_query_arg( [ 'wu-stripe-portal' => true, 'membership' => $membership->get_hash(), ] ), ]; } } return $actions; } /** * Maybe redirect to the Stripe Billing Portal. * * @since 2.1.2 * @return void */ public function maybe_redirect_to_portal(): void { if ( ! wu_request('wu-stripe-portal')) { return; } $membership = WP_Ultimo()->currents->get_membership(); if ( ! $membership) { return; } $customer = wu_get_current_customer(); if ( ! is_super_admin() && (! $customer || $customer->get_id() !== $membership->get_customer_id())) { wp_die(__('You are not allowed to modify this membership.', 'wp-ultimo')); } $gateway_id = $membership->get_gateway(); $gateway = wu_get_gateway($gateway_id); $allowed_payment_method_types = apply_filters( 'wu_stripe_checkout_allowed_payment_method_types', [ 'card', ], $gateway ); $customer_id = $membership->get_customer_id(); $s_customer_id = $membership->get_gateway_customer_id(); $return_url = remove_query_arg('wu-stripe-portal', wu_get_current_url()); // If customer is not set, get from checkout session if (empty($s_customer_id)) { $subscription_data = [ 'payment_method_types' => $allowed_payment_method_types, 'mode' => 'setup', 'success_url' => $return_url, 'cancel_url' => wu_get_current_url(), 'billing_address_collection' => 'required', 'client_reference_id' => $customer_id, 'customer' => $s_customer_id, ]; $session = Stripe\Checkout\Session::create($subscription_data); $s_customer_id = $session->subscript_ion_data['customer']; } $portal_config_id = get_site_option('wu_stripe_portal_config_id'); if ( ! $portal_config_id) { $portal_config = Stripe\BillingPortal\Configuration::create( [ 'features' => [ 'invoice_history' => [ 'enabled' => true, ], 'payment_method_update' => [ 'enabled' => true, ], 'subscription_cancel' => [ 'enabled' => true, 'mode' => 'at_period_end', 'cancellation_reason' => [ 'enabled' => true, 'options' => [ 'too_expensive', 'missing_features', 'switched_service', 'unused', 'customer_service', 'too_complex', 'other', ], ], ], ], 'business_profile' => [ 'headline' => __('Manage your membership payment methods.', 'wp-ultimo'), ], ] ); $portal_config_id = $portal_config->id; update_site_option('wu_stripe_portal_config_id', $portal_config_id); } $subscription_data = [ 'return_url' => $return_url, 'customer' => $s_customer_id, 'configuration' => $portal_config_id, ]; $session = Stripe\BillingPortal\Session::create($subscription_data); wp_redirect($session->url); exit; } /** * Allows Gateways to override the gateway title. * * @since 2.0.0 * * @return string */ public function get_public_title() { $gateway_id = wu_replace_dashes($this->id); return wu_get_setting("{$gateway_id}_public_title", __('Credit Card', 'wp-ultimo')); } /** * Adds the Stripe Gateway settings to the settings screen. * * @since 2.0.0 * @return void */ public function settings(): void { $gateway_id = wu_replace_dashes($this->id); wu_register_settings_field( 'payment-gateways', "{$gateway_id}_enable_portal", [ 'title' => __('Use Stripe Billing Portal', 'wp-ultimo'), 'desc' => 'Add a link to the Billing Portal in the site actions widget so your customer can change the payment method used in Stripe (additional charges from Stripe could be applied).', 'type' => 'toggle', 'default' => 0, 'capability' => 'manage_api_keys', 'require' => [ 'active_gateways' => $this->get_id(), ], ] ); } /** * Checks if we already have a webhook listener installed. * * @since 2.0.0 * @return WebhookEndpoint|\WP_Error|false */ public function has_webhook_installed() { try { $webhook_url = $this->get_webhook_listener_url(); $this->setup_api_keys(); $search_webhook = Stripe\WebhookEndpoint::all( [ 'limit' => 100, ] ); $set_webhook_endpoint = false; foreach ($search_webhook as $webhook_endpoint) { if ($webhook_endpoint->url === $webhook_url) { return $webhook_endpoint; } } } catch (\Throwable $e) { $error_code = $e->getCode(); // WP Error did not handle empty error code if (empty($error_code)) { if (method_exists($e, 'getHttpStatus')) { $error_code = $e->getHttpStatus(); } else { $error_code = 500; } } return new \WP_Error($error_code, $e->getMessage()); } return false; } /** * Fix stripe settings * * @since 2.0.18 * * @param array $settings The final settings array being saved, containing ALL options. * @param array $settings_to_save Array containing just the options being updated. * @param array $saved_settings Array containing the original settings. * @return array */ public function fix_saving_settings($settings, $settings_to_save, $saved_settings) { $id = wu_replace_dashes($this->get_id()); $active_gateways = (array) wu_get_isset($settings_to_save, 'active_gateways', []); if ( ! in_array($this->get_id(), $active_gateways, true)) { return $settings; } if ( ! isset($settings_to_save[ "{$id}_sandbox_mode" ])) { $settings[ "{$id}_sandbox_mode" ] = false; } // Unset webhook url to show the get_webhook_listener_url value to customer unset($settings[ "{$id}_webhook_listener_explanation" ]); return $settings; } /** * Check stripe API keys * * @since 2.0.18 * * @param array $settings The final settings array being saved, containing ALL options. * @param array $settings_to_save Array containing just the options being updated. * @param array $saved_settings Array containing the original settings. * @return void */ public function check_keys_status($settings, $settings_to_save, $saved_settings): void { $id = wu_replace_dashes($this->get_id()); $active_gateways = (array) wu_get_isset($settings_to_save, 'active_gateways', []); if ( ! in_array($this->get_id(), $active_gateways, true)) { return; } $stripe_mode = (bool) (int) $settings[ "{$id}_sandbox_mode" ] ? 'test' : 'live'; /* * Checked if the Stripe Settings changed, so we can install webhooks. */ $changed_settings = [ $settings[ "{$id}_sandbox_mode" ], $settings[ "{$id}_{$stripe_mode}_pk_key" ], $settings[ "{$id}_{$stripe_mode}_sk_key" ], ]; $original_settings = [ $saved_settings[ "{$id}_sandbox_mode" ], $saved_settings[ "{$id}_{$stripe_mode}_pk_key" ], $saved_settings[ "{$id}_{$stripe_mode}_sk_key" ], ]; if ($changed_settings == $original_settings) { // phpcs:ignore return; } try { Stripe\Stripe::setApiKey($settings[ "{$id}_{$stripe_mode}_sk_key" ]); Stripe\Token::create( [ 'card' => [ 'number' => '4242424242424242', 'exp_month' => 7, 'exp_year' => 2028, 'cvc' => '314', ], ] ); wu_save_setting("{$id}_{$stripe_mode}_sk_key_status", ''); } catch (\Throwable $e) { if (str_starts_with($e->getMessage(), 'Invalid API Key provided')) { /** * The secret key is invalid; */ $t = "{$id}_{$stripe_mode}_sk_key_status"; wu_save_setting("{$id}_{$stripe_mode}_sk_key_status", __('Invalid API Key provided', 'wp-ultimo')); } } try { Stripe\Stripe::setApiKey($settings[ "{$id}_{$stripe_mode}_pk_key" ]); Stripe\Token::create( [ 'card' => [ 'number' => '4242424242424242', 'exp_month' => 7, 'exp_year' => 2028, 'cvc' => '314', ], ] ); wu_save_setting("{$id}_{$stripe_mode}_pk_key_status", ''); } catch (\Throwable $e) { if (str_starts_with($e->getMessage(), 'Invalid API Key provided')) { /** * The public key is invalid; */ wu_save_setting("{$id}_{$stripe_mode}_pk_key_status", __('Invalid API Key provided', 'wp-ultimo')); } } } /** * Installs webhook urls onto Stripe. * * WP Multisite WaaS will call this whenever settings for this api changes. * That being said, it might be a good idea to check if the webhook already exists * before trying to re-create it. * * Return true for success, or a \WP_Error instance in case of failure. * * @since 2.0.0 * * @param array $settings The final settings array being saved, containing ALL options. * @param array $settings_to_save Array containing just the options being updated. * @param array $saved_settings Array containing the original settings. * @return bool|\WP_Error */ public function install_webhook($settings, $settings_to_save, $saved_settings) { $id = wu_replace_dashes($this->get_id()); $active_gateways = (array) wu_get_isset($settings_to_save, 'active_gateways', []); if ( ! in_array($this->get_id(), $active_gateways, true)) { return false; } /* * Checked if the Stripe Settings changed, so we can install webhooks. */ $changed_settings = [ $settings[ "{$id}_sandbox_mode" ], $settings[ "{$id}_test_pk_key" ], $settings[ "{$id}_test_sk_key" ], $settings[ "{$id}_live_pk_key" ], $settings[ "{$id}_live_sk_key" ], ]; $original_settings = [ $saved_settings[ "{$id}_sandbox_mode" ], $saved_settings[ "{$id}_test_pk_key" ], $saved_settings[ "{$id}_test_sk_key" ], $saved_settings[ "{$id}_live_pk_key" ], $saved_settings[ "{$id}_live_sk_key" ], ]; if ($changed_settings == $original_settings) { // phpcs:ignore return false; } $webhook_url = $this->get_webhook_listener_url(); $existing_webhook = $this->has_webhook_installed(); if (is_wp_error($existing_webhook)) { return $existing_webhook; } $this->setup_api_keys($id); try { /* * If already exists, checks for status */ if ($existing_webhook) { if ($existing_webhook->status === 'disabled') { $status = Stripe\WebhookEndpoint::update( $existing_webhook->id, [ 'status' => 'enabled', ] ); } return true; } /* * Otherwise, create it. */ Stripe\WebhookEndpoint::create( [ 'enabled_events' => ['*'], 'url' => $webhook_url, 'description' => 'Added by WP Multisite WaaS. Required to correctly handle changes in subscription status.', ] ); return true; } catch (\Throwable $e) { $error_code = $e->getCode(); // WP Error did not handle empty error code if (empty($error_code)) { if (method_exists($e, 'getHttpStatus')) { $error_code = $e->getHttpStatus(); } else { $error_code = 500; } } return new \WP_Error($error_code, $e->getMessage()); } } /** * Reflects membership changes on the gateway. * * By default, this method will process tha cancellation of current gateway subscription * * @since 2.1.3 * * @param \WP_Ultimo\Models\Membership $membership The membership object. * @param \WP_Ultimo\Models\Customer $customer The customer object. * @return bool|\WP_Error true if it's all done or error object if something went wrong. */ public function process_membership_update(&$membership, $customer) { $gateway_subscription_id = $membership->get_gateway_subscription_id(); if (empty($gateway_subscription_id)) { return new \WP_Error('wu_stripe_no_subscription_id', __('Error: No gateway subscription ID found for this membership.', 'wp-ultimo')); } $this->setup_api_keys(); try { $subscription = Stripe\Subscription::retrieve($gateway_subscription_id); /** * Generate a temporary wu payment so we can get the correct line items and amounts. * It's important to note that we should only get recurring payments so we can correctly update the subscription. */ $temp_payment = wu_membership_create_new_payment($membership, false, true, false); $line_items = $temp_payment->get_line_items(); $recurring_items = []; $credits = []; $s_coupon = ''; foreach ($line_items as $line_item) { if ($line_item->get_total() < 0) { $credits[] = [ 'amount' => $line_item->get_total(), 'description' => $line_item->get_title(), ]; continue; } $sub_total = $line_item->get_quantity() * $line_item->get_unit_price(); $discounts = $line_item->calculate_discounts($sub_total); $discounted_subtotal = $sub_total - $discounts; // We will probably never enter here but just in case. if ($discounted_subtotal < 0) { continue; } $tax_behavior = ''; $s_tax_rate = false; if ($line_item->is_taxable() && ! empty($line_item->get_tax_rate())) { $tax_behavior = $line_item->get_tax_inclusive() ? 'inclusive' : 'exclusive'; $tax_args = [ 'country' => $membership->get_billing_address()->billing_country, 'tax_rate' => $line_item->get_tax_rate(), 'type' => $line_item->get_tax_type(), 'title' => $line_item->get_tax_label(), 'inclusive' => $line_item->get_tax_inclusive(), ]; $s_tax_rate = $this->maybe_create_tax_rate($tax_args); } $s_price = $this->maybe_create_price( $line_item->get_title(), $discounted_subtotal, $membership->get_currency(), $line_item->get_quantity(), $membership->get_duration(), $membership->get_duration_unit(), $tax_behavior ); $recurring_item = [ 'price' => $s_price, ]; if ($s_tax_rate) { $recurring_item['tax_rates'] = [$s_tax_rate]; } $recurring_items[] = $recurring_item; } if ( ! empty($credits)) { if (count($credits) > 1) { $credit = [ 'amount' => array_sum(wp_list_pluck($credits, 'amount')), 'description' => __('Amount adjustment based on custom deal.', 'wp-ultimo'), ]; } else { $credit = $credits[0]; } $s_amount = - round($credit['amount'] * wu_stripe_get_currency_multiplier()); if ($s_amount >= 1) { $currency = strtolower($membership->get_currency()); $coupon_data = [ 'id' => sprintf('%s-%s-%s', $s_amount, $currency, 'forever'), 'name' => $credit['description'], 'amount_off' => $s_amount, 'duration' => 'forever', 'currency' => $currency, ]; $s_coupon = $this->get_stripe_coupon($coupon_data); } } $existing_items = array_map( fn($item) => [ 'id' => $item->id, 'deleted' => true, ], $subscription->items->data ); $update_data = [ 'items' => array_merge($recurring_items, $existing_items), 'proration_behavior' => 'none', 'coupon' => $s_coupon, ]; $subscription = Stripe\Subscription::update($gateway_subscription_id, $update_data); if (empty($s_coupon) && ! empty($subscription->discount)) { $stripe = new Stripe\StripeClient($this->secret_key); $stripe->subscriptions->deleteDiscount($gateway_subscription_id); } } catch (\Throwable $e) { return new \WP_Error('wu_stripe_update_error', $e->getMessage()); } return true; } /** * Run preparations before checkout processing. * * This runs during the checkout form validation * and it is a great chance to do preflight stuff * if the gateway requires it. * * If you return an array here, Ultimo * will append the key => value of that array * as hidden fields to the checkout field, * and those get submitted with the rest of the form. * * As an example, this is how we create payment * intents for Stripe to make the experience more * streamlined. * * @since 2.0.0 * @return void|array */ public function run_preflight() {} /** * Get or create Stripe Customer. * * @since 2.0.0 * * @param integer $customer_id WP Multisite WaaS customer ID. * @param integer $user_id The WordPress user ID. * @param integer $stripe_customer_id The Stripe Customer ID. * @return \Stripe\Customer|\WP_Error */ public function get_or_create_customer($customer_id = 0, $user_id = 0, $stripe_customer_id = 0) { /* * Sets flag to control if we need * to create a new customer or not. */ $customer_exists = false; /* * Use the WP Multisite WaaS customer ID to search on the * database for an existing Stripe customer id. */ if (empty($stripe_customer_id)) { $stripe_customer_id = wu_get_customer_gateway_id($customer_id, ['stripe', 'stripe-checkout']); } /** * Ensure the correct api keys are set */ $this->setup_api_keys(); /* * We found a Stripe Customer ID! * * Now we have to use it to try and retrieve a * stripe customer object. */ if ($stripe_customer_id) { try { $stripe_customer = Stripe\Customer::retrieve($stripe_customer_id); /* * If the customer was deleted, we * cannot use it again... */ if ( ! isset($stripe_customer->deleted) || ! $stripe_customer->deleted) { $customer_exists = true; } } catch (\Exception $e) { /** * Silence is golden. */ } } /* * No customer found. * * In this scenario, we'll need to create a new one. */ if (empty($customer_exists)) { try { /* * Pass the name and email to stripe. */ $customer_args = [ 'email' => $this->customer->get_email_address(), 'name' => $this->customer->get_display_name(), 'address' => $this->convert_to_stripe_address($this->customer->get_billing_address()), ]; /* * Filters the customer creation arguments. */ $customer_args = apply_filters('wu_stripe_customer_create_args', $customer_args, $this); /* * Finally, try to create it. */ $stripe_customer = Stripe\Customer::create($customer_args); } catch (\Exception $e) { $error_code = $e->getCode(); // WP Error did not handle empty error code if (empty($error_code)) { if (method_exists($e, 'getHttpStatus')) { $error_code = $e->getHttpStatus(); } else { $error_code = 500; } } return new \WP_Error($error_code, $e->getMessage()); } } return $stripe_customer; } /** * Convert our billing address to the format Stripe is expecting. * * @since 2.0.11 * * @param \WP_Ultimo\Objects\Billing_Address $billing_address The WP Multisite WaaS billing address. * @return array */ public function convert_to_stripe_address($billing_address) { return [ 'city' => $billing_address->billing_city, 'country' => $billing_address->billing_country, 'line1' => $billing_address->billing_address_line_1, 'line2' => $billing_address->billing_address_line_2, 'postal_code' => $billing_address->billing_zip_code, 'state' => $billing_address->billing_state, ]; } /** * Returns an array with customer meta data. * * @since 2.0.0 * @return array */ protected function get_customer_metadata() { $meta_data = [ 'key' => $this->membership->get_id(), 'email' => $this->customer->get_email_address(), 'membership_id' => $this->membership->get_id(), 'customer_id' => $this->customer->get_id(), 'payment_id' => $this->payment->get_id(), ]; return $meta_data; } /** * Process a checkout. * * It takes the data concerning * a new checkout and process it. * * @since 2.0.0 * * @param \WP_Ultimo\Models\Payment $payment The payment associated with the checkout. * @param \WP_Ultimo\Models\Membership $membership The membership. * @param \WP_Ultimo\Models\Customer $customer The customer checking out. * @param \WP_Ultimo\Checkout\Cart $cart The cart object. * @param string $type The checkout type. Can be 'new', 'retry', 'upgrade', 'downgrade', 'addon'. * * @throws \Exception When a stripe API error is caught. * * @return void */ public function process_checkout($payment, $membership, $customer, $cart, $type) {} /** * Create a recurring subscription in Stripe. * * @since 2.0.0 * * @param \WP_Ultimo\Models\Membership $membership The membership. * @param \WP_Ultimo\Checkout\Cart $cart The cart object. * @param Stripe\PaymentMethod $payment_method The save payment method on Stripe. * @param Stripe\Customer $s_customer The Stripe customer. * * @return Stripe\Subscription|bool The Stripe subscription object or false if the creation is running in another process. */ protected function create_recurring_payment($membership, $cart, $payment_method, $s_customer) { /** * First we need to ensure that this process is not running in another place. */ $internal_key = "wu_stripe_recurring_creation_{$membership->get_id()}"; $has_transient = get_site_transient($internal_key); if ($has_transient) { /** * Process already start at another point (webhook or sync call). */ return false; } /** * Set transient to avoid multiple calls. */ set_site_transient($internal_key, true, 120); /* * We need to create a cart description that Stripe understands. */ $stripe_cart = $this->build_stripe_cart($cart); /* * The cart creation process might run into * errors, and in that case, it will * return a WP_Error object. */ if (is_object($stripe_cart) && is_wp_error($stripe_cart)) { throw new \Exception($stripe_cart->get_error_message()); } // Otherwise, use the calculated expiration date of the membership, modified to current time instead of 23:59. $billing_date = $cart->get_billing_start_date(); $base_date = $billing_date ?: $cart->get_billing_next_charge_date(); $datetime = \DateTime::createFromFormat('U', $base_date); $current_time = getdate(); $datetime->setTime($current_time['hours'], $current_time['minutes'], $current_time['seconds']); $start_date = $datetime->getTimestamp() - HOUR_IN_SECONDS; // Reduce by 60 seconds to account for inaccurate server times. if (empty($payment_method)) { throw new \Exception(__('Invalid payment method', 'wp-ultimo')); } /* * Subscription arguments for Stripe */ $sub_args = [ 'items' => array_values($stripe_cart), 'default_payment_method' => $payment_method->id, 'prorate' => false, 'metadata' => $this->get_customer_metadata(), ]; /* * Now determine if we use `trial_end` or `billing_cycle_anchor` to schedule the start of the * subscription. * * If this is an actual trial, then we use `trial_end`. * * Otherwise, billing cycle anchor is preferable because that works with Stripe MRR. * However, the anchor date cannot be further in the future than a normal billing cycle duration. * If that's the case, then we have to use trial end instead. */ $stripe_max_anchor = $this->get_stripe_max_billing_cycle_anchor($cart->get_duration(), $cart->get_duration_unit(), 'now'); if ($cart->has_trial() || $start_date > $stripe_max_anchor->getTimestamp()) { $sub_args['trial_end'] = $start_date; } else { $sub_args['billing_cycle_anchor'] = $start_date; } /* * Sets the billing anchor. */ $set_anchor = isset($sub_args['billing_cycle_anchor']); /** * If we have a nun recurring discount code we need to add here to use in first payment. */ if ($cart->has_trial()) { /** * If we have pro-rata credit (in case of an upgrade, for example) * try to create a custom coupon. */ $s_coupon = $this->get_credit_coupon($cart); if ($s_coupon) { $sub_args['coupon'] = $s_coupon; } } /* * Filters the Stripe subscription arguments. */ $sub_args = apply_filters('wu_stripe_create_subscription_args', $sub_args, $this); /* * If we have a `billing_cycle_anchor` AND a `trial_end`, then we need to unset whichever one * we set, and leave the customer's custom one in tact. * * This is done to account for people who filter the arguments to customize the next bill * date. If `trial_end` is used in conjunction with `billing_cycle_anchor` then it will create * unexpected results and the next bill date will not be what they want. * * This may not be completely perfect but it's the best way to try to account for any errors. */ if ( ! empty($sub_args['trial_end']) && ! empty($sub_args['billing_cycle_anchor'])) { /* * If we set an anchor, remove that, because * this means the customer has set their own `trial_end`. */ if ($set_anchor) { unset($sub_args['billing_cycle_anchor']); } else { /* * We set a trial, which means the customer * has set their own `billing_cycle_anchor`. */ unset($sub_args['trial_end']); } } $sub_options = apply_filters( 'wu_stripe_create_subscription_options', [ 'idempotency_key' => wu_stripe_generate_idempotency_key($sub_args), ] ); try { /* * Tries to create the subscription * on Stripe! */ $subscription = $s_customer->subscriptions->create($sub_args, $sub_options); } catch (Stripe\Exception\IdempotencyException $exception) { /** * In this case, the subscription is being created by another call. */ return false; } // If we have a trial we need to add fees to next invoice. if ($cart->has_trial()) { $currency = strtolower($cart->get_currency()); $fees = array_filter($cart->get_line_items_by_type('fee'), fn($fee) => ! $fee->is_recurring()); $s_fees = []; foreach ($fees as $fee) { $amount = $fee->get_quantity() * $fee->get_unit_price(); $tax_behavior = ''; $s_tax_rate = false; if ($fee->is_taxable() && ! empty($fee->get_tax_rate())) { $tax_behavior = $fee->get_tax_inclusive() ? 'inclusive' : 'exclusive'; $tax_args = [ 'country' => $membership->get_billing_address()->billing_country, 'tax_rate' => $fee->get_tax_rate(), 'type' => $fee->get_tax_type(), 'title' => $fee->get_tax_label(), 'inclusive' => $fee->get_tax_inclusive(), ]; $s_tax_rate = $this->maybe_create_tax_rate($tax_args); } $s_price = $this->maybe_create_price( $fee->get_title(), $amount, $currency, 1, false, false, $tax_behavior, ); $s_fee = [ 'price' => $s_price, ]; if ($s_tax_rate) { $s_fee['tax_rates'] = [$s_tax_rate]; } $s_fees[] = $s_fee; } if ( ! empty($s_fees)) { $options = [ 'add_invoice_items' => $s_fees, ]; $sub_options = [ 'idempotency_key' => wu_stripe_generate_idempotency_key(array_merge(['s_subscription' => $subscription->id], $options)), ]; try { $subscription = Stripe\Subscription::update($subscription->id, $options, $sub_options); } catch (Stripe\Exception\IdempotencyException $exception) { /** * In this case, the subscription is being updated by another call. */ return false; } } } return $subscription; } /** * Checks if we need to create a pro-rate/credit coupon based on the cart data. * * Will return an array with coupon arguments for stripe if * there is credit to be added and false if not. * * @since 2.0.0 * * @param \WP_Ultimo\Checkout\Cart $cart The current cart. * @return string|false */ protected function get_credit_coupon($cart) { $amount = 0; foreach ($cart->get_line_items() as $line_item) { if ($line_item->get_total() < 0) { $amount += $line_item->get_total(); } elseif ( ! $line_item->should_apply_discount_to_renewals()) { $amount += - $line_item->get_discount_total(); } } if (empty($amount)) { return false; } $s_amount = - round($amount * wu_stripe_get_currency_multiplier()); $currency = strtolower($cart->get_currency()); $coupon_data = [ 'id' => sprintf('%s-%s-%s', $s_amount, $currency, 'once'), 'name' => __('Account credit and other discounts', 'wp-ultimo'), 'amount_off' => $s_amount, 'duration' => 'once', 'currency' => $currency, ]; return $this->get_stripe_coupon($coupon_data); } /** * Checks to see if the coupon exists, and if so, returns the ID of * that coupon. If not, a new coupon is created. * * @since 2.0.18 * * @param array $coupon_data The cart/order object. * @return string */ protected function get_stripe_coupon($coupon_data) { // First check to see if a coupon exists with this ID. If so, return that. try { $coupon = Stripe\Coupon::retrieve($coupon_data['id']); Stripe\Coupon::update( $coupon->id, [ 'name' => $coupon_data['name'], ] ); return $coupon->id; } catch (\Exception $e) { // silence is golden } // Otherwise, create a new plan. try { $coupon = Stripe\Coupon::create($coupon_data); return $coupon->id; } catch (\Exception $e) { $error_code = $e->getCode(); // WP Error did not handle empty error code if (empty($error_code)) { if (method_exists($e, 'getHttpStatus')) { $error_code = $e->getHttpStatus(); } else { $error_code = 500; } } wu_log_add('stripe', sprintf('Error creating Stripe coupon. Code: %s; Message: %s', $error_code, $e->getMessage()), LogLevel::ERROR); throw $e; } } /** * Builds the non-recurring list of items to be paid on Stripe. * * @since 2.0.0 * * @param \WP_Ultimo\Checkout\Cart $cart The cart/order object. * @param bool $include_recurring_products If we should include recurring items as non-recurring. */ protected function build_non_recurring_cart($cart, $include_recurring_products = false): array { $cart_items = []; foreach ($cart->get_line_items() as $line_item) { /* * Skip recurring items */ if ($line_item->is_recurring() && false === $include_recurring_products) { continue; } /* * Skip negative items. * In cases like this, we need to generate a coupon code. */ if ($line_item->get_unit_price() < 0) { continue; } $cart_items[ $line_item->get_id() ] = [ 'name' => $line_item->get_title(), 'quantity' => $line_item->get_quantity(), 'amount' => $line_item->get_unit_price() * wu_stripe_get_currency_multiplier(), 'currency' => strtolower($cart->get_currency()), ]; $description = $line_item->get_description(); if ( ! empty($description)) { $cart_items[ $line_item->get_id() ]['description'] = $description; } /* * Now, we handle the taxable status * of the payment. * * We might need to create tax rates on * Stripe and apply it on the subscription cart. */ if ($line_item->is_taxable() && ! empty($line_item->get_tax_rate())) { $tax_args = [ 'country' => $this->membership->get_billing_address()->billing_country, 'tax_rate' => $line_item->get_tax_rate(), 'type' => $line_item->get_tax_type(), 'title' => $line_item->get_tax_label(), 'inclusive' => $line_item->get_tax_inclusive(), ]; $cart_items[ $line_item->get_id() ]['tax_rates'] = [$this->maybe_create_tax_rate($tax_args)]; } } return array_values($cart_items); } /** * Converts the WP Multisite WaaS cart into Stripe Sub arguments. * * @since 2.0.0 * * @param \WP_Ultimo\Checkout\Cart $cart The cart object. * @return array */ protected function build_stripe_cart($cart) { /* * Set up a recurring subscription in Stripe with * a delayed start date. * * All start dates are delayed one cycle because we use a * one-time payment for the first charge. */ $plans = []; $all_products = $cart->get_all_products(); foreach ($cart->get_line_items() as $line_item) { $product = $line_item->get_product(); if ( ! $product) { continue; } /* * Exclude products that are not recurring. */ if ( ! $product->is_recurring()) { continue; } $amount = $product->get_amount(); $discount_code = $cart->get_discount_code(); if ($discount_code) { if ($discount_code->should_apply_to_renewals() && $cart->get_cart_type() !== 'renewal') { $amount = wu_get_discounted_price($amount, $discount_code->get_value(), $discount_code->get_type(), false); } } try { /* * We might need to create the plan on Stripe. * Otherwise, we'll get the stripe plan id in here. */ $plan_id = $this->maybe_create_plan( [ 'name' => $product->get_name(), 'price' => $amount, 'interval' => $product->get_duration_unit(), 'interval_count' => $product->get_duration(), ] ); if (is_wp_error($plan_id)) { return $plan_id; } /* * Adds the new plan ID to the subscription cart. */ $plans[ $plan_id ] = [ 'plan' => $plan_id, ]; } catch (\Exception $e) { $error_message = sprintf('Failed to create subscription for membership #%d. Message: %s', $this->membership->get_id(), $e->getMessage()); return new \WP_Error('plan-creation-failed', $error_message); } /* * Now, we handle the taxable status * of the payment. * * We might need to create tax rates on * Stripe and apply it on the subscription cart. */ if ($line_item->is_taxable() && ! empty($line_item->get_tax_rate())) { $tax_args = [ 'country' => $this->membership->get_billing_address()->billing_country, 'tax_rate' => $line_item->get_tax_rate(), 'type' => $line_item->get_tax_type(), 'title' => $line_item->get_tax_label(), 'inclusive' => $line_item->get_tax_inclusive(), ]; $plans[ $plan_id ]['tax_rates'] = [$this->maybe_create_tax_rate($tax_args)]; } } return $plans; } /** * Converts the Stripe invoice line items into WP Multisite WaaS line items. * * @since 2.0.19 * * @param Stripe\InvoiceLineItem[] $invoice_line_items The line items array. * @return array */ protected function get_ultimo_line_items_from_invoice($invoice_line_items) { $line_items = []; $membership_products = []; if ($this->membership) { $m_products = $this->membership->get_all_products(); foreach ($m_products as $array) { $membership_products[ $array['product']->get_name() ] = $array['product']; } } foreach ($invoice_line_items as $s_line_item) { $currency = strtoupper((string) $s_line_item->currency); $currency_multiplier = wu_stripe_get_currency_multiplier($currency); $quantity = $s_line_item->quantity; $description_pattern = "/{$quantity} × (.*) - .*/"; $title = preg_replace($description_pattern, '$1', (string) $s_line_item->description); $line_item_data = [ 'title' => $title, 'description' => $s_line_item->description, 'tax_inclusive' => $s_line_item->amount !== $s_line_item->amount_excluding_tax, 'unit_price' => $s_line_item->unit_amount_excluding_tax / $currency_multiplier, 'quantity' => $quantity, ]; if (wu_get_isset($membership_products, $title)) { $line_item_data['product'] = wu_get_isset($membership_products, $title); } $line_item = new Line_Item($line_item_data); $subtotal = $s_line_item->amount_excluding_tax / $currency_multiplier; $tax_total = ($s_line_item->amount - $s_line_item->amount_excluding_tax) / $currency_multiplier; $total = $s_line_item->amount / $currency_multiplier; // Set this values after generate the line item to bypass the recalculate_totals $line_item->attributes( [ 'discount_total' => 0, 'subtotal' => $subtotal, 'tax_total' => $tax_total, 'total' => $total, ] ); $line_items[] = $line_item; } return $line_items; } /** * Saves a payment method to a customer on Stripe. * * @since 2.0.0 * * @param Stripe\Payment_Intent $payment_intent The payment intent. * @param Stripe\Customer $s_customer The stripe customer. * @return Stripe\Payment_Method */ protected function save_payment_method($payment_intent, $s_customer) { $payment_method = false; try { $payment_method = Stripe\PaymentMethod::retrieve($payment_intent->payment_method); if (empty($payment_method->customer)) { $payment_method->attach( [ 'customer' => $s_customer->id, ] ); } /* * Update remote payment methods. */ Stripe\Customer::update( $s_customer->id, [ 'invoice_settings' => [ 'default_payment_method' => $payment_intent->payment_method, ], ] ); /* * De-dupe payment methods. * * If someone re-registers with the same card details they've used in the past, Stripe * will actually create a whole new payment method object with the same fingerprint. * This could result in the same card being added to the customer's payment methods in * Stripe, which is kind of annoying. So we de-dupe them to make sure one customer only * has each payment method listed once. Hopefully Stripe will handle this automatically * in the future. */ $customer_payment_methods = Stripe\PaymentMethod::all( [ 'customer' => $s_customer->id, 'type' => 'card', ] ); if ( ! empty($customer_payment_methods->data)) { foreach ($customer_payment_methods->data as $existing_method) { /* * Detach if the fingerprint matches but payment method ID is different. */ if ($existing_method->card->fingerprint === $payment_method->card->fingerprint && $existing_method->id !== $payment_method->id) { $existing_method->detach(); } } } } catch (\Exception $e) { $error = sprintf('Stripe Gateway: Failed to attach payment method to customer while activating membership #%d. Message: %s', 0, $e->getMessage()); wu_log_add('stripe', $error, LogLevel::ERROR); } return $payment_method; } /** * Maybe cancel old subscriptions. * * @since 2.0.0 * * @param Stripe\Customer $s_customer The stripe customer. * @return void */ public function maybe_cancel_old_subscriptions($s_customer): void { $allow_multiple_membership = wu_multiple_memberships_enabled(); try { // Set up array of subscriptions we cancel below so we don't try to cancel the same one twice. $cancelled_subscriptions = []; // Clean up any past due or unpaid subscriptions. We do this to ensure we don't end up with duplicates. $subscriptions = $s_customer->subscriptions->all(); foreach ( $subscriptions->data as $subscription ) { // Cancel subscriptions with the RCP metadata present and matching member ID. if ( ! empty($subscription->metadata) ) { $customer_id = (int) $subscription->metadata['customer_id']; // Legacy WP Multisite WaaS uses user_id $user_id = (int) $subscription->metadata['user_id']; if (0 === $customer_id && 0 === $user_id) { continue; } if ($this->customer->get_id() !== $customer_id && $this->customer->get_user_id() !== $user_id) { continue; } $membership_id = (int) $subscription->metadata['membership_id']; if ($allow_multiple_membership && $this->membership->get_id() !== $membership_id) { continue; } if (0 === $membership_id && 0 === $customer_id) { /** * If we do not have a $membership_id it can be a legacy subscription. * The best way to check this is checking if the plan in Stripe haves * a plan_id on metadata (value used on legacy) */ $stop_here = true; // Check if it is not a ultimo subscription foreach ($subscription->items->data as $item) { if ( ! empty($item->plan) && ! empty($item->plan->metadata) && isset($item->plan->metadata['plan_id'])) { if ( wu_get_product_by('migrated_from_id', $item->plan->metadata['plan_id']) ) { $stop_here = false; break; } } } if ($stop_here) { continue; } } // Check if membership exist and is from this customer before delete subscription if (0 !== $membership_id && $this->membership->get_id() !== $membership_id) { $membership_from_s = wu_get_membership($membership_id); if ( ! $membership_from_s || $membership_from_s->get_customer_id() !== $customer_id) { continue; } } $subscription->cancel(); $cancelled_subscriptions[] = $subscription->id; wu_log_add('stripe', sprintf('Stripe Gateway: Cancelled Stripe subscription %s.', $subscription->id)); continue; } } } catch ( \Exception $e ) { wu_log_add('stripe', sprintf('Stripe Gateway: Subscription cleanup failed for customer #%d. Message: %s', $this->customer->get_id(), $e->getMessage()), LogLevel::ERROR); } } /** * Process a refund. * * It takes the data concerning * a refund and process it. * * @since 2.0.0 * * @param float $amount The amount to refund. * @param \WP_Ultimo\Models\Payment $payment The payment associated with the checkout. * @param \WP_Ultimo\Models\Membership $membership The membership. * @param \WP_Ultimo\Models\Customer $customer The customer checking out. * @return void|bool */ public function process_refund($amount, $payment, $membership, $customer): bool { $gateway_payment_id = $payment->get_gateway_payment_id(); if (empty($gateway_payment_id)) { throw new \Exception(__('Gateway payment ID not found. Cannot process refund automatically.', 'wp-ultimo')); } /** * Ensure the correct api keys are set */ $this->setup_api_keys(); /* * Check if we have an invoice, * or a charge at hand. */ if (str_starts_with((string) $gateway_payment_id, 'ch_')) { $charge_id = $gateway_payment_id; } elseif (str_starts_with((string) $gateway_payment_id, 'in_')) { $invoice = Stripe\Invoice::retrieve($gateway_payment_id); $gateway_payment_id = $invoice->charge; } else { throw new Exception(__('Gateway payment ID not valid.', 'wp-ultimo')); } /* * We need to normalize the value * for Stripe, which usually works * in cents. */ $normalize_amount = $amount * wu_stripe_get_currency_multiplier(); Stripe\Refund::create( [ 'charge' => $charge_id, 'amount' => $normalize_amount, ] ); /* * You might be asking why we are not * calling $payment->refund($amount) to * update the payment status. * * We will do that once Stripe tells us * that the refund was successful. */ return true; } /** * Process a cancellation. * * It takes the data concerning * a membership cancellation and process it. * * @since 2.0.0 * * @param \WP_Ultimo\Models\Membership $membership The membership. * @param \WP_Ultimo\Models\Customer $customer The customer checking out. * @return void|bool */ public function process_cancellation($membership, $customer) { $subscription_id = $membership->get_gateway_subscription_id(); if ( ! empty($subscription_id)) { /** * Ensure the correct api keys are set */ $this->setup_api_keys(); try { $subscription = Stripe\Subscription::retrieve($subscription_id); if ($subscription->status !== 'canceled') { $subscription->cancel(); } } catch (\Exception $e) { wu_log_add('stripe', sprintf('Stripe Gateway: Failed to cancel subscription %s. Message: %s', $subscription_id, $e->getMessage()), LogLevel::ERROR); return false; } } } /** * Attempt to guess the maximum `billing_cycle_anchor` Stripe will allow us to set, given a signup date * and billing cycle interval. * * @param int $interval Billing cycle interval. * @param string $interval_unit Billing cycle interval unit. * @param string $signup_date Signup date that can be parsed by `strtotime()`. Will almost always be * `now`, but can be overridden for help in unit tests. * * @since 2.0.0 * @return DateTime */ public function get_stripe_max_billing_cycle_anchor($interval, $interval_unit, $signup_date = 'now') { try { $signup_date = new \DateTimeImmutable($signup_date); } catch (Exception $exception) { $signup_date = new \DateTimeImmutable(); } $stripe_max_anchor = $signup_date->modify(sprintf('+%d %s', $interval, $interval_unit)); $proposed_next_bill_date = new \DateTime(); $proposed_next_bill_date->setTimestamp($signup_date->getTimestamp()); // Set to first day of the month so we're not dealing with mismatching days of the month. $proposed_next_bill_date->setDate($proposed_next_bill_date->format('Y'), $proposed_next_bill_date->format('m'), 1); // Now we can safely add 1 interval and still be in the expected month. $proposed_next_bill_date->modify(sprintf('+ %d %s', $interval, $interval_unit)); /* * If the day of the month in the signup date exceeds the total number of days in the proposed month, * set the anchor to the last day of the proposed month - whatever that is. */ if (date('j', $signup_date->getTimestamp()) > date('t', $proposed_next_bill_date->getTimestamp())) { // phpcs:ignore try { $stripe_max_anchor = new \DateTime(date('Y-m-t H:i:s', $proposed_next_bill_date->getTimestamp())); // phpcs:ignore } catch (\Exception $exception) { // Silence is golden } } return $stripe_max_anchor; } /** * Get Stripe error from exception * * This converts the exception into a WP_Error object with a localized error message. * * @param Error\Base $e The stripe error object. * * @since 2.0.0 * @return \WP_Error */ protected function get_stripe_error($e) { $wp_error = new \WP_Error(); if (method_exists($e, 'getJsonBody')) { $body = $e->getJsonBody(); $error = $body['error']; $wp_error->add($error['code'], $this->get_localized_error_message($error['code'], $e->getMessage())); } else { $wp_error->add('unknown_error', __('An unknown error has occurred.', 'wp-ultimo')); } return $wp_error; } /** * Localize common Stripe error messages so they're available for translation. * * @link https://stripe.com/docs/error-codes * * @param string $error_code Stripe error code. * @param string $error_message Original Stripe error message. This will be returned if we don't have a localized version of * the error code. * * @since 2.0.0 * @return string */ protected function get_localized_error_message($error_code, $error_message = '') { $errors = wu_stripe_get_localized_error_messages(); if ( ! empty($errors[ $error_code ])) { return $errors[ $error_code ]; } else { // translators: 1 is the error code and 2 the message. return sprintf(__('An error has occurred (code: %1$s; message: %2$s).', 'wp-ultimo'), $error_code, $error_message); } } /** * Gives gateways a chance to run things before backwards compatible webhooks are run. * * @since 2.0.8 * @return void */ public function before_backwards_compatible_webhook(): void { if (empty($this->secret_key)) { $other_id = $this->get_id() === 'stripe' ? 'stripe-checkout' : 'stripe'; /* * If we don't have stripe anymore, and only stripe checkout, * We might want to use the keys from stripe checkout here * or vice-versa. */ $this->setup_api_keys($other_id); } } /** * Process webhooks * * @since 2.0.0 * * @return bool */ public function process_webhooks() { wu_log_add('stripe', 'Receiving Stripe webhook...'); /* * PHP Input as object */ $received_event = wu_get_input(); // for extra security, retrieve from the Stripe API if ( ! isset($received_event->id)) { throw new \Exception(__('Event ID not found.', 'wp-ultimo')); } // Set the right mode for this request if (isset($received_event->livemode) && ! $received_event->livemode !== $this->test_mode) { $this->test_mode = ! $received_event->livemode; } /** * Ensure the correct api keys are set */ $this->setup_api_keys(); $event_id = $received_event->id; $event = Stripe\Event::retrieve($event_id); $payment_event = $event->data->object; $membership = false; $payment = false; $customer = false; $invoice = false; $subscription = false; /* * Check if we have a customer present. */ if (empty($payment_event->customer)) { return; } /* * Try to get an invoice object from the payment event. */ if ( ! empty($payment_event->object) && 'invoice' === $payment_event->object) { $invoice = $payment_event; } elseif ( ! empty($payment_event->invoice)) { $invoice = Stripe\Invoice::retrieve($payment_event->invoice); } /* * Now try to get a subscription from the invoice object. */ if ( ! empty($invoice->subscription)) { $subscription = Stripe\Subscription::retrieve($invoice->subscription); } /* * We can also get the subscription by the * object ID in some circumstances. */ if (empty($subscription) && str_contains((string) $payment_event->id, 'sub_')) { $subscription = Stripe\Subscription::retrieve($payment_event->id); } /* * Retrieve the membership by subscription ID. */ if ( ! empty($subscription)) { $membership = wu_get_membership_by('gateway_subscription_id', $subscription->id); } // Retrieve the membership by payment meta (one-time charges only). if ( ! empty($payment_event->metadata->membership_id)) { $membership = wu_get_membership($payment_event->metadata->membership_id); } /** * Last ditch effort to retrieve a valid membership. */ if (empty($membership) && ! empty($invoice)) { $amount = $invoice->amount_paid / wu_stripe_get_currency_multiplier(); $membership = wu_get_membership_by_customer_gateway_id($payment_event->customer, ['stripe', 'stripe-checkout'], $amount); } /** * Filters the membership record associated with this webhook. * * This filter was introduced due to conflicts that may arise when the same Stripe customer may * be used on different sites. * * @param \WP_Ultimo\Models\Membership|false $membership The membership object. * @param Stripe\Event $event The event received. */ $membership = apply_filters('wu_stripe_webhook_membership', $membership, $event); $this->membership = $membership; if ( ! is_a($membership, '\WP_Ultimo\Models\Membership')) { /** * If we don't have a membership, we can't do anything * and this is not an error. */ return; } /** * Ensure the membership is using the current gateway */ if ($this->get_id() !== $membership->get_gateway()) { // translators: %s is the customer ID. throw new Ignorable_Exception(sprintf(__('Exiting Stripe webhook - This call must be handled by %s webhook', 'wp-ultimo'), $membership->get_gateway())); } /* * Set the WP Multisite WaaS customer. */ $customer = $membership->get_customer(); $this->customer = $customer; /* * Now, we actually get to handle * webhook messages. * * We'll handle 4 cases: * * 1. Customer subscription created - For Stripe Gateway; * 2. Charge Succeeded & Invoice Payment Succeed; * 3. Payment failed; * 4. Subscription deleted. * * First, we'll start customer subscription created. */ if ($event->type === 'customer.subscription.created') { do_action('wu_webhook_recurring_payment_profile_created', $membership, $this); } /* * Deal with Stripe Checkouts case. * * On Stripe Checkout, we rely entirely on * the webhook call to change the status of things. */ if ($event->type === 'checkout.session.completed') { $membership->set_gateway_customer_id($payment_event->customer); $membership->set_gateway_subscription_id($payment_event->subscription); $membership->set_gateway($this->get_id()); $membership->save(); return true; } /* * Next, let's deal with charges that went through! */ if ($event->type === 'charge.succeeded' || $event->type === 'invoice.payment_succeeded') { /** * Here we need to handle invoice.payment_succeeded * events due subscriptions with trials and we need * to handle charge.succeeded for payments without * stripe invoices. */ $payment_data = [ 'status' => Payment_Status::COMPLETED, 'gateway' => $this->get_id(), ]; if ($event->type === 'charge.succeeded') { /* * Successful one-time payment */ if (empty($payment_event->invoice)) { $payment_data['total'] = $payment_event->amount / wu_stripe_get_currency_multiplier(); $payment_data['gateway_payment_id'] = $payment_event->id; /* * Subscription payment received. */ } else { $payment_data['total'] = $invoice->total / wu_stripe_get_currency_multiplier(); $payment_data['subtotal'] = ($invoice->total_excluding_tax / wu_stripe_get_currency_multiplier()) - $payment_data['discount_total']; $payment_data['tax_total'] = $invoice->tax / wu_stripe_get_currency_multiplier(); $payment_data['gateway_payment_id'] = $payment_event->id; if ( ! empty($payment_event->discount)) { $payment_data['discount_code'] = $payment_event->discount->coupon_id; } } } /* * Let's check if we have the payment * created already. We only want to create a new * one if we don't have one already. */ $gateway_payment_id = $payment_event->id; $payment = wu_get_payment_by('gateway_payment_id', $gateway_payment_id); $expiration = false; /* * Payment does not exist. */ if ( ! empty($gateway_payment_id) && ! $payment) { /* * Checks if we have the data about a subscription. */ if ( ! empty($subscription)) { $membership->set_recurring(true); $membership->set_gateway_subscription_id($subscription->id); /* * Set the new expiration date. * We use the `current_period_end` as our base and force the time to be 23:59:59 that day. * However, this must be at least two hours after `current_period_end` to ensure there's * plenty of time between the next invoice being generated and actually being paid/finalized. * Stripe usually does this within 1 hour, but we're using 2 to be on the safe side and * account for delays. */ $renewal_date = new \DateTime(); $renewal_date->setTimestamp($subscription->current_period_end); $renewal_date->setTime(23, 59, 59); /* * Estimated charge date is 2 hours * after `current_period_end`. */ $stripe_estimated_charge_timestamp = $subscription->current_period_end + (2 * HOUR_IN_SECONDS); if ($stripe_estimated_charge_timestamp > $renewal_date->getTimestamp()) { $renewal_date->setTimestamp($stripe_estimated_charge_timestamp); } /* * Set the expiration. */ $expiration = $renewal_date->format('Y-m-d H:i:s'); } /* * Checks for a pending payment on the membership. */ $pending_payment = $membership->get_last_pending_payment(); if ( ! empty($pending_payment)) { /* * Completing a pending payment. */ $pending_payment->attributes($payment_data); $payment = $pending_payment; } elseif ($event->type === 'charge.succeeded') { /** * These must be retrieved after the status * is set to active in order for upgrades to work properly */ // We need to get the line items from the invoice. $line_items = $this->get_ultimo_line_items_from_invoice($invoice->lines->data); // If we have a tax_total let's add it to line items. if ( ! empty($payment_data['tax_total'])) { foreach ($line_items as &$line_item) { $current_total = $line_item->get_total(); $percent_of_subtotal = $current_total / $payment_data['subtotal']; $item_tax_total = $payment_data['tax_total'] * $percent_of_subtotal; $item_total = $current_total + $item_tax_total; $item_tax_rate = round(($item_tax_total / $current_total) * 100, 2); $line_item->set_tax_total($item_tax_total); $line_item->set_tax_rate($item_tax_rate); $line_item->set_total($item_total); } } /* * As we add the discount code value into plan price we need * to add the discount code from membership if it exist. */ $discount_code = $membership->get_discount_code(); if ($discount_code && $discount_code->should_apply_to_renewals()) { $type = $discount_code->get_type(); $old_subtotal = $payment_data['subtotal']; if ('percentage' === $type) { $payment_data['subtotal'] = $old_subtotal / (1 - ($discount_code->get_value() / 100)); $discount_total = $payment_data['subtotal'] - $old_subtotal; } elseif ('absolute' === $type) { $discount_total = $discount_code->get_value(); $payment_data['subtotal'] -= $discount_total; } // Now we apply this discount to the line items. foreach ($line_items as &$line_item) { $current_item_subtotal = $line_item->get_subtotal(); $percent_of_subtotal = $current_item_subtotal / $old_subtotal; $line_item->set_discount_total($discount_total * $percent_of_subtotal); $line_item->set_subtotal($line_item->get_discount_total() + $current_item_subtotal); } } $payment_data['transaction_type'] = 'renewal'; $payment_data['customer_id'] = $customer->get_id(); $payment_data['membership_id'] = $membership->get_id(); $payment_data['line_items'] = $line_items; $payment = wu_create_payment($payment_data); } else { /** * We do not have a payment to change */ return true; } $this->payment = $payment; if ($payment_event->object === 'charge' && ! $subscription && $this->get_id() === 'stripe') { $cart = $payment->get_meta('wu_original_cart'); $payment_intent_id = (string) $payment->get_meta('stripe_payment_intent_id'); // We handle setup intents from process_checkout. $is_setup_intent = str_starts_with($payment_intent_id, 'seti_'); if ($cart && $cart->should_auto_renew() && $cart->has_recurring() && ! $is_setup_intent) { $s_customer = Stripe\Customer::retrieve($payment_event->customer); $payment_method = Stripe\PaymentMethod::retrieve($payment_event->payment_method); $subscription = $this->create_recurring_payment($membership, $cart, $payment_method, $s_customer); // If we receive a subscription, we need to update the membership. if ($subscription) { $membership->set_gateway_subscription_id($subscription->id); $renewal_date = new \DateTime(); $renewal_date->setTimestamp($subscription->current_period_end); $renewal_date->setTime(23, 59, 59); $stripe_estimated_charge_timestamp = $subscription->current_period_end + (2 * HOUR_IN_SECONDS); if ($stripe_estimated_charge_timestamp > $renewal_date->getTimestamp()) { $renewal_date->setTimestamp($stripe_estimated_charge_timestamp); } $expiration = $renewal_date->format('Y-m-d H:i:s'); } else { return true; } } } /** * Renewals the membership */ $membership->add_to_times_billed(1); $membership->renew($membership->is_recurring(), 'active', $expiration); /** * We need to save here to ensure that we are not saving more than once. * * @see process_checkout method */ $payment->save(); /* * Tell the gateway to do their stuff. */ $this->trigger_payment_processed($payment, $membership); return true; } elseif ( ! empty($gateway_payment_id) && $payment) { /* * The payment already exists. * * Throws to inform that * we have a duplicate payment. */ throw new Ignorable_Exception(__('Duplicate payment.', 'wp-ultimo')); } } /* * Next, let's deal with charges that went through! */ if ($event->type === 'charge.refunded') { $payment_data = [ 'gateway' => 'stripe', ]; $payment_id = $payment_event->metadata->payment_id; $payment = wu_get_payment($payment_id); if (empty($payment)) { throw new Ignorable_Exception(__('Payment not found on refund webhook call.', 'wp-ultimo')); } $is_refundable = in_array($payment->get_status(), wu_get_refundable_payment_types(), true); if ( ! $is_refundable) { throw new Ignorable_Exception(__('Payment is not refundable.', 'wp-ultimo')); } /* * Let's address the type. */ $amount = $payment_event->amount_refunded / wu_stripe_get_currency_multiplier(); /* * Actually process the refund * using the helper method. */ $status = $payment->refund($amount); return $status; } /* * Failed payments. */ if ($event->type === 'invoice.payment_failed') { $this->webhook_event_id = $event->id; // Make sure this invoice is tied to a subscription and is the user's current subscription. if ( ! empty($event->data->object->subscription) && $event->data->object->subscription === $membership->get_gateway_subscription_id()) { do_action('wu_recurring_payment_failed', $membership, $this); } do_action('wu_stripe_charge_failed', $payment_event, $event, $membership); return true; } /* * Cancelled / failed subscription. */ if ($event->type === 'customer.subscription.deleted') { wu_log_add('stripe', 'Processing Stripe customer.subscription.deleted webhook.'); if ($payment_event->id === $membership->get_gateway_subscription_id()) { /* * If this is a completed payment plan, * we can skip any cancellation actions. */ if ( ! $membership->is_forever_recurring() && $membership->at_maximum_renewals()) { return; } if ($membership->is_active()) { $membership->cancel(); $membership->add_note(__('Membership cancelled via Stripe webhook.', 'wp-ultimo')); } else { wu_log_add('stripe', sprintf('Membership #%d is not active - not cancelling account.', $membership->get_id())); } return true; } else { wu_log_add('stripe', sprintf('Payment event ID (%s) doesn\'t match membership\'s merchant subscription ID (%s).', $payment_event->id, $membership->get_gateway_subscription_id()), true); } } } /** * Get saved card options for this customers. * * @since 2.0.0 * @return array */ public function get_saved_card_options() { if ( ! is_user_logged_in()) { return []; } $options = []; $user_id = isset($this->customer) && $this->customer ? $this->customer->get_user_id() : false; $saved_payment_methods = $this->get_user_saved_payment_methods(); foreach ($saved_payment_methods as $saved_payment_method) { $options[ $saved_payment_method->id ] = sprintf( // translators: 1 is the card brand (e.g. VISA), and 2 is the last 4 digits. __('%1$s ending in %2$s', 'wp-ultimo'), strtoupper((string) $saved_payment_method->card->brand), $saved_payment_method->card->last4 ); } return $options; } /** * Add credit card fields. * * @since 2.0.0 */ public function fields(): string { return ''; } /** * Load fields for the Update Billing Card form. * * @since 2.0.0 * @return void */ public function update_card_fields(): void { // phpcs:disable ?>

publishable_key) || ! in_array($this->get_id(), $active_gateways, true)) { return; } wp_register_script('wu-stripe-sdk', 'https://js.stripe.com/v3/', false, 'v3'); wp_register_script("wu-{$this->get_id()}", wu_get_asset("gateways/{$this->get_id()}.js", 'js'), ['wu-checkout', 'wu-stripe-sdk'], wu_get_version(), true); $saved_cards = $this->get_saved_card_options(); $obj_name = 'wu_' . str_replace('-', '_', (string) $this->get_id()); wp_localize_script( "wu-{$this->get_id()}", $obj_name, [ 'pk_key' => $this->publishable_key, 'request_billing_address' => $this->request_billing_address, 'add_new_card' => empty($saved_cards), 'payment_method' => empty($saved_cards) ? 'add-new' : current(array_keys($saved_cards)), ] ); wp_enqueue_script("wu-{$this->get_id()}"); } /** * Maybe create a new tax rate on Stripe * * @since 2.0.0 * * @param array $args The tax rate arguments. * @return string */ public function maybe_create_tax_rate($args) { $slug = strtolower(sprintf('%s-%s-%s', $args['country'], $args['tax_rate'], $args['type'])); if (wu_get_isset($args, 'inclusive')) { $slug .= '-inclusive'; } static $cache = []; if (wu_get_isset($cache, $slug)) { return wu_get_isset($cache, $slug); } $stripe_tax_rates = Stripe\TaxRate::all(); foreach ($stripe_tax_rates as $stripe_tax_rate) { if (isset($stripe_tax_rate->metadata->tax_rate_id) && $stripe_tax_rate->metadata->tax_rate_id === $slug) { $cache[ $slug ] = $stripe_tax_rate->id; return $stripe_tax_rate->id; } } $args = [ 'display_name' => $args['title'], 'description' => $args['title'], 'jurisdiction' => $args['country'], 'percentage' => absint($args['tax_rate']), 'inclusive' => wu_get_isset($args, 'inclusive'), 'metadata' => [ 'tax_rate_id' => $slug, ], ]; try { $tax_rate = Stripe\TaxRate::create($args); return $tax_rate->id; } catch (Exception $exception) { // Silence is golden. return ''; } } /** * Checks to see if a plan exists with the provided arguments, and if so, returns the ID of * that plan. If not, a new plan is created. * * This method differs from create_plan() and plan_exists() because it doesn't expect * a membership level ID number. This allows for the creation of plans that may not be * exactly based on a membership level's parameters. * * @param array $args { * Array of arguments. * * @type string $name Required. Name of the plan. * @type float $price Required. Price each interval. * @type string $interval Optional. Billing interval (i.e ."day", "month", "year"). Default is "month". * @type int $interval_count Optional. Interval count. Default is "1". * @type string $currency Optional. Currency. Defaults to site currency. * @type string $id Optional. Plan ID. Automatically generated based on arguments. * } * * @since 2.0.0 * @return string|\WP_Error Plan ID on success or WP_Error on failure. */ public function maybe_create_plan($args) { $args = wp_parse_args( $args, [ 'name' => '', 'price' => 0.00, 'interval' => 'month', 'interval_count' => 1, 'currency' => strtolower((string) wu_get_setting('currency_symbol', 'USD')), 'id' => '', ] ); // Name and price are required. if (empty($args['name']) || empty($args['price'])) { return new \WP_Error('missing_name_price', __('Missing plan name or price.', 'wp-ultimo')); } /* * Create a new object that looks like a membership level object. * We do this because generate_plan_id() expects a membership level object but we * don't actually have one. */ if (empty($args['id'])) { $plan_level = new \stdClass(); $plan_level->name = $args['name']; $plan_level->price = $args['price']; $plan_level->duration = $args['interval_count']; $plan_level->duration_unit = $args['interval']; $plan_level->currency = $args['currency']; $plan_id = $this->generate_plan_id($plan_level); } else { $plan_id = $args['id']; } if (empty($plan_id)) { return new \WP_Error('empty_plan_id', __('Empty plan ID.', 'wp-ultimo')); } // Convert price to Stripe format. $price = round($args['price'] * wu_stripe_get_currency_multiplier(), 0); // First check to see if a plan exists with this ID. If so, return that. try { $membership_level = $plan_level ?? new \stdClass(); /** * Filters the ID of the plan to check for. If this exists, the new subscription will * use this plan. * * @param string $plan_id ID of the Stripe plan to check for. * @param object $membership_level Membership level object. */ $existing_plan_id = apply_filters('wu_stripe_existing_plan_id', $plan_id, $membership_level); $plan = Stripe\Plan::retrieve($existing_plan_id); return $plan->id; } catch (\Exception $e) { // silence is golden } // Otherwise, create a new plan. try { $product = Stripe\Product::create( [ 'name' => $args['name'] . ' - ' . $args['currency'], 'type' => 'service', ] ); $plan = Stripe\Plan::create( [ 'amount' => $price, 'interval' => $args['interval'], 'interval_count' => $args['interval_count'], 'currency' => $args['currency'], 'id' => $plan_id, 'product' => $product->id, ] ); // plan successfully created return $plan->id; } catch (\Exception $e) { $error_code = $e->getCode(); // WP Error did not handle empty error code if (empty($error_code)) { if (method_exists($e, 'getHttpStatus')) { $error_code = $e->getHttpStatus(); } else { $error_code = 500; } } wu_log_add('stripe', sprintf('Error creating Stripe plan. Code: %s; Message: %s', $error_code, $e->getMessage()), LogLevel::ERROR); return new \WP_Error('stripe_exception', sprintf('Error creating Stripe plan. Code: %s; Message: %s', $error_code, $e->getMessage())); } } /** * Checks to see if a product exists with the provided arguments, and if so, returns the ID of * that product. If not, a new product is created. * * @since 2.1.1 * * @param string $name Product name. * @param string $id Optional. Product ID. Automatically generated based on arguments. * @return string|\WP_Error Product ID on success or WP_Error on failure. */ private function maybe_create_product($name, $id = '') { // Name are required. if (empty($name)) { return new \WP_Error('missing_name', __('Missing product name.', 'wp-ultimo')); } if (empty($id)) { $product_id = strtolower(str_replace(' ', '', sanitize_title_with_dashes($name))); $product_id = sprintf('wu-%s', $product_id); $product_id = preg_replace('/[^a-z0-9_\-]/', '-', $product_id); } else { $product_id = $id; } if (empty($product_id)) { return new \WP_Error('empty_product_id', __('Empty product ID.', 'wp-ultimo')); } // First check to see if a product exists with this ID. If so, return that. try { /** * Filters the ID of the product to check for. If this exists, the new subscription will * use this product. * * @param string $product_id ID of the Stripe product to check for. * @param object $name Product name. */ $existing_product_id = apply_filters('wu_stripe_existing_product_id', $product_id, $name); $product = Stripe\Product::retrieve($existing_product_id); return $product->id; } catch (\Exception $e) { // silence is golden } // Otherwise, create a new product. try { $product = Stripe\Product::create( [ 'id' => $product_id, 'name' => $name, ] ); // product successfully created return $product->id; } catch (\Exception $e) { $error_code = $e->getCode(); // WP Error did not handle empty error code if (empty($error_code)) { if (method_exists($e, 'getHttpStatus')) { $error_code = $e->getHttpStatus(); } else { $error_code = 500; } } wu_log_add('stripe', sprintf('Error creating Stripe product. Code: %s; Message: %s', $error_code, $e->getMessage()), LogLevel::ERROR); return new \WP_Error('stripe_exception', sprintf('Error creating Stripe product. Code: %s; Message: %s', $error_code, $e->getMessage())); } } /** * Checks to see if a price exists with the provided arguments, * and if so, returns the ID of that price. If not, a new price is created. * * @since 2.1.3 * @param string $title Price title. * @param float $amount Amount. * @param string $currency Currency. * @param int $quantity Quantity. * @param int $duration Duration. * @param string $duration_unit Duration unit. * @param string $tax_behavior Tax behavior. * @return string|\WP_Error Price ID on success or WP_Error on failure. */ function maybe_create_price($title, $amount, $currency, $quantity = 1, $duration = false, $duration_unit = false, $tax_behavior = '') { $name = 1 === $quantity ? $title : "x$quantity $title"; $currency = strtolower($currency); $s_amount = round($amount * wu_stripe_get_currency_multiplier()); $s_product = $this->maybe_create_product($name); $s_price_data = [ 'lookup_key' => "$s_product-$s_amount-$currency", 'unit_amount' => $s_amount, 'currency' => $currency, 'product' => $s_product, ]; if ($duration && $duration_unit) { $s_price_data['recurring'] = [ 'interval' => $duration_unit, 'interval_count' => $duration, ]; $s_price_data['lookup_key'] .= "-$duration-$duration_unit"; } if ( ! empty($tax_behavior)) { $s_price_data['tax_behavior'] = $tax_behavior; $s_price_data['lookup_key'] .= "-$tax_behavior"; } // check if price already exists $existing = Stripe\Price::all( [ 'lookup_keys' => [$s_price_data['lookup_key']], 'limit' => 1, ] ); if ( ! empty($existing->data)) { return $existing->data[0]->id; } $s_price = Stripe\Price::create($s_price_data); return $s_price->id; } /** * Generate a Stripe plan ID string based on a membership level * * The plan name is set to {levelname}-{price}-{duration}{duration unit} * Strip out invalid characters such as '@', '.', and '()'. * Similar to WP core's sanitize_html_class() & sanitize_key() functions. * * @param object $product_info The product info object. * * @since 2.0.0 * @return string */ private function generate_plan_id($product_info) { $product_name = strtolower(str_replace(' ', '', sanitize_title_with_dashes($product_info->name))); $plan_id = sprintf('%s-%s-%s-%s', $product_name, $product_info->price, $product_info->currency, $product_info->duration . $product_info->duration_unit); $plan_id = preg_replace('/[^a-z0-9_\-]/', '-', $plan_id); return $plan_id; } /** * Get the saved Stripe payment methods for a given user ID. * * @since 2.0.0 * * @throws \Exception, When info is wrong. * @throws \Exception When info is wrong 2. * @return PaymentMethod[]|array */ public function get_user_saved_payment_methods() { $customer = wu_get_current_customer(); if ( ! $customer) { return []; } $customer_id = $customer->get_id(); /** * Ensure the correct api keys are set */ $this->setup_api_keys(); try { /* * Declare static to prevent multiple calls. */ static $existing_payment_methods; if ( ! is_null($existing_payment_methods) && array_key_exists($customer_id, $existing_payment_methods)) { return $existing_payment_methods[ $customer_id ]; } $customer_payment_methods = []; $stripe_customer_id = \WP_Ultimo\Models\Membership::query( [ 'customer_id' => $customer_id, 'search' => 'cus_*', 'fields' => ['gateway_customer_id'], ] ); $stripe_customer_id = current(array_column($stripe_customer_id, 'gateway_customer_id')); $payment_methods = Stripe\PaymentMethod::all( [ 'customer' => $stripe_customer_id, 'type' => 'card', ] ); foreach ($payment_methods->data as $payment_method) { $customer_payment_methods[ $payment_method->id ] = $payment_method; } $existing_payment_methods[ $customer_id ] = $customer_payment_methods; return $existing_payment_methods[ $customer_id ]; } catch (\Throwable $exception) { return []; } } /** * Returns the external link to view the payment on the payment gateway. * * Return an empty string to hide the link element. * * @since 2.0.0 * * @param string $gateway_payment_id The gateway payment id. * @return string. */ public function get_payment_url_on_gateway($gateway_payment_id): string { $route = $this->test_mode ? '/test' : '/'; $path = 'payments'; if (str_starts_with($gateway_payment_id, 'in_')) { $path = 'invoices'; } return sprintf('https://dashboard.stripe.com%s/%s/%s', $route, $path, $gateway_payment_id); } /** * Returns the external link to view the membership on the membership gateway. * * Return an empty string to hide the link element. * * @since 2.0.0 * * @param string $gateway_subscription_id The gateway subscription id. * @return string. */ public function get_subscription_url_on_gateway($gateway_subscription_id): string { $route = $this->test_mode ? '/test' : '/'; return sprintf('https://dashboard.stripe.com%s/subscriptions/%s', $route, $gateway_subscription_id); } /** * Returns the external link to view the customer on the gateway. * * Return an empty string to hide the link element. * * @since 2.0.7 * * @param string $gateway_customer_id The gateway customer id. * @return string. */ public function get_customer_url_on_gateway($gateway_customer_id): string { $route = $this->test_mode ? '/test' : '/'; return sprintf('https://dashboard.stripe.com%s/customers/%s', $route, $gateway_customer_id); } }