'new', /* * The list of products being bought. */ 'products' => [], /* * The duration parameters * This will dictate which price variations we are going to use. */ 'duration' => false, 'duration_unit' => false, /* * The membership ID. * This is passed when we want to handle a upgrade/downgrade/addon. */ 'membership_id' => false, /* * Payment ID. * This is passed when we are trying to recovered a abandoned/pending payment. */ 'payment_id' => false, /* * The discount code to be used. */ 'discount_code' => false, /* * If we should auto-renew or not. */ 'auto_renew' => true, /* * The country, state, and city of the customer. * Used for taxation purposes. */ 'country' => '', 'state' => '', 'city' => '', /* * Currency */ 'currency' => '', ], $args ); /* * Checks for errors */ $this->errors = new \WP_Error(); /* * Save arguments in memory */ $this->attributes = (object) $args; /** * Allow developers to make additional changes to * the checkout object. * * @since 2.0.0 * @param $this \WP_Ultimo\Checkout\Cart The cart object. */ do_action('wu_cart_setup', $this); // @phpstan-ignore-line /* * Set the country, duration and duration_unit. */ $this->cart_type = $this->attributes->cart_type; $this->country = $this->attributes->country; $this->state = $this->attributes->state; $this->city = $this->attributes->city; $this->currency = $this->attributes->currency; $this->duration = $this->attributes->duration; $this->duration_unit = $this->attributes->duration_unit; /* * Loads the current customer, if it exists. */ $this->customer = wu_get_current_customer(); /* * At this point, we have almost everything we can ready. * It's time to deal with discount codes. */ $this->set_discount_code($this->attributes->discount_code); /* * Delegates the logic to another * method that builds up the cart. */ $this->build_cart(); /* * Also set the auto-renew status. * * This setting can be forced if the settings say so, * so we only set it if that is not enabled. */ if ( ! wu_get_setting('force_auto_renew', true)) { $this->auto_renew = wu_string_to_bool($this->attributes->auto_renew); } /* * Calculate-totals. * * This will make sure our cart is ready to be consumed * by other parts of the code. */ $this->calculate_totals(); /** * Allow developers to make additional changes to * the checkout object. * * @since 2.0.0 * @param $this \WP_Ultimo\Checkout\Cart The cart object. */ do_action('wu_cart_after_setup', $this); // @phpstan-ignore-line } /** * Get additional parameters set by integrations and add-ons. * * @param string $key The parameter key. * @param boolean $default_value The default value. * * @return mixed * @since 2.0.0 */ public function get_param($key, $default_value = false) { return wu_get_isset($this->attributes, $key, $default_value); } /** * Set additional parameters set by integrations and add-ons. * * @since 2.0.0 * * @param string $key The key to set. * @param mixed $value The value to set. * @return void */ public function set_param($key, $value): void { $this->extra[] = $key; $this->attributes->{$key} = $value; } /** * Gets the tax exempt status of the current cart. * * @since 2.0.0 * @return boolean */ public function is_tax_exempt() { return apply_filters('wu_cart_is_tax_exempt', false, $this); } /** * Builds the cart. * * Here, we try to determine the type of * cart so we can properly set it up, based * on the payment, membership, and products passed. * * @since 2.0.0 * @return void */ protected function build_cart() { /* * Maybe deal with payment recovery first. */ $is_recovery_cart = $this->build_from_payment($this->attributes->payment_id); /* * If we are recovering a payment, we stop right here. * The pending payment object has all the info we need * in order to build the proper cart. */ if ($is_recovery_cart) { return; } /* * The next step is to deal with membership changes. * These include downgrades/upgrades and addons. */ $is_membership_change = $this->build_from_membership($this->attributes->membership_id); /* * If this is a membership change, * we can return as our work is done. */ if ($is_membership_change) { return; } /* * Otherwise, we add the the products normally, * and set the cart as new. */ $this->cart_type = 'new'; if (is_array($this->attributes->products)) { /* * Otherwise, we add the products to build the cart. */ foreach ($this->attributes->products as $product_id) { $this->add_product($product_id); } } } /** * Creates a string that describes the cart. * * Some gateways require a description that you need * to match after the payment confirmation. * * This method generates such a string based on * the products on the cart. * * @since 2.0.0 * @return string */ public function get_cart_descriptor() { if ( ! empty($this->cart_descriptor)) { return $this->cart_descriptor; } $desc = wu_get_setting('company_name', __('Subscription', 'wp-ultimo')); $products = []; foreach ($this->get_line_items() as $line_item) { $product = $line_item->get_product(); if ( ! $product) { continue; } $products[] = $line_item->get_title(); } $descriptor = $desc . ' - ' . implode(', ', $products); return trim($descriptor); } /** * Set a custom cart descriptor. * * @since 2.1.3 * * @param string $descriptor The cart description. * @return void */ public function set_cart_descriptor($descriptor): void { $this->cart_descriptor = $descriptor; } /** * Decides if we are trying to recover a payment. * * @since 2.0.0 * * @param int $payment_id A valid payment ID. */ protected function build_from_payment($payment_id): bool { /* * No valid payment id passed, so we * are not trying to recover a payment. */ if (empty($payment_id)) { return false; } /* * Now, let's try to fetch the payment in question. */ $payment = wu_get_payment($payment_id); if ( ! $payment) { $this->errors->add('payment_not_found', __('The payment in question was not found.', 'wp-ultimo')); return true; } /* * The payment exists, set it globally. */ $this->payment = $payment; /* * Adds the country to calculate taxes. */ $this->country = ($this->country ?: $this->customer) ? $this->customer->get_country() : ''; /* * Set the currency in cart */ $this->set_currency($payment->get_currency()); /* * Check for the correct permissions. * * For obvious reasons, only the customer that owns * a payment can pay it. Let's check for that. */ if (empty($this->customer) || $this->customer->get_id() !== $payment->get_customer_id()) { $this->errors->add('lacks_permission', __('You are not allowed to modify this payment.', 'wp-ultimo')); return true; } /* * Sets the membership as well, to prevent issues */ $membership = $payment->get_membership(); if ( ! $membership) { $this->errors->add('membership_not_found', __('The membership in question was not found.', 'wp-ultimo')); return true; } if ($payment->get_discount_code()) { /** * First check if is a membership discount code; */ $discount_code = $membership->get_discount_code(); if ($discount_code && $discount_code->get_code() === $payment->get_discount_code()) { $this->add_discount_code($discount_code); } else { $this->add_discount_code($payment->get_discount_code()); } } /* * Sets membership globally. */ $this->membership = $membership; $this->duration = $membership->get_duration(); $this->duration_unit = $membership->get_duration_unit(); /* * Finally, copy the line items from the payment. */ foreach ($payment->get_line_items() as $line_item) { $product = $line_item->get_product(); if ($product) { if ($product->is_recurring() && ($product->get_duration_unit() !== $this->duration_unit || $product->get_duration() !== $this->duration)) { $product_variation = $product->get_as_variation($this->duration, $this->duration_unit); /* * Checks if the variation exists before re-setting the product. */ if ($product_variation) { $product = $product_variation; } } $this->products[] = $product; if ($line_item->get_type() === 'product' && $product->get_type() === 'plan') { /* * If we already have a plan, we can't add * another one. */ if (empty($this->plan_id)) { $this->plan_id = $product->get_id(); $this->billing_cycles = $product->get_billing_cycles(); $this->duration = $line_item->get_duration(); $this->duration_unit = $line_item->get_duration_unit(); } } } $this->add_line_item($line_item); } /* * If the payment is completed * this can't be a retry, so we skip * the rest. */ if ($payment->get_status() === 'completed') { /** * We should return false to continue in case of membership updates. */ return false; } /* * Check for payment status. * * We want to make sure we only allow for repayment of pending, * cancelled, or abandoned payments */ $allowed_status = apply_filters( 'wu_cart_set_payment_allowed_status', [ 'pending', ] ); if ( ! in_array($payment->get_status(), $allowed_status, true)) { $this->errors->add('invalid_status', __('The payment in question has an invalid status.', 'wp-ultimo')); return true; } /* * If the membership is active or is * already in trial this can't be a * retry, so we skip the rest. */ if ($membership->is_active() || ($membership->get_status() === Membership_Status::TRIALING && ! $this->has_trial())) { return false; } /* * We got here, that means * the intend behind this cart was to actually * recover a payment. * * That means we can safely set the cart type to retry. */ $this->cart_type = 'retry'; return true; } /** * Uses the membership to decide if this is a upgrade/downgrade/addon cart. * * @since 2.0.0 * * @param int $membership_id A valid membership ID. */ protected function build_from_membership($membership_id): bool { /* * No valid membership id passed, so we * are not trying to change a membership. */ if (empty($membership_id)) { return false; } /* * We got here, that means * the intend behind this cart was to actually * change a membership. * * We can set the cart type provisionally. * This assignment might change in the future, as we make * additional assertions about the contents of the cart. */ $this->cart_type = 'upgrade'; /* * Now, let's try to fetch the membership in question. */ $membership = wu_get_membership($membership_id); if ( ! $membership) { $this->errors->add('membership_not_found', __('The membership in question was not found.', 'wp-ultimo')); return true; } /* * The membership exists, set it globally. */ $this->membership = $membership; /* * In the case of membership changes, * the status is not that relevant, as customers * might want to make changes to memberships that are * active, cancelled, etc. * * We do need to check for permissions, though. * Only the customer that owns a membership can change it. */ if (empty($this->customer) || $this->customer->get_id() !== $membership->get_customer_id()) { $this->errors->add('lacks_permission', __('You are not allowed to modify this membership.', 'wp-ultimo')); return true; } /* * Adds the country to calculate taxes. */ $this->country = $this->country ?: $this->customer->get_country(); /* * Set the currency in cart */ $this->set_currency($membership->get_currency()); /* * If we get to this point, we now need to assess * what are the changes being made. * * First, we need to see if there are actual products * being added, and process those. */ if (empty($this->attributes->products)) { if ($this->payment) { /** * If we do not have any change but we have a already * created payment it means that this cart is to pay * for this. */ return false; } $this->errors->add('no_changes', __('This cart proposes no changes to the current membership.', 'wp-ultimo')); return true; } /* * Otherwise, we add the products to build the cart. */ foreach ($this->attributes->products as $product_id) { $this->add_product($product_id); } /* * With products added, let's check if this is an addon. * * An addon cart adds a new product or service to the current membership. * If this cart, after adding the products, doesn't have a plan, it means * it should continue to use the membership plan, and the other products * must be added to the membership. */ if (empty($this->plan_id)) { if (count($this->products) === 0) { $this->errors->add('no_changes', __('This cart proposes no changes to the current membership.', 'wp-ultimo')); return true; } /* * Set the type to addon. */ $this->cart_type = 'addon'; /* * Sets the durations to avoid problems * with addon purchases. */ $plan_product = $membership->get_plan(); if ($plan_product && ! $membership->is_free()) { $this->duration = $plan_product->get_duration(); $this->duration_unit = $plan_product->get_duration_unit(); } /* * Checks the membership to see if we need to add back the * setup fee. * * If the membership was already successfully charged once, * it probably means that the setup fee was already paid, so we can skip it. */ add_filter('wu_apply_signup_fee', fn() => $membership->get_times_billed() <= 0); /* * Adds the membership plan back in, for completeness. * This is also useful to make sure we present * the totals correctly for the customer. */ $this->add_product($membership->get_plan_id()); /* * Adds the credit line, after * calculating pro-rate. */ $this->calculate_prorate_credits(); return true; } /* * With products added, let's check if the plan is changing. * * A plan change implies a upgrade or a downgrade, which we will determine * below. * * A plan change can take many forms. * - Different plan altogether; * - Same plan with different periodicity; * - upgrade to lifetime; * - downgrade to free; */ $is_plan_change = false; if ($membership->get_plan_id() !== $this->plan_id || $membership->get_duration_unit() !== $this->duration_unit || $membership->get_duration() !== $this->duration) { $is_plan_change = true; } /* * Checks for periodicity changes. */ $old_periodicity = sprintf('%s-%s', $membership->get_duration(), $membership->get_duration_unit()); $new_periodicity = sprintf('%s-%s', $this->duration, $this->duration_unit); if ($old_periodicity !== $new_periodicity) { $is_plan_change = true; } /* * If there is no plan change, but the product count is > 1 * We know that there is another product in this cart other than the * plan, so this is again an addon cart. */ if (count($this->products) > 1 && false === $is_plan_change) { /* * Set the type to addon. */ $this->cart_type = 'addon'; /* * Sets the durations to avoid problems * with addon purchases. */ $plan_product = $membership->get_plan(); if ($plan_product && ! $membership->is_free()) { $this->duration = $plan_product->get_duration(); $this->duration_unit = $plan_product->get_duration_unit(); } /* * Checks the membership to see if we need to add back the * setup fee. * * If the membership was already successfully charged once, * it probably means that the setup fee was already paid, so we can skip it. */ add_filter('wu_apply_signup_fee', fn() => $membership->get_times_billed() <= 0); /* * Adds the credit line, after * calculating pro-rate. */ $this->calculate_prorate_credits(); return true; } /* * We'll probably never enter in this if, but we * hev it here to prevent bugs. */ if ( ! $is_plan_change || ($this->get_plan_id() === $membership->get_plan_id() && $membership->get_duration_unit() === $this->duration_unit && $membership->get_duration() === $this->duration)) { $this->products = []; $this->line_items = []; $this->errors->add('no_changes', __('This cart proposes no changes to the current membership.', 'wp-ultimo')); return true; } /* * Upgrade to Lifetime. */ if ( ! $this->has_recurring() && ! $this->is_free()) { /* * Adds the credit line, after * calculating pro-rate. */ $this->calculate_prorate_credits(); return true; } /* * If we get to this point, we know that this is either * an upgrade or a downgrade, so we need to determine which. * * Since by default we set the value to upgrade, * we just need to check for a downgrade scenario. */ $days_in_old_cycle = wu_get_days_in_cycle($membership->get_duration_unit(), $membership->get_duration()); $days_in_new_cycle = wu_get_days_in_cycle($this->duration_unit, $this->duration); $old_price_per_day = $days_in_old_cycle > 0 ? $membership->get_amount() / $days_in_old_cycle : $membership->get_amount(); $new_price_per_day = $days_in_new_cycle > 0 ? $this->get_recurring_total() / $days_in_new_cycle : $this->get_recurring_total(); $is_same_product = $membership->get_plan_id() === $this->plan_id; /** * Here we search for variations of the plans * with the same duration to avoid mistakens * when setting a downgrade cart. */ if ($days_in_old_cycle !== $days_in_new_cycle) { $old_plan = $membership->get_plan(); $new_plan = $this->get_plan(); $variations = $this->search_for_same_period_plans($old_plan, $new_plan); if ($variations) { $old_plan = $variations[0]; $new_plan = $variations[1]; $days_in_old_cycle_plan = wu_get_days_in_cycle($old_plan->get_duration_unit(), $old_plan->get_duration()); $days_in_new_cycle_plan = wu_get_days_in_cycle($new_plan->get_duration_unit(), $new_plan->get_duration()); $old_price_per_day = $days_in_old_cycle_plan > 0 ? $old_plan->get_amount() / $days_in_old_cycle_plan : $old_plan->get_amount(); $new_price_per_day = $days_in_new_cycle_plan > 0 ? $new_plan->get_amount() / $days_in_new_cycle_plan : $new_plan->get_amount(); } } if ( ! $membership->is_free() && $old_price_per_day < $new_price_per_day && $days_in_old_cycle > $days_in_new_cycle && $membership->get_status() === Membership_Status::ACTIVE) { $this->products = []; $this->line_items = []; $description = sprintf( // translators: %1$s the duration, and %2$s the duration unit (day, week, month, etc) _n('%2$s', '%1$s %2$s', $membership->get_duration(), 'wp-ultimo'), // phpcs:ignore $membership->get_duration(), wu_get_translatable_string(($membership->get_duration() <= 1 ? $membership->get_duration_unit() : $membership->get_duration_unit() . 's')) ); // Translators: Placeholder receives the recurring period description $message = sprintf(__('You already have an active %s agreement.', 'wp-ultimo'), $description); $this->errors->add('no_changes', $message); return true; } /* * If is the same product and the customer will start to pay less * or if is not the same product and the price per day is smaller * this is a downgrade */ if (($is_same_product && $membership->get_amount() > $this->get_recurring_total()) || (! $is_same_product && $old_price_per_day > $new_price_per_day)) { $this->cart_type = 'downgrade'; // If membership is active or trialing we will schendule the swap if ($membership->is_active() || $membership->get_status() === Membership_Status::TRIALING) { $line_item_params = apply_filters( 'wu_checkout_credit_line_item_params', [ 'type' => 'credit', 'title' => __('Scheduled Swap Credit', 'wp-ultimo'), 'description' => __('Swap scheduled to next billing cycle.', 'wp-ultimo'), 'discountable' => false, 'taxable' => false, 'quantity' => 1, 'unit_price' => - $this->get_total(), ] ); $credit_line_item = new Line_Item($line_item_params); $this->add_line_item($credit_line_item); } } // If this is an upgrade, we need to prorate the current amount if ('upgrade' === $this->cart_type) { $this->calculate_prorate_credits(); } /* * All set! */ return true; } /** * Search for variations of the plans with same duration. * * @since 2.0.20 * @param \WP_Ultimo\Models\Product $plan_a The first plan without variations. * @param \WP_Ultimo\Models\Product $plan_b The second plan without variations. * @return mixed[]|false */ protected function search_for_same_period_plans($plan_a, $plan_b) { if ($plan_a->get_duration_unit() === $plan_b->get_duration_unit() && $plan_a->get_duration() === $plan_b->get_duration()) { return [ $plan_a, $plan_b, ]; } $plan_a_variation = $plan_a->get_as_variation($plan_b->get_duration(), $plan_b->get_duration_unit()); if ($plan_a_variation) { return [ $plan_a_variation, $plan_b, ]; } $plan_b_variation = $plan_b->get_as_variation($plan_a->get_duration(), $plan_a->get_duration_unit()); if ($plan_b_variation) { return [ $plan_a, $plan_b_variation, ]; } if ($this->duration_unit && $this->duration && ($plan_b->get_duration_unit() !== $this->duration_unit || $plan_b->get_duration() !== $this->duration)) { $plan_a_variation = $plan_a->get_as_variation($this->duration, $this->duration_unit); if ( ! $plan_a_variation) { return false; } if ($plan_b->get_duration_unit() === $plan_a_variation->get_duration_unit() && $plan_b->get_duration() === $plan_a_variation->get_duration()) { return [ $plan_a_variation, $plan_b, ]; } $plan_b_variation = $plan_b->get_as_variation($this->duration, $this->duration_unit); if ($plan_b_variation) { return [ $plan_a_variation, $plan_b_variation, ]; } } return false; } /** * Calculate pro-rate credits. * * @since 2.0.0 * @return void */ protected function calculate_prorate_credits() { /* * Now we come to the craziest part: pro-rating! * * This is super hard to get right, but we basically need to add * new line items to account for the time using the old plan. */ /* * If the membership is in trial period theres nothing to prorate. */ if ($this->membership->get_status() === Membership_Status::TRIALING) { return; } if ($this->membership->is_lifetime() || ! $this->membership->is_recurring()) { $credit = $this->membership->get_initial_amount(); } else { $days_unused = $this->membership->get_remaining_days_in_cycle(); $days_in_old_cycle = wu_get_days_in_cycle($this->membership->get_duration_unit(), $this->membership->get_duration()); $old_price_per_day = $days_in_old_cycle > 0 ? $this->membership->get_amount() / $days_in_old_cycle : $this->membership->get_amount(); $credit = $days_unused * $old_price_per_day; if ($credit > $this->membership->get_amount()) { $credit = $this->membership->get_amount(); } } /* * No credits */ if (empty($credit)) { return; } /* * Checks if we need to add back the value of the * setup fee */ $has_setup_fee = $this->get_line_items_by_type('fee'); if ( ! empty($has_setup_fee) || $this->get_cart_type() === 'upgrade') { $old_plan = $this->membership->get_plan(); $new_plan = $this->get_plan(); if ($old_plan && $new_plan) { $old_setup_fee = $old_plan->get_setup_fee(); $new_setup_fee = $new_plan->get_setup_fee(); $fee_credit = $old_setup_fee < $new_setup_fee ? $old_setup_fee : $new_setup_fee; if ($fee_credit > 0) { $new_line_item = new Line_Item( [ 'product' => $old_plan, 'type' => 'fee', 'description' => '--', 'title' => '', 'taxable' => $old_plan->is_taxable(), 'recurring' => false, 'unit_price' => $fee_credit, 'quantity' => 1, ] ); $new_line_item = $this->apply_taxes_to_item($new_line_item); $new_line_item->recalculate_totals(); $credit += $new_line_item->get_total(); } } } /** * Allow plugin developers to meddle with the credit value. * * @since 2.0.0 * * @param int $credit The credit amount. * @param self $cart This cart object. */ $credit = apply_filters('wu_checkout_calculate_prorate_credits', $credit, $this); $credit = round($credit, wu_currency_decimal_filter()); /* * No credits */ if (empty($credit)) { return; } $line_item_params = apply_filters( 'wu_checkout_credit_line_item_params', [ 'type' => 'credit', 'title' => __('Credit', 'wp-ultimo'), 'description' => __('Prorated amount based on the previous membership.', 'wp-ultimo'), 'discountable' => false, 'taxable' => false, 'quantity' => 1, 'unit_price' => - $credit, ] ); /* * Finally, we add the credit to the purchase. */ $credit_line_item = new Line_Item($line_item_params); $this->add_line_item($credit_line_item); } /** * Adds a discount code to the cart. * * @since 2.0.0 * * @param int|string $code A valid discount code ID or code. */ protected function set_discount_code($code): bool { if (empty($code)) { return false; } $code = strtoupper($code); $discount_code = wu_get_discount_code_by_code($code); if (empty($discount_code)) { // translators: %s is the coupon code being used, all-caps. e.g. PROMO10OFF $this->errors->add('discount_code', sprintf(__('The code %s do not exist or is no longer valid.', 'wp-ultimo'), $code)); return false; } $is_valid = $discount_code->is_valid(); if (is_wp_error($is_valid)) { $this->errors->merge_from($is_valid); return false; } /* * Set the coupon */ $this->discount_code = $discount_code; return true; } /** * Returns the current errors. * * @since 2.0.0 * @return \WP_Error */ public function get_errors() { return $this->errors; } /** * For an order to be valid, all the recurring products must have the same * billing intervals and cycle. * * @since 2.0.0 * @return boolean */ public function is_valid() { $is_valid = true; /* * If we got any errors during * the setup, bail. */ if ($this->errors->has_errors()) { return false; } $interval = null; foreach ($this->line_items as $line_item) { $duration = $line_item->get_duration(); $duration_unit = $line_item->get_duration_unit(); $cycles = $line_item->get_billing_cycles(); if ( ! $line_item->is_recurring()) { continue; } /* * Create a key that will tell us if something changes. * * If unit, duration or cycles are different, we return false. * This means that this order is not valid. * * Maybe in the future we can try to come of ways of accommodating * different billing periods on the same order, right now, there * isn't a way of doing that with all the different gateways we * plan to support. */ $line_item_interval = "{$duration}-{$duration_unit}-{$cycles}"; if ( ! $interval) { $interval = $line_item_interval; } if ($line_item_interval !== $interval) { // translators: two intervals $this->errors->add('wrong', sprintf(__('Interval %1$s and %2$s do not match.', 'wp-ultimo'), $line_item_interval, $interval)); return false; } } return $is_valid; } /** * Checks if this order is free. * * This is used on the checkout to deal with this separately. * * @todo handle 100% off coupon codes. * * @since 2.0.0 * @return boolean */ public function is_free() { return empty($this->get_total()); } /** * Checks if we need to collect a payment method. * * Will return false if the order is free or when * the order contains a trial and no payment method is required. * * @since 2.0.0 * @return boolean */ public function should_collect_payment() { $should_collect_payment = true; if ($this->is_free() && $this->get_recurring_total() === 0.0) { $should_collect_payment = false; } elseif ($this->has_trial()) { $should_collect_payment = ! wu_get_setting('allow_trial_without_payment_method', false); } return (bool) apply_filters('wu_cart_should_collect_payment', $should_collect_payment, $this); } /** * Checks if the cart has a plan. * * @since 2.0.0 * @return boolean */ public function has_plan() { return (bool) $this->get_plan(); } /** * Returns the cart plan. * * @since 2.0.0 * @return \WP_Ultimo\Models\Product|false */ public function get_plan() { return wu_get_product((int) $this->plan_id); } /** * Returns the recurring products added to the cart. * * @since 2.0.0 * @return array */ public function get_recurring_products() { return $this->recurring_products; } /** * Returns the non-recurring products added to the cart. * * @since 2.0.0 * @return array */ public function get_non_recurring_products() { return $this->additional_products; } /** * Returns an array containing all products added to the cart, recurring or not. * * @since 2.0.0 * @return array */ public function get_all_products() { return $this->products; } /** * Returns the duration value for this cart. * * @since 2.0.0 * @return int */ public function get_duration() { return $this->duration; } /** * Returns the duration unit for this cart. * * @since 2.0.0 * @return string */ public function get_duration_unit() { return $this->duration_unit; } /** * Add a new line item. * * @since 2.0.0 * * @param \WP_Ultimo\Checkout\Line_Item $line_item The line item. * @return void */ public function add_line_item($line_item): void { if ( ! is_a($line_item, '\WP_Ultimo\Checkout\Line_Item')) { return; } if ($line_item->is_discountable()) { $line_item = $this->apply_discounts_to_item($line_item); } if ($line_item->is_taxable()) { $line_item = $this->apply_taxes_to_item($line_item); } $this->line_items[ $line_item->get_id() ] = $line_item; krsort($this->line_items); } /** * Adds a new product to the cart. * * @since 2.0.0 * * @param int|string $product_id_or_slug The product id to add. * @param int $quantity The quantity. */ public function add_product($product_id_or_slug, $quantity = 1): bool { $product = is_numeric($product_id_or_slug) ? wu_get_product($product_id_or_slug) : wu_get_product_by_slug($product_id_or_slug); if ( ! $product) { $message = __('The product you are trying to add does not exist.', 'wp-ultimo'); $this->errors->add('missing-product', $message); return false; } // Here we check if the product is recurring and if so, get the correct variation if ($product->is_recurring() && ! empty($this->duration) && ($product->get_duration() !== $this->duration || $product->get_duration_unit() !== $this->duration_unit)) { $product = $product->get_as_variation($this->duration, $this->duration_unit); if ( ! $product) { $message = __('The product you are trying to add does not exist for the selected duration.', 'wp-ultimo'); $this->errors->add('missing-price-variations', $message); return false; } } if ($product->get_type() === 'plan') { /* * If we already have a plan, we can't add * another one. Bail. */ if ( ! empty($this->plan_id)) { $message = __('Theres already a plan in this membership.', 'wp-ultimo'); $this->errors->add('plan-already-added', $message); return false; } $this->plan_id = $product->get_id(); $this->billing_cycles = $product->get_billing_cycles(); } /* * We only try to reset the duration and such if * they are not already set. * * We need to do this because we * want access this to fetch price variations. */ if (empty($this->duration) || $product->is_recurring() === false) { $this->duration = $product->get_duration(); $this->duration_unit = $product->get_duration_unit(); } if (empty($this->currency)) { $this->currency = $product->get_currency(); } /* * Set product amount in here, because * that can change... */ $amount = $product->get_amount(); $duration = $product->get_duration(); $duration_unit = $product->get_duration_unit(); /* * Deal with price variations. * * Here's the general idea: * * If the cart duration or duration unit differs from * the product's, we try to fetch a price variation. * * If a price variation doesn't exist, we add an error to * the cart. */ if ($product->is_free() === false) { if (absint($this->duration) !== $product->get_duration() || $product->get_duration_unit() !== $this->duration_unit) { $price_variation = $product->get_price_variation($this->duration, $this->duration_unit); if ($price_variation) { $price_variation = (object) $price_variation; $amount = $price_variation->amount; $duration = $price_variation->duration; $duration_unit = $price_variation->duration_unit; } else { /* * This product does not have a valid * price variation. We need to add an error. */ // translators: respectively, product name, duration, and duration unit. $message = sprintf(__('%1$s does not have a valid price variation for that billing period (every %2$s %3$s(s)) and was not added to the cart.', 'wp-ultimo'), $product->get_name(), $this->duration, $this->duration_unit); $this->errors->add('missing-price-variations', $message); return false; } } } $line_item_data = apply_filters( 'wu_add_product_line_item', [ 'product' => $product, 'quantity' => $quantity, 'unit_price' => $amount, 'duration' => $duration, 'duration_unit' => $duration_unit, ], $product, $duration, $duration_unit, $this ); $this->products[] = $product; if (empty($line_item_data)) { return false; } $line_item = new Line_Item($line_item_data); /* * Allows for product removal on the checkout summary. */ $line_item->set_product_slug($product->get_slug()); $this->add_line_item($line_item); /** * Signup Fees */ if (empty($product->get_setup_fee())) { return true; } $add_signup_fee = 'renewal' !== $this->get_cart_type(); /** * Filters whether or not the signup fee should be applied. * * @param bool $add_signup_fee Whether or not to add the signup fee. * @param object $product Membership level object. * @param \WP_Ultimo\Checkout\Cart $this Registration object. * * @since 3.1 */ $add_signup_fee = apply_filters('wu_apply_signup_fee', $add_signup_fee, $product, $this); // @phpstan-ignore-line if ( ! $add_signup_fee) { return true; } // translators: placeholder is the product name. $description = ($product->get_setup_fee() > 0) ? __('Signup Fee for %s', 'wp-ultimo') : __('Signup Credit for %s', 'wp-ultimo'); $description = sprintf($description, $product->get_name()); /** * Allow developers to make changes to the setup fee line item. * * @since 2.1 * * @param array $setup_fee_line_item Setup fee line item parameters. * @param \WP_Ultimo\Models\Product $product The product related to the setup fee. * @param \WP_Ultimo\Checkout\Cart $cart The cart object. * @return array */ $setup_fee_line_item = apply_filters( 'wu_add_product_setup_fee_line_item', [ 'product' => $product, 'type' => 'fee', 'description' => '--', 'title' => $description, 'taxable' => $product->is_taxable(), 'recurring' => false, 'unit_price' => $product->get_setup_fee(), 'quantity' => $quantity, ], $product, $this ); $setup_fee_line_item = new Line_Item($setup_fee_line_item); $this->add_line_item($setup_fee_line_item); return true; } /** * Returns an array containing the subtotal per tax rate. * * @since 2.0.0 * @return array $tax_rate => $tax_total. */ public function get_tax_breakthrough() { $line_items = $this->line_items; $tax_brackets = []; foreach ($line_items as $line_item) { $tax_bracket = $line_item->get_tax_rate(); if (isset($tax_brackets[ $tax_bracket ])) { $tax_brackets[ $tax_bracket ] += $line_item->get_tax_total(); continue; } $tax_brackets[ $tax_bracket ] = $line_item->get_tax_total(); } return $tax_brackets; } /** * Determine whether or not the level being registered for has a trial that the current user is eligible * for. This will return false if there is a trial but the user is not eligible for it. * * @access public * @since 2.0.0 * @return bool */ public function has_trial() { $products = $this->get_all_products(); if (empty($products)) { return false; } $is_trial = $this->get_billing_start_date(); if ( ! $is_trial) { return false; } // There is a trial, but let's check eligibility. $customer = wu_get_current_customer(); // No customer, which means they're brand new, which means they're eligible. if (empty($customer)) { return true; } // Check if this is the initial membership payment with trial if ($this->membership && $this->payment && $this->membership->is_trialing()) { return empty($this->payment->get_total()); } return ! $customer->has_trialed(); } /** * Get the recovered payment object. * * @since 2.0.0 * @return object|false Payment object if set, false if not. */ public function get_recovered_payment() { return $this->recovered_payment; } /** * Add discount to the order. * * @since 2.0.0 * * @param string|\WP_Ultimo\Models\Discount_Code $code Coupon code to add. */ public function add_discount_code($code): bool { if (is_a($code, \WP_Ultimo\Models\Discount_Code::class)) { $this->discount_code = $code; return true; } $discount_code = wu_get_discount_code_by_code($code); if ( ! $discount_code) { return false; } $this->discount_code = $discount_code; return true; } /** * Get registration discounts. * * @since 2.5 * @return mixed[]|bool */ public function get_discounts() { return $this->get_line_items_by_type('discount'); } /** * Checks if the cart has any discounts applied. * * @since 2.0.0 * @return boolean */ public function has_discount() { return $this->get_total_discounts() > 0; } /** * Returns a list of line items based on the line item type. * * @since 2.0.0 * * @param string $type The type. Can be 'product', 'fee', 'discount'. * @param array $where_clauses Additional where clauses for search. * @return \WP_Ultimo\Checkout\Line_Item[] */ public function get_line_items_by_type($type = 'product', $where_clauses = []): array { $where_clauses[] = ['type', $type]; // Cast to array recursively $line_items = json_decode(json_encode($this->line_items), true); $line_items = Array_Search::find( $line_items, [ 'where' => $where_clauses, ] ); $ids = array_keys($line_items); return array_filter($this->line_items, fn($id) => in_array($id, $ids, true), ARRAY_FILTER_USE_KEY); } /** * Get registration fees. * * @since 2.0.0 * @return mixed[]|bool */ public function get_fees() { return $this->get_line_items_by_type('fees'); } /** * Calculates the total tax amount. * * @todo Refactor this. * @since 2.0.0 * @return float */ public function get_total_taxes() { $total_taxes = 0; foreach ($this->line_items as $line_item) { $total_taxes += $line_item->get_tax_total(); } return $total_taxes; } /** * Get the total number of fees. * * @since 2.0.0 * * @param null|float $total The total of fees in the order so far. * @param bool $only_recurring | set to only get fees that are recurring. * * @return float */ public function get_total_fees($total = null, $only_recurring = false) { $line_items = $this->get_fees(); if ( ! $line_items) { return 0; } $fees = 0; foreach ($line_items as $fee) { if ($only_recurring && ! $fee->is_recurring()) { continue; } $fees += $fee->get_total(); } // if total is present, make sure that any negative fees are not // greater than the total. if ($total && ($fees + $total) < 0) { $fees = -1 * $total; } return apply_filters('wu_cart_get_total_fees', (float) $fees, $total, $only_recurring, $this); } /** * Get the total proration amount. * * @todo Needs to be used and implemented on the checkout flow. * @since 2.0.0 * * @return float */ public function get_proration_credits() { if ( ! $this->get_fees()) { return 0; } $proration = 0; foreach ($this->get_fees() as $fee) { if ( ! $fee['proration']) { continue; } $proration += $fee['amount']; } return apply_filters('wu_cart_get_proration_fees', (float) $proration, $this); } /** * Get the total discounts. * * @since 2.0.0 * @return float */ public function get_total_discounts() { $total_discount = 0; foreach ($this->line_items as $line_item) { $total_discount -= $line_item->get_discount_total(); } $total_discount = round($total_discount, wu_currency_decimal_filter()); return apply_filters('wu_cart_get_total_discounts', $total_discount, $this); } /** * Gets the subtotal value of the cart. * * @since 2.0.0 * @return float */ public function get_subtotal() { $subtotal = 0; $exclude_types = [ 'discount', 'credit', ]; foreach ($this->line_items as $line_item) { if (in_array($line_item->get_type(), $exclude_types, true)) { continue; } $subtotal += $line_item->get_subtotal(); } if (0 > $subtotal) { $subtotal = 0; } $subtotal = round($subtotal, wu_currency_decimal_filter()); /** * Filter the "initial amount" total. * * @param float $subtotal Total amount due today. * @param \WP_Ultimo\Checkout\Cart $this Cart object. */ return apply_filters('wu_cart_get_subtotal', floatval($subtotal), $this); // @phpstan-ignore-line } /** * Get the registration total due today. * * @since 2.0.0 * @return float */ public function get_total() { $total = 0; foreach ($this->line_items as $line_item) { $total += $line_item->get_total(); } if (0 > $total) { $total = 0; } $total = round($total, wu_currency_decimal_filter()); /** * Filter the "initial amount" total. * * @param float $total Total amount due today. * @param \WP_Ultimo\Checkout\Cart $this Cart object. */ return apply_filters('wu_cart_get_total', floatval($total), $this); // @phpstan-ignore-line } /** * Get the registration recurring total. * * @since 2.0.0 * @return float */ public function get_recurring_total() { $total = 0; foreach ($this->line_items as $line_item) { if ( ! $line_item->is_recurring()) { continue; } /* * Check for coupon codes */ if ($line_item->get_discount_total() > 0 && ! $line_item->should_apply_discount_to_renewals()) { $new_line_item = clone $line_item; $new_line_item->attributes( [ 'discount_rate' => 0, ] ); $new_line_item->recalculate_totals(); $amount = $new_line_item->get_total(); } else { $amount = $line_item->get_total(); } $total += $amount; } if (0 > $total) { $total = 0; } $total = round($total, wu_currency_decimal_filter()); /** * Filters the "recurring amount" total. * * @param float $total Recurring amount. * @param \WP_Ultimo\Checkout\Cart $this Cart object. */ return apply_filters('wu_cart_get_recurring_total', floatval($total), $this); // @phpstan-ignore-line } /** * Gets the recurring subtotal, before taxes. * * @since 2.0.0 * @return float */ public function get_recurring_subtotal() { $subtotal = 0; foreach ($this->line_items as $line_item) { if ( ! $line_item->is_recurring()) { continue; } $subtotal += $line_item->get_subtotal(); } if (0 > $subtotal) { $subtotal = 0; } $subtotal = round($subtotal, wu_currency_decimal_filter()); /** * Filters the "recurring amount" total. * * @param float $subtotal Recurring amount. * @param \WP_Ultimo\Checkout\Cart $this Cart object. */ return apply_filters('wu_cart_get_recurring_total', floatval($subtotal), $this); // @phpstan-ignore-line } /** * Returns the timestamp of the end of the trial period. * * @since 2.0.0 * @return int|null */ public function get_billing_start_date() { if ($this->is_free() && ! $this->has_recurring()) { return null; } /* * Set extremely high value at first to prevent any change of errors. */ $smallest_trial = 300 * YEAR_IN_SECONDS; foreach ($this->get_all_products() as $product) { if ( ! $product->has_trial()) { $smallest_trial = 0; } $duration = $product->get_trial_duration(); $duration_unit = $product->get_trial_duration_unit(); if ($duration && $duration_unit) { $trial_period = strtotime("+$duration $duration_unit"); if ($trial_period < $smallest_trial) { $smallest_trial = $trial_period; } } } return $smallest_trial; } /** * Returns the timestamp of the next charge, if recurring. * * @since 2.0.0 * @return string|false */ public function get_billing_next_charge_date() { /* * Set extremely high value at first to prevent any chance of errors. */ $smallest_next_charge = 300 * YEAR_IN_SECONDS; if ($this->get_cart_type() === 'downgrade') { $membership = $this->membership; if ($membership->is_active() || $membership->get_status() === Membership_Status::TRIALING) { $next_charge = strtotime($membership->get_date_expiration()); return $next_charge; } } foreach ($this->get_all_products() as $product) { if ( ! $product->is_recurring() || ($product->has_trial() && $this->has_trial())) { continue; } $duration = $product->get_duration(); $duration_unit = $product->get_duration_unit(); $next_charge = strtotime("+$duration $duration_unit"); if ($next_charge < $smallest_next_charge) { $smallest_next_charge = $next_charge; } } return $smallest_next_charge; } /** * Checks if the order is recurring or not. * * @since 2.0.0 * @return boolean */ public function has_recurring() { return $this->get_recurring_total() > 0; } /** * Returns an array with all types of line-items of the cart. * * @since 2.0.0 * @return array */ public function get_line_items() { return $this->line_items; } /** * Apply discounts to a line item. * * @since 2.0.0 * * @param \WP_Ultimo\Checkout\Line_Item $line_item The line item. * @return \WP_Ultimo\Checkout\Line_Item */ public function apply_discounts_to_item($line_item) { /** * Product is not taxable, bail. */ if ( ! $line_item->is_discountable() || ! $this->discount_code) { return $line_item; } if (is_wp_error($this->discount_code->is_valid($line_item->get_product_id()))) { return $line_item; } /** * Should apply to fees? */ if ($line_item->get_type() === 'fee') { if ($this->discount_code->get_setup_fee_value() <= 0) { return $line_item; } $line_item->attributes( [ 'discount_rate' => $this->discount_code->get_setup_fee_value(), 'discount_type' => $this->discount_code->get_setup_fee_type(), 'apply_discount_to_renewals' => false, 'discount_label' => strtoupper($this->discount_code->get_code()), ] ); } else { $line_item->attributes( [ 'discount_rate' => $this->discount_code->get_value(), 'discount_type' => $this->discount_code->get_type(), 'apply_discount_to_renewals' => $this->discount_code->should_apply_to_renewals(), 'discount_label' => strtoupper($this->discount_code->get_code()), ] ); } $line_item->recalculate_totals(); return $line_item; } /** * Apply taxes to a line item. * * @since 2.0.0 * * @param \WP_Ultimo\Checkout\Line_Item $line_item The line item. * @return \WP_Ultimo\Checkout\Line_Item */ public function apply_taxes_to_item($line_item) { /** * Tax collection is not enabled */ if ( ! wu_should_collect_taxes()) { return $line_item; } /** * Product is not taxable, bail. */ if ( ! $line_item->is_taxable()) { return $line_item; } $tax_category = $line_item->get_tax_category(); /** * No tax category, bail. */ if ( ! $tax_category) { return $line_item; } $tax_rates = apply_filters('wu_cart_applicable_tax_rates', wu_get_applicable_tax_rates($this->country, $tax_category, $this->state, $this->city), $this->country, $tax_category, $this); if (empty($tax_rates)) { return $line_item; } foreach ($tax_rates as $applicable_tax_rate) { $tax_type = 'percentage'; $tax_rate = $applicable_tax_rate['tax_rate']; $tax_label = $applicable_tax_rate['title']; continue; } $line_item->attributes( [ 'tax_rate' => $tax_rate ?? 0, 'tax_type' => $tax_type ?? 'percentage', 'tax_label' => $tax_label ?? '', 'tax_inclusive' => wu_get_setting('inclusive_tax', false), 'tax_exempt' => $this->is_tax_exempt(), ] ); $line_item->recalculate_totals(); return $line_item; } /** * Calculates the totals of the cart and return them. * * @since 2.0.0 * @return object */ public function calculate_totals() { return (object) [ 'recurring' => (object) [ 'subtotal' => $this->get_recurring_subtotal(), 'total' => $this->get_recurring_total(), ], 'subtotal' => $this->get_subtotal(), 'total_taxes' => $this->get_total_taxes(), 'total_fees' => $this->get_total_fees(), 'total_discounts' => $this->get_total_discounts(), 'total' => $this->get_total(), ]; } /** * Used for serialization purposes. * * @since 2.0.0 * @return string */ public function jsonSerialize(): string { return json_encode($this->done()); } /** * Get the list of extra parameters. * * @since 2.0.0 * @return array */ public function get_extra_params() { $extra_params = []; foreach ($this->extra as $key) { $extra_params[ $key ] = $this->get_param($key); } return apply_filters('wu_cart_get_extra_params', $extra_params, $this); } /** * Implements our on json_decode version of this object. Useful for use in vue.js. * * @since 2.0.0 * @return \stdClass */ public function done() { $totals = $this->calculate_totals(); $errors = []; if ($this->errors->has_errors()) { foreach ($this->errors as $code => $messages) { foreach ($messages as $message) { $errors[] = [ 'code' => $code, 'message' => $message, ]; } } } return (object) [ 'errors' => $errors, 'url' => $this->get_cart_url(), 'type' => $this->get_cart_type(), 'valid' => $this->is_valid(), 'is_free' => $this->is_free(), 'should_collect_payment' => $this->should_collect_payment(), 'has_plan' => $this->has_plan(), 'has_recurring' => $this->has_recurring(), 'has_discount' => $this->has_discount(), 'has_trial' => $this->has_trial(), 'line_items' => $this->get_line_items(), 'discount_code' => $this->get_discount_code(), 'totals' => $this->calculate_totals(), 'extra' => $this->get_extra_params(), 'dates' => (object) [ 'date_trial_end' => $this->get_billing_start_date(), 'date_next_charge' => $this->get_billing_next_charge_date(), ], ]; } /** * Converts the current cart to an array of membership elements. * * @since 2.0.0 * @return array */ public function to_membership_data() { $membership_data = []; $all_additional_products = $this->get_line_items_by_type( 'product', [ ['product_id', '!=', $this->get_plan_id()], ] ); $addon_list = []; foreach ($all_additional_products as $line_item) { $addon_list[ $line_item->get_product_id() ] = $line_item->get_quantity(); } $membership_data = array_merge( [ 'recurring' => $this->has_recurring(), 'plan_id' => $this->get_plan() ? $this->get_plan()->get_id() : 0, 'initial_amount' => $this->get_total(), 'addon_products' => $addon_list, 'currency' => $this->get_currency(), 'duration' => $this->get_duration(), 'duration_unit' => $this->get_duration_unit(), 'amount' => $this->get_recurring_total(), 'times_billed' => 0, 'billing_cycles' => $this->get_plan() ? $this->get_plan()->get_billing_cycles() : 0, 'auto_renew' => false, // @todo: revisit 'upgraded_from' => false, // @todo: revisit ] ); return $membership_data; } /** * Converts the current cart to a payment data array. * * @since 2.0.0 * @return array */ public function to_payment_data() { $payment_data = []; // Creates the pending payment $payment_data = [ 'status' => 'pending', 'tax_total' => $this->get_total_taxes(), 'fees' => $this->get_total_fees(), 'discounts' => $this->get_total_discounts(), 'line_items' => $this->get_line_items(), 'discount_code' => $this->get_discount_code() ? $this->get_discount_code()->get_code() : '', 'subtotal' => $this->get_subtotal(), 'total' => $this->get_total(), ]; return $payment_data; } /** * Get the value of discount_code * * @since 2.0.0 * @return null|\WP_Ultimo\Models\Discount_Code */ public function get_discount_code() { return $this->discount_code; } /** * Get the value of plan_id * * @since 2.0.0 * @return int */ public function get_plan_id() { return $this->plan_id; } /** * Get the currency code. * * @since 2.0.0 * @return string */ public function get_currency() { return $this->currency; } /** * Set the currency. * * @since 2.0.0 * @param mixed $currency The currency code. * @return void */ public function set_currency($currency): void { $this->currency = $currency; } /** * Get the cart membership. * * @since 2.0.0 * @return null|\WP_Ultimo\Models\Membership */ public function get_membership() { return $this->membership; } /** * Get the cart payment. * * @since 2.0.0 * @return null|\WP_Ultimo\Models\Payment */ public function get_payment() { return $this->payment; } /** * Get the cart customer. * * @since 2.0.0 * @return null|\WP_Ultimo\Models\Customer */ public function get_customer() { return $this->customer; } /** * Set the cart membership. * * @since 2.0.0 * @param \WP_Ultimo\Models\Membership $membership A valid membership object. * @return void */ public function set_membership($membership): void { $this->membership = $membership; } /** * Set the cart customer. * * @since 2.0.0 * @param \WP_Ultimo\Models\Customer $customer A valid customer object. * @return void */ public function set_customer($customer): void { $this->customer = $customer; } /** * Set the cart payment. * * @since 2.0.0 * @param \WP_Ultimo\Models\Payment $payment A valid payment object. * @return void */ public function set_payment($payment): void { $this->payment = $payment; } /** * Get the value of auto_renew. * * @since 2.0.0 * @return mixed */ public function should_auto_renew() { return 'yes' === $this->auto_renew || true === $this->auto_renew; } /** * Get the cart type. * * @since 2.0.0 * @return string */ public function get_cart_type() { return $this->cart_type; } /** * Get the country of the customer. * * @since 2.0.0 * @return string */ public function get_country() { return $this->country; } /** * Set the country of the customer. * * @since 2.0.0 * @param string $country The country of the customer. * @return void */ public function set_country($country): void { $this->country = $country; } /** * Builds a cart URL that we can use with the browser history APIs. * * @since 2.0.0 * @return string */ public function get_cart_url() { $base_url = ''; $plan = wu_get_product($this->plan_id); if ($plan) { $base_url .= $plan->get_slug(); } if ($this->duration && absint($this->duration) !== 1) { $base_url .= "/{$this->duration}"; } if ($this->duration_unit && 'month' !== $this->duration_unit) { $base_url .= "/{$this->duration_unit}"; } $all_products = $this->products; $products_list = []; foreach ($all_products as $product) { if ($product->get_id() !== $this->plan_id) { $products_list[] = $product->get_slug(); } } return add_query_arg( [ 'products' => $products_list, ], $base_url ); } }