includes/models/model.llms.order.php
<?php
/**
* LLMS_Order class/model file
*
* @package LifterLMS/Models/Classes
*
* @since 3.0.0
* @version 7.1.0
*/
defined( 'ABSPATH' ) || exit;
/**
* LifterLMS order model.
*
* Provides CRUD operations for the `llms_order` post type.
*
* @property string $access_expiration Access expiration type, accepts: lifetime (default), limited-period, or limited-date.
* @property string $access_expires Date on which access expires in `m/d/Y` format. Only applicable when the `$access_expiration` property is set to "limited-date".
* @property int $access_length Length of access from time of purchase, combine with the `$access_period`. Only applicable when the `$access_expiration` property is set to "limited-period".
* @property string $access_period Time period of access from time of purchase, combine with `$access_length`. Only applicable when the `$access_expiration` property is set to "limited-period". Accepts: year, month, week, or day.
* @property string $anonymized Determines if the order has been anonymized due to a personal information erasure request. Accepts "yes" or "no".
* @property string $billing_address_1 Customer billing address line 1.
* @property string $billing_address_2 Customer billing address line 2.
* @property string $billing_city Customer billing city.
* @property string $billing_country Customer billing country, two character ISO code.
* @property string $billing_email Customer email address.
* @property string $billing_first_name Customer first name.
* @property string $billing_last_name Customer last name.
* @property string $billing_phone Customer phone number.
* @property string $billing_state Customer billing state.
* @property string $billing_zip Customer billing zip/postal code.
* @property int $billing_frequency The billing frequency interval. A value of `0` indicates a one-time payment. Accepts integers <= 6.
* @property int $billing_length Number of intervals to run payment for, combine with `$billing_period` & `$billing_frequency`. A value of `0` indicates that recurring payments run indefinitely (until cancelled). Only applicable if `$billing_frequency` is not 0.
* @property string $billing_period The billing period. Combine with `$length`. Only applicable if `$billing_frequency` is not 0. Accepts: year, month, week, or day.
* @property float $coupon_amount Amount of the coupon (flat/percentage) in relation to the plan amount.
* @property float $coupon_amout_trial Amount of the coupon (flat/percentage) in relation to the plan trial amount where applicable.
* @property string $coupon_code Coupon code applied to the order.
* @property int $coupon_id The WP_Post ID of the used coupon.
* @property string $coupon_type Type of coupon used, either percent or dollar.
* @property string $coupon_used Whether or not a coupon was used for the order. Accepts yes or no.
* @property float $coupon_value Value of the coupon. When on sale, `$sale_price` minus `$total`; when not on sale `$original_total` minus `$total`.
* @property float $coupon_value_trial Value of the coupon applied to the trial. The `$trial_original_total` minus `$trial_total`.
* @property string $currency Transaction's currency code.
* @property string $date_access_expires Date when access should expire as a datetime string: `Y-m-d H:i:s`.
* @property string $date_next_payment Date when the next recurring payment is due as a datemtime string: `Y-m-d H:i:s`. Use function LLMS_Order::get_next_payment_due_date() instead of accessing directly!
* @property string $date_trial_end Date when the trial ends for orders with a trial as a datemtime string: `Y-m-d H:i:s`. Use function LLMS_Order::get_trial_end_date() instead of accessing directly!
* @property string $gateway_api_mode API Mode of the gateway when the transaction was made, either "test" or "live".
* @property string $gateway_customer_id Gateway's unique ID for the customer who placed the order (if supported by the gateway).
* @property string $gateway_source_id Gateway's unique ID for the card or source to be used for recurring subscriptions (if supported by gateway).
* @property string $gateway_subscription_id Gateway's unique ID for the recurring subscription (if supported by the gateway).
* @property int $id The WP_Post ID of the order.
* @property int $last_retry_rule Rule number for current retry step for the order.
* @property string $on_sale Whether or not sale pricing was used for the plan, either "yes" or "no".
* @property string $order_key A unique identifier for the order that can be passed safely in URLs.
* @property string $order_type Single or recurring order, either "single" or "recurring".
* @property float $original_total Price of the order before applicable sale and coupon adjustments.
* @property string $payment_gateway LifterLMS Payment Gateway ID (eg "paypal" or "stripe").
* @property int $plan_id WP_Post ID of the purchased access plan.
* @property string $plan_sku SKU of the purchased access plan.
* @property string $plan_title Title / Name of the purchased access plan.
* @property string $plan_ended Whether or not the payment plan has ended. Only applicable when the plan is not "unlimited". Accepts "yes" or "no".
* @property int $product_id WP_Post ID of the purchased course or membership product.
* @property string $product_sku SKU of the purchased product.
* @property string $product_title Title / Name of the purchased product.
* @property string $product_type Type of product purchased (course or membership).
* @property float $sale_price Sale price before coupon adjustments.
* @property float $sale_value The value of the sale, `$original_total` - `$sale_price`.
* @property string $start_date Date when access was initially granted; this is used to determine when access expires.
* @property array $temp_gateway_ids {
* An associative array containing gateway ids. The gateway IDs are cached in this meta property while the source is being
* switched. Any gateway running actions when a source is switched may need to know the previous source IDs which might be
* cleared or overwritten by other gateways during the switch.
*
* @type string customer The value of the `gateway_customer_id` property when the source switch starts.
* @type string source The value of the `gateway_source_id` property when the source switch starts.
* @type string subscription The value of the `gateway_subscription_id` property when the source switch starts.
* }
* @property float $total Actual price of the order, after applicable sale & coupon adjustments.
* @property int $trial_length Length of the trial. Combined with $trial_period to determine the actual length of the trial.
* @property string $trial_offer Whether or not there was a trial offer applied to the order, either yes or no.
* @property float $trial_original_total Total price of the trial before applicable coupon adjustments.
* @property string $trial_period Period for the trial period. Accepts: year, month, week, or day.
* @property float $trial_total Total price of the trial after applicable coupon adjustments/
* @property int $user_id Customer WP_User ID.
* @property string $user_ip_address Customer's IP address at time of purchase.
*
* @since 3.0.0
* @since 3.32.0 Update to use latest action-scheduler functions.
* @since 3.35.0 Prepare transaction revenue SQL query properly; Sanitize $_SERVER data.
* @since 4.7.0 Added `plan_ended` meta property.
* @since 5.3.0 Removed usage of the meta property `date_billing_end` and removed private method `calculate_billing_end_date()`.
*/
class LLMS_Order extends LLMS_Post_Model {
/**
* Database post type.
*
* @var string
*/
protected $db_post_type = 'llms_order';
/**
* Model post type.
*
* @var string
*/
protected $model_post_type = 'order';
/**
* Meta properties.
*
* @var array
*/
protected $properties = array(
'anonymized' => 'yesno',
'coupon_amount' => 'float',
'coupon_amout_trial' => 'float',
'coupon_value' => 'float',
'coupon_value_trial' => 'float',
'original_total' => 'float',
'sale_price' => 'float',
'sale_value' => 'float',
'total' => 'float',
'trial_original_total' => 'float',
'trial_total' => 'float',
'access_length' => 'absint',
'billing_frequency' => 'absint',
'billing_length' => 'absint',
'coupon_id' => 'absint',
'plan_id' => 'absint',
'product_id' => 'absint',
'trial_length' => 'absint',
'user_id' => 'absint',
'access_expiration' => 'text',
'access_expires' => 'text',
'access_period' => 'text',
'billing_address_1' => 'text',
'billing_address_2' => 'text',
'billing_city' => 'text',
'billing_country' => 'text',
'billing_email' => 'text',
'billing_first_name' => 'text',
'billing_last_name' => 'text',
'billing_state' => 'text',
'billing_zip' => 'text',
'billing_period' => 'text',
'coupon_code' => 'text',
'coupon_type' => 'text',
'coupon_used' => 'text',
'currency' => 'text',
'on_sale' => 'text',
'order_key' => 'text',
'order_type' => 'text',
'payment_gateway' => 'text',
'plan_ended' => 'yesno',
'plan_sku' => 'text',
'plan_title' => 'text',
'product_sku' => 'text',
'product_type' => 'text',
'title' => 'text',
'gateway_api_mode' => 'text',
'gateway_customer_id' => 'text',
'trial_offer' => 'text',
'trial_period' => 'text',
'user_ip_address' => 'text',
'date_access_expires' => 'text',
'date_next_payment' => 'text',
'date_trial_end' => 'text',
'temp_gateway_ids' => 'array',
);
/**
* Add an admin-only note to the order visible on the admin panel
* notes are recorded using the wp comments API & DB
*
* @since 3.0.0
* @since 3.35.0 Sanitize $_SERVER data.
*
* @param string $note Note content.
* @param boolean $added_by_user Optional. If this is an admin-submitted note adds user info to note meta. Default is false.
* @return null|int Null on error or WP_Comment ID of the note.
*/
public function add_note( $note, $added_by_user = false ) {
if ( ! $note ) {
return;
}
// Added by a user from the admin panel.
if ( $added_by_user && is_user_logged_in() && current_user_can( apply_filters( 'lifterlms_admin_order_access', 'manage_options' ) ) ) {
$user_id = get_current_user_id();
$user = get_user_by( 'id', $user_id );
$author = $user->display_name;
$author_email = $user->user_email;
} else {
$user_id = 0;
$author = _x( 'LifterLMS', 'default order note author', 'lifterlms' );
$author_email = strtolower( _x( 'LifterLms', 'default order note author', 'lifterlms' ) ) . '@';
$author_email .= isset( $_SERVER['HTTP_HOST'] ) ? str_replace( 'www.', '', sanitize_text_field( wp_unslash( $_SERVER['HTTP_HOST'] ) ) ) : 'noreply.com';
$author_email = sanitize_email( $author_email );
}
$note_id = wp_insert_comment(
apply_filters(
'llms_add_order_note_content',
array(
'comment_post_ID' => $this->get( 'id' ),
'comment_author' => $author,
'comment_author_email' => $author_email,
'comment_author_url' => '',
'comment_content' => $note,
'comment_type' => 'llms_order_note',
'comment_parent' => 0,
'user_id' => $user_id,
'comment_approved' => 1,
'comment_agent' => 'LifterLMS',
'comment_date' => current_time( 'mysql' ),
)
)
);
do_action( 'llms_new_order_note_added', $note_id, $this );
return $note_id;
}
/**
* Called after inserting a new order into the database
*
* @since 3.0.0
*
* @return void
*/
protected function after_create() {
// Add a random key that can be passed in the URL and whatever.
$this->set( 'order_key', $this->generate_order_key() );
}
/**
* Calculate the next payment due date
*
* @since 3.10.0
* @since 3.12.0 Unknown.
* @since 3.37.6 Now uses the last successful transaction time to calculate from when the previously
* stored next payment date is in the future.
* @since 4.9.0 Fix comparison for PHP8 compat.
* @since 5.3.0 Determine if a limited order has ended based on number of remaining payments in favor of current date/time.
*
* @param string $format PHP date format used to format the returned date string.
* @return string The formatted next payment due date or an empty string when there is no next payment.
*/
private function calculate_next_payment_date( $format = 'Y-m-d H:i:s' ) {
// If the limited plan has already ended return early.
$remaining = $this->get_remaining_payments();
if ( 0 === $remaining ) {
// This filter is documented below.
return apply_filters( 'llms_order_calculate_next_payment_date', '', $format, $this );
}
$start_time = $this->get_date( 'date', 'U' );
$next_payment_time = $this->get_date( 'date_next_payment', 'U' );
$last_txn_time = $this->get_last_transaction_date( 'llms-txn-succeeded', 'recurring', 'U' );
// If were on a trial and the trial hasn't ended yet next payment date is the date the trial ends.
if ( $this->has_trial() && ! $this->has_trial_ended() ) {
$next_payment_time = $this->get_trial_end_date( 'U' );
} else {
/**
* Calculate next payment date from the saved `date_next_payment` calculated during
* the previous recurring transaction or during order initialization.
*
* This condition will be encountered during the 2nd, 3rd, 4th, etc... recurring payments.
*/
if ( $next_payment_time && $next_payment_time < llms_current_time( 'timestamp' ) ) {
$from_time = $next_payment_time;
/**
* Use the order's last successful transaction date.
*
* This will be encountered when any amount of "chaos" is
* introduced causing the previously stored `date_next_payment`
* to be GREATER than the current time.
*
* Orders created
*/
} elseif ( $last_txn_time && $last_txn_time > $start_time ) {
$from_time = $last_txn_time;
/**
* Use the order's creation time.
*
* This condition will be encountered for the 1st recurring payment only.
*/
} else {
$from_time = $start_time;
}
$period = $this->get( 'billing_period' );
$frequency = $this->get( 'billing_frequency' );
$next_payment_time = strtotime( '+' . $frequency . ' ' . $period, $from_time );
/**
* Make sure the next payment is more than 2 hours in the future
*
* This ensures changes to the site's timezone because of daylight savings
* will never cause a 2nd renewal payment to be processed on the same day.
*/
$i = 1;
while ( $next_payment_time < ( llms_current_time( 'timestamp', true ) + 2 * HOUR_IN_SECONDS ) && $i < 3000 ) {
$next_payment_time = strtotime( '+' . $frequency . ' ' . $period, $next_payment_time );
$i++;
}
}
/**
* Filter the calculated next payment date
*
* @since 3.10.0
*
* @param string $ret The formatted next payment due date or an empty string when there is no next payment.
* @param string $format The requested date format.
* @param LLMS_Order $order The order object.
*/
return apply_filters( 'llms_order_calculate_next_payment_date', date( $format, $next_payment_time ), $format, $this );
}
/**
* Calculate the end date of the trial
*
* @since 3.10.0
*
* @param string $format Optional. Desired return format of the date. Defalt is 'Y-m-d H:i:s'.
* @return string
*/
private function calculate_trial_end_date( $format = 'Y-m-d H:i:s' ) {
$start = $this->get_date( 'date', 'U' ); // Start with the date the order was initially created.
$length = $this->get( 'trial_length' );
$period = $this->get( 'trial_period' );
$end = strtotime( '+' . $length . ' ' . $period, $start );
$ret = date_i18n( $format, $end );
return apply_filters( 'llms_order_calculate_trial_end_date', $ret, $format, $this );
}
/**
* Determines if an order can be confirmed.
*
* An order can be confirmed only when the order's status is pending.
*
* Additional requirements can be introduced via the filter `llms_order_can_be_confirmed`.
*
* @since 7.0.0
*
* @return boolean
*/
public function can_be_confirmed() {
/**
* Determine if the order can be confirmed.
*
* @since 3.34.4
*
* @param boolean $can_be_confirmed Whether or not the order can be confirmed.
* @param LLMS_Order $order Order object.
* @param string $gateway_id Payment gateway ID.
*/
return apply_filters(
'llms_order_can_be_confirmed',
( 'llms-pending' === $this->get( 'status' ) ),
$this,
$this->get( 'payment_gateway' )
);
}
/**
* Determine if the order can be retried for recurring payments
*
* @since 3.10.0
* @since 5.2.0 Use strict type comparison.
* @since 5.2.1 Combine conditions that return `false`.
*
* @return boolean
*/
public function can_be_retried() {
$can_retry = true;
if (
// Only recurring orders can be retried.
! $this->is_recurring() ||
// Recurring rety feature is disabled.
! llms_parse_bool( get_option( 'lifterlms_recurring_payment_retry', 'yes' ) ) ||
// Only active & on-hold orders qualify for a retry.
! in_array( $this->get( 'status' ), array( 'llms-active', 'llms-on-hold' ), true )
) {
$can_retry = false;
} else {
// If the gateway isn't active or the gateway doesn't support recurring retries.
$gateway = $this->get_gateway();
if ( is_wp_error( $gateway ) || ! $gateway->supports( 'recurring_retry' ) ) {
$can_retry = false;
}
}
/**
* Filters whether or not a recurring order can be retried
*
* @since 5.2.1
*
* @param boolean $can_retry Whether or not the order can be retried.
* @param LLMS_Order $order Order object.
*/
return apply_filters( 'llms_order_can_be_retried', $can_retry, $this );
}
/**
* Determines if the order can be resubscribed to.
*
* @since 3.19.0
* @since 5.2.0 Use strict type comparison.
*
* @return bool
*/
public function can_resubscribe() {
$can_resubscribe = false;
if ( $this->is_recurring() ) {
/**
* Filters the order statuses from which an order can be reactivated.
*
* @since 7.0.0
*
* @param string[] $allowed_statuses The list of allowed order statuses.
*/
$allowed_statuses = apply_filters(
'llms_order_status_can_resubscribe_from',
array(
'llms-on-hold',
'llms-pending',
'llms-pending-cancel',
)
);
$can_resubscribe = in_array( $this->get( 'status' ), $allowed_statuses, true );
}
/**
* Determines whether or not a user can resubscribe to an inactive recurring payment order.
*
* @since 3.19.0
*
* @param boolean $can_resubscribe Whether or not a user can resubscribe.
* @param LLMS_Order $order The order object.
*/
return apply_filters( 'llms_order_can_resubscribe', $can_resubscribe, $this );
}
/**
* Determines if the order's payment source can be changed.
*
* @since 7.0.0
*
* @return boolean
*/
public function can_switch_source() {
$can_switch = 'llms-active' === $this->get( 'status' ) || $this->can_resubscribe();
/**
* Filters whether or not the order's payment source can be changed.
*
* @since 7.0.0
*
* @param boolean $can_switch Whether or not the order's source can be switched.
* @param LLMS_Order $order The order object.
*/
return apply_filters( 'llms_order_can_switch_source', $can_switch, $this );
}
/**
* Generate an order key for the order
*
* @since 3.0.0
*
* @return string
*/
public function generate_order_key() {
/**
* Modify the generated order key for the order.
*
* @since 3.0.0
* @since 5.2.1 Added the `$order` parameter.
*
* @param string $order_key The generated order key.
* @param LLMS_Order $order_key Order object.
*/
return apply_filters( 'lifterlms_generate_order_key', uniqid( 'order-' ), $this );
}
/**
* Determine the date when access will expire
*
* Based on the access settings of the access plan
* at the `$start_date` of access.
*
* @since 3.0.0
* @since 3.19.0 Unknown.
*
* @param string $format Optional. Date format. Default is 'Y-m-d'.
* @return string Date string.
* "Lifetime Access" for plans with lifetime access.
* "To be Determined" for limited date when access hasn't started yet.
*/
public function get_access_expiration_date( $format = 'Y-m-d' ) {
$type = $this->get( 'access_expiration' );
$ret = $this->get_date( 'date_access_expires', $format );
if ( ! $ret ) {
switch ( $type ) {
case 'lifetime':
$ret = __( 'Lifetime Access', 'lifterlms' );
break;
case 'limited-date':
$ret = date_i18n( $format, ( $this->get_date( 'access_expires', 'U' ) + ( DAY_IN_SECONDS - 1 ) ) );
break;
case 'limited-period':
if ( $this->get( 'start_date' ) ) {
$time = strtotime( '+' . $this->get( 'access_length' ) . ' ' . $this->get( 'access_period' ), $this->get_date( 'start_date', 'U' ) ) + ( DAY_IN_SECONDS - 1 );
$ret = date_i18n( $format, $time );
} else {
$ret = __( 'To be Determined', 'lifterlms' );
}
break;
default:
$ret = apply_filters( 'llms_order_' . $type . '_access_expiration_date', $type, $this, $format );
}
}
return apply_filters( 'llms_order_get_access_expiration_date', $ret, $this, $format );
}
/**
* Get the current status of a student's access
*
* Based on the access plan data stored on the order at the time of purchase.
*
* @since 3.0.0
* @since 3.19.0 Unknown.
* @since 5.2.0 Use stric type comparison.
*
* @return string 'inactive' If the order is refunded, failed, pending, etc...
* 'expired' If access has expired according to $this->get_access_expiration_date()
* 'active' Otherwise.
*/
public function get_access_status() {
$statuses = apply_filters(
'llms_order_allow_access_stasuses',
array(
'llms-active',
'llms-completed',
'llms-pending-cancel',
/**
* Recurring orders can expire but still grant access
* eg: 3monthly payments grants 1 year of access
* on the 4th month the order will be marked as expired
* but the access has not yet expired based on the data below.
*/
'llms-expired',
)
);
// If the order doesn't have one of the allowed statuses.
// Return 'inactive' and don't bother checking expiration data.
if ( ! in_array( $this->get( 'status' ), $statuses, true ) ) {
return 'inactive';
}
// Get the expiration date as a timestamp.
$expires = $this->get_access_expiration_date( 'U' );
/**
* A translated non-numeric string will be returned for lifetime access
* so if we have a timestamp we should compare it against the current time
* to determine if access has expired.
*/
if ( is_numeric( $expires ) ) {
$now = llms_current_time( 'timestamp' );
// Expiration date is in the past
// eg: the access has already expired.
if ( $expires < $now ) {
return 'expired';
}
}
// We're active.
return 'active';
}
/**
* Retrieve arguments passed to order-related events processed by the action scheduler
*
* @since 3.19.0
*
* @return array
*/
protected function get_action_args() {
return array(
'order_id' => $this->get( 'id' ),
);
}
/**
* Get the formatted coupon amount with a currency symbol or percentage
*
* @since 3.0.0
*
* @param string $payment Coupon discount type, either 'regular' or 'trial'.
* @return string
*/
public function get_coupon_amount( $payment = 'regular' ) {
if ( 'regular' === $payment ) {
$amount = $this->get( 'coupon_amount' );
} elseif ( 'trial' === $payment ) {
$amount = $this->get( 'coupon_amount_trial' );
}
$type = $this->get( 'coupon_type' );
if ( 'percent' === $type ) {
$amount = $amount . '%';
} elseif ( 'dollar' === $type ) {
$amount = llms_price( $amount );
}
return $amount;
}
/**
* Retrieve the customer's full name
*
* @since 3.0.0
* @since 3.18.0 Unknown.
*
* @return string
*/
public function get_customer_name() {
if ( 'yes' === $this->get( 'anonymized' ) ) {
return __( 'Anonymous', 'lifterlms' );
}
return trim( $this->get( 'billing_first_name' ) . ' ' . $this->get( 'billing_last_name' ) );
}
/**
* Retrieve the customer's full billing address
*
* @since 5.2.0
*
* @return string
*/
public function get_customer_full_address() {
$billing_address_1 = $this->get( 'billing_address_1' );
if ( empty( $billing_address_1 ) ) {
return '';
}
$address = array(
trim( $billing_address_1 . ' ' . $this->get( 'billing_address_2' ) ),
);
$address[] = trim( $this->get( 'billing_city' ) . ' ' . $this->get( 'billing_state' ) );
$address[] = $this->get( 'billing_zip' );
$address[] = llms_get_country_name( $this->get( 'billing_country' ) );
return implode( ', ', array_filter( $address ) );
}
/**
* An array of default arguments to pass to $this->create() when creating a new post
*
* @since 3.0.0
* @since 3.10.0 Unknown.
* @since 5.3.1 Set the `post_date` property using `llms_current_time()`.
* @since 5.9.0 Remove usage of deprecated `strftime()`.
*
* @param string $title Title to create the post with.
* @return array
*/
protected function get_creation_args( $title = '' ) {
$date = llms_current_time( 'mysql' );
if ( empty( $title ) ) {
$title = sprintf(
// Translators: %1$s = Transaction creation date.
__( 'Order – %1$s', 'lifterlms' ),
date_format( date_create( $date ), 'M d, Y @ h:i A' )
);
}
return apply_filters(
"llms_{$this->model_post_type}_get_creation_args",
array(
'comment_status' => 'closed',
'ping_status' => 'closed',
'post_author' => 1,
'post_content' => '',
'post_date' => $date,
'post_excerpt' => '',
'post_password' => uniqid( 'order_' ),
'post_status' => 'llms-' . apply_filters( 'llms_default_order_status', 'pending' ),
'post_title' => $title,
'post_type' => $this->get( 'db_post_type' ),
),
$this
);
}
/**
* Retrieve the payment gateway instance for the order's selected payment gateway
*
* @since 1.0.0
*
* @return LLMS_Payment_Gateway|WP_Error Instance of the LLMS_Payment_Gateway extending class used for the payment.
* WP_Error if the gateway cannot be located, e.g. because it's no longer enabled.
*/
public function get_gateway() {
$gateways = llms()->payment_gateways();
$gateway = $gateways->get_gateway_by_id( $this->get( 'payment_gateway' ) );
if ( $gateway && ( $gateway->is_enabled() || is_admin() ) ) {
return $gateway;
} else {
return new WP_Error( 'error', sprintf( __( 'Payment gateway %s could not be located or is no longer enabled', 'lifterlms' ), $this->get( 'payment_gateway' ) ) );
}
}
/**
* Get the initial payment amount due on checkout
*
* This will always be the value of "total" except when the product has a trial.
*
* @since 3.0.0
*
* @return mixed
*/
public function get_initial_price( $price_args = array(), $format = 'html' ) {
if ( $this->has_trial() ) {
$price = 'trial_total';
} else {
$price = 'total';
}
return $this->get_price( $price, $price_args, $format );
}
/**
* Get an array of the order notes
*
* Each note is actually a WordPress comment.
*
* @since 3.0.0
*
* @param integer $number Number of comments to return.
* @param integer $page Page number for pagination.
* @return array
*/
public function get_notes( $number = 10, $page = 1 ) {
$comments = get_comments(
array(
'status' => 'approve',
'number' => $number,
'offset' => ( $page - 1 ) * $number,
'post_id' => $this->get( 'id' ),
)
);
return $comments;
}
/**
* Retrieve an LLMS_Post_Model object for the associated product
*
* @since 3.8.0
*
* @return LLMS_Post_Model|WP_Post|null|false LLMS_Post_Model extended object (LLMS_Course|LLMS_Membership),
* null if WP get_post() fails,
* false if LLMS_Post_Model extended class isn't found.
*/
public function get_product() {
return llms_get_post( $this->get( 'product_id' ) );
}
/**
* Retrieve the last (most recent) transaction processed for the order.
*
* @since 3.0.0
* @since 7.1.0 Skip counting the total rows found when retrieving the last transaction.
*
* @param array|string $status Filter by status (see transaction statuses). By default looks for any status.
* @param array|string $type Filter by type [recurring|single|trial]. By default looks for any type.
* @return LLMS_Transaction|false instance of the LLMS_Transaction or false if none found
*/
public function get_last_transaction( $status = 'any', $type = 'any' ) {
$txns = $this->get_transactions(
array(
'per_page' => 1,
'status' => $status,
'type' => $type,
'no_found_rows' => true,
)
);
if ( $txns['count'] ) {
return array_pop( $txns['transactions'] );
}
return false;
}
/**
* Retrieve the date of the last (most recent) transaction
*
* @since 3.0.0
*
* @param array|string $status Optional. Filter by status (see transaction statuses). Default is 'llms-txn-succeeded'.
* @param array|string $type Optional. Filter by type [recurring|single|trial]. By default looks for any type.
* @param string $format Optional. Date format of the return. Default is 'Y-m-d H:i:s'.
* @return string|false Date or false if none found.
*/
public function get_last_transaction_date( $status = 'llms-txn-succeeded', $type = 'any', $format = 'Y-m-d H:i:s' ) {
$txn = $this->get_last_transaction( $status, $type );
if ( $txn ) {
return $txn->get_date( 'date', $format );
} else {
return false;
}
}
/**
* Retrieve the due date of the next payment according to access plan terms
*
* @since 3.0.0
* @since 3.19.0 Unknown.
* @since 5.2.0 Use stric type comparisons.
*
* @param string $format Optional. Date return format. Default is 'Y-m-d H:i:s'.
* @return string
*/
public function get_next_payment_due_date( $format = 'Y-m-d H:i:s' ) {
// Single payments will never have a next payment date.
if ( ! $this->is_recurring() ) {
return new WP_Error( 'not-recurring', __( 'Order is not recurring', 'lifterlms' ) );
} elseif ( ! in_array( $this->get( 'status' ), array( 'llms-active', 'llms-failed', 'llms-on-hold', 'llms-pending', 'llms-pending-cancel' ), true ) ) {
return new WP_Error( 'invalid-status', __( 'Invalid order status', 'lifterlms' ), $this->get( 'status' ) );
}
// Retrieve the saved due date.
$next_payment_time = $this->get_date( 'date_next_payment', 'U' );
// Calculate it if not saved.
if ( ! $next_payment_time ) {
$next_payment_time = $this->calculate_next_payment_date( 'U' );
if ( ! $next_payment_time ) {
return new WP_Error( 'plan-ended', __( 'No more payments due', 'lifterlms' ) );
}
}
/**
* Filter the next payment due date.
*
* A timestamp should always be returned as the conversion to the requested format
* will be performed on the returned value.
*
* @since 3.0.0
*
* @param int $next_payment_time Unix timestamp for the next payment due date.
* @param LLMS_Order $order Order object.
* @param string $format Requested date format.
*/
$next_payment_time = apply_filters( 'llms_order_get_next_payment_due_date', $next_payment_time, $this, $format );
return date_i18n( $format, $next_payment_time );
}
/**
* Retrieve the timestamp of the next scheduled event for a given action
*
* @since 4.6.0
*
* @param string $action Action hook ID. Core actions are "llms_charge_recurring_payment", "llms_access_plan_expiration".
* @return int|false Returns the timestamp of the next action as an integer or `false` when no action exist.
*/
public function get_next_scheduled_action_time( $action ) {
return as_next_scheduled_action( $action, $this->get_action_args() );
}
/**
* Retrieves the number of payments remaining for a recurring plan with a limited number of payments
*
* @since 5.3.0
*
* @return bool|int Returns `false` for invalid order types (single-payment orders or recurring orders
* without a billing length). Otherwise returns the number of remaining payments as an integer.
*/
public function get_remaining_payments() {
$remaining = false;
if ( $this->has_plan_expiration() ) {
$len = $this->get( 'billing_length' );
$txns = $this->get_transactions(
array(
'status' => array( 'llms-txn-succeeded', 'llms-txn-refunded' ),
'per_page' => 1,
'type' => array( 'recurring', 'single' ), // If a manual payment is recorded it's counted a single payment and that should count.
)
);
$remaining = $len - $txns['total'];
}
/**
* Filters the number of payments remaining for a recurring plan with a limited number of payments.
*
* @since 5.3.0
*
* @param bool|int $remaining Number of remaining payments or `false` when called against invalid order types.
* @param LLMS_Order $order Order object.
*/
return apply_filters( 'llms_order_remaining_payments', $remaining, $this );
}
/**
* Get configured payment retry rules
*
* @since 3.10.0
*
* @return array[] {
* An array of retry rule arrays.
*
* @type int $delay The number of seconds to delay to use when scheduling the retry attempt.
* @type string $status The status of the order while awaiting the next retry.
* @type bool $notifications Whether or not to trigger notifications to the student/user.
* }
*/
private function get_retry_rules() {
$rules = array(
array(
'delay' => HOUR_IN_SECONDS * 12,
'status' => 'on-hold',
'notifications' => false,
),
array(
'delay' => DAY_IN_SECONDS,
'status' => 'on-hold',
'notifications' => true,
),
array(
'delay' => DAY_IN_SECONDS * 2,
'status' => 'on-hold',
'notifications' => true,
),
array(
'delay' => DAY_IN_SECONDS * 3,
'status' => 'on-hold',
'notifications' => true,
),
);
/**
* Filters the automatic payment recurring retry rules.
*
* @since 7.0.0
*
* @param array $rules Array of retry rule arrays {@see LLMS_Order::get_retry_rules()}.
* @param LLMS_Order $rules The order object.
*/
return apply_filters( 'llms_order_automatic_retry_rules', $rules, $this );
}
/**
* SQL query to retrieve total amounts for transactions by type
*
* @since 3.0.0
* @since 3.35.0 Prepare SQL query properly.
*
* @param string $type Optional. Type can be 'amount' or 'refund_amount'. Default is 'amount'.
* @return float
*/
public function get_transaction_total( $type = 'amount' ) {
$statuses = array( 'llms-txn-refunded' );
if ( 'amount' === $type ) {
$statuses[] = 'llms-txn-succeeded';
}
$post_statuses = '';
foreach ( $statuses as $i => $status ) {
$post_statuses .= " p.post_status = '$status'";
if ( $i + 1 < count( $statuses ) ) {
$post_statuses .= 'OR';
}
}
global $wpdb;
// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- $post_statuses is prepared above.
$grosse = $wpdb->get_var(
$wpdb->prepare(
"SELECT SUM( m2.meta_value )
FROM $wpdb->posts AS p
LEFT JOIN $wpdb->postmeta AS m1 ON m1.post_id = p.ID -- Join for the ID.
LEFT JOIN $wpdb->postmeta AS m2 ON m2.post_id = p.ID -- Get the actual amounts.
WHERE p.post_type = 'llms_transaction'
AND ( $post_statuses )
AND m1.meta_key = %s
AND m1.meta_value = %d
AND m2.meta_key = %s
;",
array(
"{$this->meta_prefix}order_id",
$this->get( 'id' ),
"{$this->meta_prefix}{$type}",
)
)
); // db call ok; no-cache ok.
// phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared
return floatval( $grosse );
}
/**
* Get the start date for the order.
*
* Gets the date of the first initially successful transaction
* if none found, uses the created date of the order.
*
* @since 3.0.0
* @since 7.1.0 Skip counting the total rows found when retrieving the first transaction.
*
* @param string $format Desired return format of the date.
* @return string
*/
public function get_start_date( $format = 'Y-m-d H:i:s' ) {
/**
* Get the first recorded transaction.
* Refunds are okay b/c that would have initially given the user access.
*/
$txns = $this->get_transactions(
array(
'order' => 'ASC',
'orderby' => 'date',
'per_page' => 1,
'status' => array( 'llms-txn-succeeded', 'llms-txn-refunded' ),
'type' => 'any',
'no_found_rows' => true,
)
);
if ( $txns['count'] ) {
$txn = array_pop( $txns['transactions'] );
$date = $txn->get_date( 'date', $format );
} else {
$date = $this->get_date( 'date', $format );
}
/**
* Filter the order start date.
*
* @since 3.0.0
* @since 7.1.0 Added the `$format` parameter.
*
* @param string $date The formatted start date for the order.
* @param LLMS_Order $order The order object.
* @param string $format The requested format of the date.
*/
return apply_filters( 'llms_order_get_start_date', $date, $this, $format );
}
/**
* Retrieves the user action required when changing the order's payment source.
*
* @since 7.0.0
*
* @return null|string Returns `switch` when the payment source can be switched and `pay` when payment on the new source
* is required before switching. A `null` return indicates that the order's payment source cannot be switched.
*/
public function get_switch_source_action() {
$action = null;
if ( $this->can_switch_source() ) {
$action = in_array( $this->get( 'status' ), array( 'llms-active', 'llms-pending-cancel' ), true ) ? 'switch' : 'pay';
}
/**
* Filters the required user action for the order when switching the order's payment source.
*
* @since 7.0.0
*
* @param null|string $action The switch action ID or `null` when the payment source cannot be switched.
* @param LLMS_Order $order The order object.
*/
return apply_filters( 'llms_order_switch_source_action', $action, $this );
}
/**
* Retrieve an array of transactions associated with the order according to supplied arguments.
*
* @since 3.0.0
* @since 3.10.0 Unknown.
* @since 3.37.6 Add additional return property, `total`, which returns the total number of found transactions.
* @since 5.2.0 Use stric type comparisons.
* @since 7.1.0 Added `no_found_rows` parameter.
*
* @param array $args {
* Hash of query argument data, ultimately passed to a WP_Query.
*
* @type string|string[] $status Transaction post status or array of transaction post status. Defaults to "any".
* @type string|string[] $type Transaction types or array of transaction types. Defaults to "any".
* Accepts "recurring", "single", or "trial".
* @type int $per_page Number of transactions to include in the return. Default `50`.
* @type int $paged Result set page number.
* @type string $order Result set order. Default "DESC". Accepts "DESC" or "ASC".
* @type string $orderby Result set ordering field. Default "date".
* @type bool $no_found_rows Whether to skip counting the total rows found. Enabling can improve
* performance. Default `false`.
* }
* @return array
*/
public function get_transactions( $args = array() ) {
extract(
wp_parse_args(
$args,
array(
'status' => 'any', // String or array or post statuses.
'type' => 'any', // String or array of transaction types [recurring|single|trial].
'per_page' => 50, // Int, number of transactions to return.
'paged' => 1, // Int, page number of transactions to return.
'order' => 'DESC',
'orderby' => 'date', // Field to order results by.
'no_found_rows' => false,
)
)
);
// Assume any and use this to check for valid statuses.
$statuses = llms_get_transaction_statuses();
// Check statuses.
if ( 'any' !== $statuses ) {
// If status is a string, ensure it's a valid status.
if ( is_string( $status ) && in_array( $status, $statuses, true ) ) {
$statuses = array( $status );
} elseif ( is_array( $status ) ) {
$temp = array();
foreach ( $status as $stat ) {
if ( in_array( (string) $stat, $statuses, true ) ) {
$temp[] = $stat;
}
}
$statuses = $temp;
}
}
// Setup type meta query.
$types = array(
'relation' => 'OR',
);
if ( 'any' === $type ) {
$types[] = array(
'key' => $this->meta_prefix . 'payment_type',
'value' => 'recurring',
);
$types[] = array(
'key' => $this->meta_prefix . 'payment_type',
'value' => 'single',
);
$types[] = array(
'key' => $this->meta_prefix . 'payment_type',
'value' => 'trial',
);
} elseif ( is_string( $type ) ) {
$types[] = array(
'key' => $this->meta_prefix . 'payment_type',
'value' => $type,
);
} elseif ( is_array( $type ) ) {
foreach ( $type as $t ) {
$types[] = array(
'key' => $this->meta_prefix . 'payment_type',
'value' => $t,
);
}
}
// Execute the query.
$query = new WP_Query(
/**
* Filters the order's transactions query aguments.
*
* @since 3.0.0
* @since 7.1.0 Added `$no_found_rows` arg.
*
* @param array $query_args {
* Hash of query argument data passed to a WP_Query.
*
* @type string|string[] $status Transaction post status or array of transaction post status.
* Defaults to "any".
* @type string|string[] $type Transaction types or array of transaction types.
* Defaults to "any".
* Accepts "recurring", "single", or "trial".
* @type int $per_page Number of transactions to include in the return. Default `50`.
* @type int $paged Result set page number.
* @type string $order Result set order. Default "DESC". Accepts "DESC" or "ASC".
* @type string $orderby Result set ordering field. Default "date".
* @type bool $no_found_rows Whether to skip counting the total rows found.
* Enabling can improve performance. Default false.
* }
*/
apply_filters(
'llms_order_get_transactions_query',
array(
'meta_query' => array(
'relation' => 'AND',
array(
'key' => $this->meta_prefix . 'order_id',
'value' => $this->get( 'id' ),
),
$types,
),
'order' => $order,
'orderby' => $orderby,
'post_status' => $statuses,
'post_type' => 'llms_transaction',
'posts_per_page' => $per_page,
'paged' => $paged,
'no_found_rows' => $no_found_rows,
)
),
$this,
$status
);
$transactions = array();
foreach ( $query->posts as $post ) {
$transactions[ $post->ID ] = llms_get_post( $post );
}
return array(
'total' => $query->found_posts,
'count' => $query->post_count,
'page' => $paged,
'pages' => $query->max_num_pages,
'transactions' => $transactions,
);
}
/**
* Retrieve the date when a trial will end
*
* @since 3.0.0
*
* @param string $format Optional. Date return format. Default is 'Y-m-d H:i:s'.
* @return string
*/
public function get_trial_end_date( $format = 'Y-m-d H:i:s' ) {
if ( ! $this->has_trial() ) {
$trial_end_date = '';
} else {
// Retrieve the saved end date.
$trial_end_date = $this->get_date( 'date_trial_end', $format );
// If not saved, calculate it.
if ( ! $trial_end_date ) {
$trial_end_date = $this->calculate_trial_end_date( $format );
}
}
return apply_filters( 'llms_order_get_trial_end_date', $trial_end_date, $this );
}
/**
* Gets the total revenue of an order
*
* @since 3.0.0
* @since 3.1.3 Handle legacy orders.
*
* @param string $type Optional. Revenue type [grosse|net]. Default is 'net'.
* @return float
*/
public function get_revenue( $type = 'net' ) {
if ( $this->is_legacy() ) {
$amount = $this->get( 'total' );
} else {
$amount = $this->get_transaction_total( 'amount' );
if ( 'net' === $type ) {
$refunds = $this->get_transaction_total( 'refund_amount' );
$amount = $amount - $refunds;
}
}
return apply_filters( 'llms_order_get_revenue', $amount, $type, $this );
}
/**
* Get a link to view the order on the student dashboard
*
* @since 3.0.0
* @since 3.8.0 Unknown.
*
* @return string
*/
public function get_view_link() {
$link = llms_get_endpoint_url( 'orders', $this->get( 'id' ), llms_get_page_url( 'myaccount' ) );
return apply_filters( 'llms_order_get_view_link', $link, $this );
}
/**
* Determine if the student associated with this order has access
*
* @since 3.0.0
*
* @return boolean
*/
public function has_access() {
return ( 'active' === $this->get_access_status() ) ? true : false;
}
/**
* Determine if a coupon was used
*
* @since 3.0.0
*
* @return boolean
*/
public function has_coupon() {
return ( 'yes' === $this->get( 'coupon_used' ) );
}
/**
* Determine if there was a discount applied to this order via either a sale or a coupon
*
* @since 3.0.0
*
* @return boolean
*/
public function has_discount() {
return ( $this->has_coupon() || $this->has_sale() );
}
/**
* Determine if a recurring order has a limited number of payments
*
* @since 5.3.0
*
* @return boolean Returns `true` for recurring orders with a billing length and `false` otherwise.
*/
public function has_plan_expiration() {
return ( $this->is_recurring() && ( $this->get( 'billing_length' ) > 0 ) );
}
/**
* Determine if the access plan was on sale during the purchase
*
* @since 3.0.0
*
* @return boolean
*/
public function has_sale() {
return ( 'yes' === $this->get( 'on_sale' ) );
}
/**
* Determine if there's a payment scheduled for the order
*
* @since 3.0.0
*
* @return boolean
*/
public function has_scheduled_payment() {
$date = $this->get_next_payment_due_date();
return is_wp_error( $date ) ? false : true;
}
/**
* Determine if the order has a trial
*
* @since 3.0.0
*
* @return boolean True if has a trial, false if it doesn't.
*/
public function has_trial() {
return ( $this->is_recurring() && 'yes' === $this->get( 'trial_offer' ) );
}
/**
* Determine if the trial period has ended for the order
*
* @since 3.0.0
* @since 3.10.0 Unknown.
*
* @return boolean True if ended, false if not ended.
*/
public function has_trial_ended() {
return ( llms_current_time( 'timestamp' ) >= $this->get_trial_end_date( 'U' ) );
}
/**
* Initializes a new order with user, plan, gateway, and coupon metadata.
*
* Assumes all data passed in has already been validated.
*
* @since 3.8.0
* @since 3.10.0 Unknown.
* @since 5.3.0 Don't set unused legacy property `date_billing_end`.
* @since 7.0.0 Use `LLMS_Order::set_user_data()` to update user data.
*
* @param array|LLMS_Student|WP_User|integer $user_data User info for the person placing the order. See
* {@see LLMS_Order::set_user_data()} for more info.
* @param LLMS_Access_Plan $plan The purchase access plan.
* @param LLMS_Payment_Gateway $gateway Gateway being used.
* @param LLMS_Coupon $coupon Coupon object or `false` if no coupon used.
* @return LLMS_Order
*/
public function init( $user_data, $plan, $gateway, $coupon = false ) {
$this->set_user_data( $user_data );
// Access plan data.
$this->set( 'plan_id', $plan->get( 'id' ) );
$this->set( 'plan_title', $plan->get( 'title' ) );
$this->set( 'plan_sku', $plan->get( 'sku' ) );
// Product data.
$product = $plan->get_product();
$this->set( 'product_id', $product->get( 'id' ) );
$this->set( 'product_title', $product->get( 'title' ) );
$this->set( 'product_sku', $product->get( 'sku' ) );
$this->set( 'product_type', $plan->get_product_type() );
$this->set( 'payment_gateway', $gateway->get_id() );
$this->set( 'gateway_api_mode', $gateway->get_api_mode() );
// Trial data.
if ( $plan->has_trial() ) {
$this->set( 'trial_offer', 'yes' );
$this->set( 'trial_length', $plan->get( 'trial_length' ) );
$this->set( 'trial_period', $plan->get( 'trial_period' ) );
$trial_price = $plan->get_price( 'trial_price', array(), 'float' );
$this->set( 'trial_original_total', $trial_price );
$trial_total = $coupon ? $plan->get_price_with_coupon( 'trial_price', $coupon, array(), 'float' ) : $trial_price;
$this->set( 'trial_total', $trial_total );
$this->set( 'date_trial_end', $this->calculate_trial_end_date() );
} else {
$this->set( 'trial_offer', 'no' );
}
$price = $plan->get_price( 'price', array(), 'float' );
$this->set( 'currency', get_lifterlms_currency() );
// Price data.
if ( $plan->is_on_sale() ) {
$price_key = 'sale_price';
$this->set( 'on_sale', 'yes' );
$sale_price = $plan->get( 'sale_price', array(), 'float' );
$this->set( 'sale_price', $sale_price );
$this->set( 'sale_value', $price - $sale_price );
} else {
$price_key = 'price';
$this->set( 'on_sale', 'no' );
}
// Store original total before any discounts.
$this->set( 'original_total', $price );
// Get the actual total due after discounts if any are applicable.
$total = $coupon ? $plan->get_price_with_coupon( $price_key, $coupon, array(), 'float' ) : $$price_key;
$this->set( 'total', $total );
// Coupon data.
if ( $coupon ) {
$this->set( 'coupon_id', $coupon->get( 'id' ) );
$this->set( 'coupon_amount', $coupon->get( 'coupon_amount' ) );
$this->set( 'coupon_code', $coupon->get( 'title' ) );
$this->set( 'coupon_type', $coupon->get( 'discount_type' ) );
$this->set( 'coupon_used', 'yes' );
$this->set( 'coupon_value', $$price_key - $total );
if ( $plan->has_trial() && $coupon->has_trial_discount() ) {
$this->set( 'coupon_amount_trial', $coupon->get( 'trial_amount' ) );
$this->set( 'coupon_value_trial', $trial_price - $trial_total );
}
} else {
$this->set( 'coupon_used', 'no' );
}
// Get all billing schedule related information.
$this->set( 'billing_frequency', $plan->get( 'frequency' ) );
if ( $plan->is_recurring() ) {
$this->set( 'billing_length', $plan->get( 'length' ) );
$this->set( 'billing_period', $plan->get( 'period' ) );
$this->set( 'order_type', 'recurring' );
$this->set( 'date_next_payment', $this->calculate_next_payment_date() );
} else {
$this->set( 'order_type', 'single' );
}
$this->set( 'access_expiration', $plan->get( 'access_expiration' ) );
// Get access related data so when payment is complete we can calculate the actual expiration date.
if ( $plan->can_expire() ) {
$this->set( 'access_expires', $plan->get( 'access_expires' ) );
$this->set( 'access_length', $plan->get( 'access_length' ) );
$this->set( 'access_period', $plan->get( 'access_period' ) );
}
/**
* Action triggered after the order is initialized.
*
* @since Unknown.
* @since 7.0.0 Added `$user_data` parameter.
* The `$student` parameter returns an "empty" student object
* if the method's input data is an array instead of an existing
* user object.
*
* @param LLMS_Order $order The order object.
* @param LLMS_Student $student The student object. If an array of data is passed
* to `LLMS_Order::init()` then an empty student object
* will be passed.
* @param array|LLMS_Student|WP_User|integer $user_data User data.
*/
do_action(
'lifterlms_new_pending_order',
$this,
is_array( $user_data ) ? new LLMS_Student( null, false ) : llms_get_student( $user_data ),
$user_data
);
return $this;
}
/**
* Determine if the order is a legacy order migrated from 2.x
*
* @since 3.0.0
*
* @return boolean
*/
public function is_legacy() {
return ( 'publish' === $this->get( 'status' ) );
}
/**
* Determine if the order is recurring or singular
*
* @since 3.0.0
*
* @return boolean True if recurring, false if not.
*/
public function is_recurring() {
return $this->get( 'order_type' ) === 'recurring';
}
/**
* Schedule access expiration
*
* @since 3.19.0
* @since 3.32.0 Update to use latest action-scheduler functions.
*
* @return void
*/
public function maybe_schedule_expiration() {
// Get expiration date based on setting.
$expires = $this->get_access_expiration_date( 'U' );
// Will return a timestamp or "Lifetime Access as a string".
if ( is_numeric( $expires ) ) {
$this->unschedule_expiration();
as_schedule_single_action( $expires, 'llms_access_plan_expiration', $this->get_action_args() );
}
}
/**
* Schedules the next payment due on a recurring order
*
* Can be called without consequence on a single payment order.
* Will always unschedule the scheduled action (if one exists) before scheduling another.
*
* @since 3.0.0
* @since 3.32.0 Update to use latest action-scheduler functions.
* @since 4.7.0 Add `plan_ended` metadata when a plan ends.
* @since 5.2.0 Move scheduling recurring payment into a proper method.
*
* @return void
*/
public function maybe_schedule_payment( $recalc = true ) {
if ( ! $this->is_recurring() ) {
return;
}
if ( $recalc ) {
$this->set( 'date_next_payment', $this->calculate_next_payment_date() );
}
$date = $this->get_next_payment_due_date();
// Unschedule and reschedule.
if ( $date && ! is_wp_error( $date ) ) {
$this->schedule_recurring_payment( $date );
} elseif ( is_wp_error( $date ) ) {
if ( 'plan-ended' === $date->get_error_code() ) {
// Unschedule the next action (does nothing if no action scheduled).
$this->unschedule_recurring_payment();
// Add a note that the plan has completed.
$this->add_note( __( 'Order payment plan completed.', 'lifterlms' ) );
$this->set( 'plan_ended', 'yes' );
}
}
}
/**
* Handles scheduling recurring payment retries when the gateway supports them
*
* @since 3.10.0
* @since 7.0.0 Added return value.
*
* @return null|boolean Returns `null` if the order cannot be retried, `false` when all retry rules have been tried (or none exist), and `true`
* when a retry is scheduled.
*/
public function maybe_schedule_retry() {
if ( ! $this->can_be_retried() ) {
return null;
}
// Get the index of the rule to use for this retry.
$current_rule_index = $this->get( 'last_retry_rule' );
if ( '' === $current_rule_index ) {
$current_rule_index = 0;
} else {
++$current_rule_index;
}
$rules = $this->get_retry_rules();
$current_rule = $rules[ $current_rule_index ] ?? false;
// No rule to run.
if ( ! $current_rule ) {
$this->set_status( 'failed' );
$this->set( 'last_retry_rule', '' );
$this->add_note( esc_html__( 'Maximum retry attempts reached.', 'lifterlms' ) );
/**
* Action triggered when there are not more recurring payment retry rules.
*
* @since 3.10.0
*
* @param LLMS_Order $order The order object.
*/
do_action( 'llms_automatic_payment_maximum_retries_reached', $this );
return false;
}
$timestamp = current_time( 'timestamp' ) + $current_rule['delay'];
$this->set_date( 'next_payment', date_i18n( 'Y-m-d H:i:s', $timestamp ) );
$this->set_status( $current_rule['status'] );
$this->set( 'last_retry_rule', $current_rule_index );
$this->add_note(
sprintf(
// Translators: %s = next attempt date.
esc_html__( 'Automatic retry attempt scheduled for %s', 'lifterlms' ),
date( get_option( 'date_format' ) . ' ' . get_option( 'time_format' ), $timestamp )
)
);
// If notifications should be sent, trigger them.
if ( $current_rule['notifications'] ) {
/**
* Triggers the "Payment Retry Scheduled" notification.
*
* @since 3.10.0
*
* @param LLMS_Order $order The order object.
*/
do_action( 'llms_send_automatic_payment_retry_notification', $this );
}
/**
* Action triggered after a recurring payment retry is successfully scheduled.
*
* @since 3.10.0
*
* @param LLMS_Order $order The order object.
*/
do_action( 'llms_automatic_payment_retry_scheduled', $this );
return true;
}
/**
* Record a transaction for the order
*
* @since 3.0.0
*
* @param array $data Optional array of additional data to store for the transaction.
* @return LLMS_Transaction Instance of LLMS_Transaction for the created transaction.
*/
public function record_transaction( $data = array() ) {
extract(
array_merge(
array(
'amount' => 0,
'completed_date' => current_time( 'mysql' ),
'customer_id' => '',
'fee_amount' => 0,
'source_id' => '',
'source_description' => '',
'transaction_id' => '',
'status' => 'llms-txn-succeeded',
'payment_gateway' => $this->get( 'payment_gateway' ),
'payment_type' => 'single',
),
$data
)
);
$txn = new LLMS_Transaction( 'new', $this->get( 'id' ) );
$txn->set( 'api_mode', $this->get( 'gateway_api_mode' ) );
$txn->set( 'amount', $amount );
$txn->set( 'currency', $this->get( 'currency' ) );
$txn->set( 'gateway_completed_date', date_i18n( 'Y-m-d h:i:s', strtotime( $completed_date ) ) );
$txn->set( 'gateway_customer_id', $customer_id );
$txn->set( 'gateway_fee_amount', $fee_amount );
$txn->set( 'gateway_source_id', $source_id );
$txn->set( 'gateway_source_description', $source_description );
$txn->set( 'gateway_transaction_id', $transaction_id );
$txn->set( 'order_id', $this->get( 'id' ) );
$txn->set( 'payment_gateway', $payment_gateway );
$txn->set( 'payment_type', $payment_type );
$txn->set( 'status', $status );
return $txn;
}
/**
* Date field setter for date fields that require things to be updated when their value changes
*
* This is mainly used to allow updating dates which are editable from the admin panel which
* should trigger additional actions when updated.
*
* Settable dates: date_next_payment, date_trial_end, date_access_expires.
*
* @since 3.10.0
* @since 3.19.0 Unknown.
*
* @param string $date_key Date field to set.
* @param string $date_val Date string or a unix time stamp.
*/
public function set_date( $date_key, $date_val ) {
// Convert to timestamp if not already a timestamp.
if ( ! is_numeric( $date_val ) ) {
$date_val = strtotime( $date_val );
}
$this->set( 'date_' . $date_key, date( 'Y-m-d H:i:s', $date_val ) );
switch ( $date_key ) {
// Reschedule access expiration.
case 'access_expires':
$this->maybe_schedule_expiration();
break;
// Additionally update the next payment date & don't break because we want to reschedule payments too.
case 'trial_end':
$this->set_date( 'next_payment', $this->calculate_next_payment_date( 'U' ) );
// Everything else reschedule's payments.
default:
$this->maybe_schedule_payment( false );
}
}
/**
* Update the status of an order
*
* @since 3.8.0
* @since 3.10.0 Unknown.
* @since 5.2.0 Prefer `array_key_exists( $key, $keys )` over `in_array( $key, array_keys( $assoc_array ) )`.
*
* @param string $status Status name, accepts unprefixed statuses.
* @return void
*/
public function set_status( $status ) {
if ( false === strpos( $status, 'llms-' ) ) {
$status = 'llms-' . $status;
}
if ( array_key_exists( $status, llms_get_order_statuses( $this->get( 'order_type' ) ) ) ) {
$this->set( 'status', $status );
}
}
/**
* Sets user-related metadata for the order.
*
* @since 7.0.0
*
* @param array|LLMS_Student|WP_User|integer $user_or_data Accepts a raw array user meta-data or
* an input string accepted by `llms_get_student()`.
* When passing an existing user the data will be pulled
* from the user metadata and saved to the order.
* @return array {
* Returns an associative array representing the user metadata that was stored on the order.
*
* @type integer $user_id User's WP_User id.
* @type string $user_ip_address User's ip address.
* @type string $billing_email User's email.
* @type string $billing_first_name User's first name.
* @type string $billing_last_name User's last name.
* @type string $billing_address_1 User's address line 1.
* @type string $billing_address_2 User's address line 2.
* @type string $billing_city User's city.
* @type string $billing_state User's state.
* @type string $billing_zip User's zip.
* @type string $billing_country User's country.
* @type string $billing_phone User's phone.
* }
*/
public function set_user_data( $user_or_data ) {
$to_set = array(
'user_id' => '',
'billing_email' => '',
'billing_first_name' => '',
'billing_last_name' => '',
'billing_address_1' => '',
'billing_address_2' => '',
'billing_city' => '',
'billing_state' => '',
'billing_zip' => '',
'billing_country' => '',
'billing_phone' => '',
);
$user = ! is_array( $user_or_data ) ? llms_get_student( $user_or_data ) : false;
if ( $user ) {
$user_or_data = array();
$map = array(
'user_id' => 'id',
'billing_email' => 'user_email',
'billing_phone' => 'phone',
'billing_first_name' => 'first_name',
'billing_last_name' => 'last_name',
);
foreach ( array_keys( $to_set ) as $order_key ) {
$to_set[ $order_key ] = $user->get( $map[ $order_key ] ?? $order_key );
}
}
// Only use the default IP address if it wasn't specified in the input array.
$to_set['user_ip_address'] = $user_or_data['user_ip_address'] ?? llms_get_ip_address();
// Merge the data and remove excess keys.
$to_set = array_intersect_key(
array_merge( $to_set, $user_or_data ),
$to_set
);
$this->set_bulk( $to_set );
return $to_set;
}
/**
* Record the start date of the access plan and schedule expiration if expiration is required in the future
*
* @since 3.0.0
* @since 3.19.0 Unknown.
* @since 5.2.0 Use strict type comparision.
*
* @return void
*/
public function start_access() {
// Only start access if access isn't already started.
$date = $this->get( 'start_date' );
if ( ! $date ) {
// Set the start date to now.
$date = llms_current_time( 'mysql' );
$this->set( 'start_date', $date );
}
$this->unschedule_expiration();
// Setup expiration.
if ( in_array( $this->get( 'access_expiration' ), array( 'limited-date', 'limited-period' ), true ) ) {
$expires_date = $this->get_access_expiration_date( 'Y-m-d H:i:s' );
$this->set( 'date_access_expires', $expires_date );
$this->maybe_schedule_expiration();
}
}
/**
* Cancels a scheduled expiration action
*
* Does nothing if no expiration is scheduled
*
* @since 3.19.0
* @since 3.32.0 Update to use latest action-scheduler functions.
* @since 4.6.0 Use `$this->get_next_scheduled_action_time()` to determine if the action is currently scheduled.
*
* @return void
*/
public function unschedule_expiration() {
if ( $this->get_next_scheduled_action_time( 'llms_access_plan_expiration' ) ) {
as_unschedule_action( 'llms_access_plan_expiration', $this->get_action_args() );
}
}
/**
* Cancels a scheduled recurring payment action
*
* Does nothing if no payments are scheduled
*
* @since 3.0.0
* @since 3.32.0 Update to use latest action-scheduler functions.
* @since 4.6.0 Use `$this->get_next_scheduled_action_time()` to determine if the action is currently scheduled.
*
* @return void
*/
public function unschedule_recurring_payment() {
if ( $this->get_next_scheduled_action_time( 'llms_charge_recurring_payment' ) ) {
$action_args = $this->get_action_args();
as_unschedule_action( 'llms_charge_recurring_payment', $action_args );
/**
* Fired after a recurring payment is unscheduled
*
* @since 5.2.0
*
* @param LLMS_Order $order LLMS_Order instance.
* @param int $date Timestamp of the recurring payment date UTC.
* @param array $action_args Arguments passed to the scheduler.
*/
do_action( 'llms_charge_recurring_payment_unscheduled', $this, $action_args );
}
}
/**
* Schedule recurring payment
*
* It will unschedule the next recurring payment action, if any, before scheduling.
*
* @since 5.2.0
*
* @param string $next_payment_date Optional. Next payment date. If not provided it'll be retrieved using `$this->get_next_payment_due_date()`.
* @param boolean $gmt Optional. Whether the provided `$next_payment_date` date is gmt. Default is `false`.
* Only applies when the `$next_payment_date` is provided.
* @return WP_Error|integer WP_Error if the plan ended. Otherwise returns the return value of `as_schedule_single_action`: the action's ID.
*/
public function schedule_recurring_payment( $next_payment_date = false, $gmt = false ) {
// Unschedule the next action (does nothing if no action scheduled).
$this->unschedule_recurring_payment();
$date = $this->get_recurring_payment_due_date_for_scheduler( $next_payment_date, $gmt );
if ( is_wp_error( $date ) ) {
return $date;
}
$action_args = $this->get_action_args();
// Schedule the payment.
$action_id = as_schedule_single_action(
$date,
'llms_charge_recurring_payment',
$action_args
);
/**
* Fired after a recurring payment is scheduled
*
* @since 5.2.0
*
* @param LLMS_Order $order LLMS_Order instance.
* @param integer $date Timestamp of the recurring payment date UTC.
* @param array $action_args Arguments passed to the scheduler.
* @param integer $action_id Scheduled action ID.
*/
do_action( 'llms_charge_recurring_payment_scheduled', $this, $date, $action_args, $action_id );
return $action_id;
}
/**
* Returns the recurring payment due date in a suitable format for the scheduler.
*
* @since 5.2.0
*
* @param string $next_payment_date Optional. Next payment date. If not provided it'll be retrieved using `$this->get_next_payment_due_date()`.
* @param boolean $gmt Optional. Whether the provided `$next_payment_date` date is gmt. Default is `false`.
* Only applies when the `$next_payment_date` is provided.
* @return WP_Error|integer
*/
public function get_recurring_payment_due_date_for_scheduler( $next_payment_date = false, $gmt = false ) {
$date = false === $next_payment_date ? $this->get_next_payment_due_date() : $next_payment_date;
if ( ! $date ) {
return new WP_Error( 'invalid-recurring-payment-date', __( 'Next recurring payment due date is not valid', 'lifterlms' ) );
}
if ( is_wp_error( $date ) ) {
return $date;
}
// Convert our date to Unix time and UTC before passing to the scheduler.
// No date parameter passed, or passed date parameter was not in gmt.
if ( ! $next_payment_date || ( $next_payment_date && ! $gmt ) ) {
$date = get_gmt_from_date( $date, 'U' );
} else {
// Get timestamp.
$date = date_format( date_create( $date ), 'U' );
}
return (int) $date;
}
/**
* Determine whether the recurring payment for this order can be modified.
*
* Depends on whether the order's gateway supports.
*
* @since 7.0.0
*
* @return bool
*/
public function supports_modify_recurring_payments() {
$gateway = $this->get_gateway();
return is_wp_error( $gateway ) ? false : $gateway->supports( 'modify_recurring_payments', $this );
}
}