gocodebox/lifterlms

View on GitHub
includes/class-llms-order-generator.php

Summary

Maintainability
B
4 hrs
Test Coverage
A
99%
<?php
/**
 * LLMS_Order_Generator class file.
 *
 * @package LifterLMS/Classes
 *
 * @since 7.0.0
 * @version 7.0.0
 */

defined( 'ABSPATH' ) || exit;

/**
 * Validate and create LLMS_Order posts.
 *
 * @since 7.0.0
 */
class LLMS_Order_Generator {

    /**
     * Error code: invalid coupon code submitted.
     *
     * @var string
     */
    const E_COUPON_INVALID = 'llms-order-gen-coupon-invalid';

    /**
     * Error code: coupon code not found.
     *
     * @var string
     */
    const E_COUPON_NOT_FOUND = 'llms-order-gen-coupon-not-found';

    /**
     * Error code: issue encountered during order post creation.
     *
     * @var string
     */
    const E_CREATE_ORDER = 'llms-order-gen-create-order';

    /**
     * Error code: payment gateway id not submitted.
     *
     * @var string
     */
    const E_GATEWAY_REQUIRED = 'llms-order-gen-gateway-required';

    /**
     * Error code: missing or invalid order key during confirmation.
     *
     * @var string
     */
    const E_ORDER_NOT_FOUND = 'llms-order-gen-order-not-found';

    /**
     * Error code: order cannot be confirmed.
     *
     * @var string
     */
    const E_ORDER_NOT_CONFIRMABLE = 'llms-order-gen-order-not-confirmable';

    /**
     * Error code: required plan ID not submitted.
     *
     * @var string
     */
    const E_PLAN_REQUIRED = 'llms-order-gen-plan-required';

    /**
     * Error code: access plan not found.
     *
     * @var string
     */
    const E_PLAN_NOT_FOUND = 'llms-order-gen-plan-not-found';

    /**
     * Error code: site's terms not accepted.
     *
     * @var string
     */
    const E_SITE_TERMS = 'llms-order-gen-site-terms';

    /**
     * Error code: user already enrolled.
     *
     * @var string
     */
    const E_USER_ENROLLED = 'llms-order-gen-user-enrolled';

    /**
     * User Action: validate and then commit (register or update) the user.
     *
     * @var string
     */
    const UA_COMMIT = 'commit';

    /**
     * User Action: perform user validation only.
     *
     * @var string
     */
    const UA_VALIDATE = 'validate';

    /**
     * The coupon used to discount the order.
     *
     * Derived from `$this->data['llms_coupon_code']`.
     *
     * Will be empty until the coupon is validated.
     *
     * @var LLMS_Coupon|null
     */
    protected $coupon = null;

    /**
     * Associative array of input data.
     *
     * Usually the $_POST superglobal.
     *
     * @var array
     */
    protected $data = array();

    /**
     * The payment gateway used to process the order.
     *
     * Derived from `$this->data['llms_payment_gateway']` .
     *
     * Will be empty until the gateway is validated.
     *
     * @var LLMS_Payment_Gateway|null
     */
    protected $gateway = null;

    /**
     * The access plan used to generate the order.
     *
     * Derived from `$this->data['llms_plan_id']`.
     *
     * Will be empty until the plan is validated.
     *
     * @var LLMS_Access_Plan|null
     */
    protected $plan = null;

    /**
     * The order.
     *
     * Derived from `$this->data['llms_order_key']`.
     *
     * Will be empty until the order is validated.
     *
     * This is only used during confirmation of existing orders.
     *
     * @var LLMS_Order|null
     */
    protected $order = null;

    /**
     * The student used to generate the order.
     *
     * Will be empty until the user is created / update following all validations.
     *
     * @var LLMS_Student|null
     */
    protected $student = null;

    /**
     * Constructor.
     *
     * @since 7.0.0
     *
     * @param array $data {
     *     An associative array of input data used to generate the order, usually from $_POST.
     *
     *     @type integer $llms_plan_id         An LLMS_Access_Plan ID.
     *     @type string  $llms_agree_to_terms  A yes/no value determining whether or not the user has agreed to the site's terms.
     *     @type string  $llms_payment_gateway The ID of the payment gateway used to process the order.
     *     @type string  $llms_coupon_code     Optional. The coupon code string being used.
     *     @type string  $llms_order_key       Optional. An `LLMS_Order` key used to modify an existing pending order rather than creating a new one.
     *     @type array   ...$user_data         All remaining data is passed to the user creation functions.
     * }
     * @return void
     */
    public function __construct( $data ) {
        $this->data = $data;
    }

    /**
     * Confirms an existing pending order.
     *
     * @since 7.0.0
     *
     * @return WP_Error|array Returns an array of data from the payment gateway's `confirm_pending_order()` method on success.
     */
    public function confirm() {

        $validate = $this->validate( true );
        if ( is_wp_error( $validate ) ) {
            return $validate;
        }

        $gateway_confirm = $this->gateway->confirm_pending_order( $this->order );
        if ( is_wp_error( $gateway_confirm ) ) {
            return $gateway_confirm;
        }

        $user = $this->commit_user();
        if ( is_wp_error( $user ) ) {
            return $user;
        }

        // Save the user to the order.
        $this->order->set_user_data( $this->get_user_data() );

        if ( 'SUCCESS' === ( $gateway_confirm['status'] ?? null ) && ! empty( $gateway_confirm['transaction'] ) ) {
            // Record the transaction.
            $this->order->record_transaction( $gateway_confirm['transaction'] );
        }

        return $gateway_confirm;

    }


    /**
     * Creates a new pending order.
     *
     * @since 7.0.0
     *
     * @return WP_Error|LLMS_Order
     */
    protected function create() {

        $order = new LLMS_Order( $this->get_order_id() );

        // If there's no id we can't proceed, return an error.
        if ( ! $order->get( 'id' ) ) {
            return $this->error(
                self::E_CREATE_ORDER,
                __( 'There was an error creating your order, please try again.', 'lifterlms' )
            );
        }

        $order->init( $this->get_user_data(), $this->plan, $this->gateway, $this->coupon );

        return $order;

    }

    /**
     * Registers or updates the user from the submitted data.
     *
     * @since 7.0.0
     *
     * @return integer|WP_Error Returns the `WP_User` ID on success or an error object.
     */
    protected function commit_user() {

        $args = array(
            'plan' => $this->plan,
        );

        $user_id = get_current_user_id() ?
            llms_update_user( $this->data, 'checkout', $args ) :
            llms_register_user( $this->data, 'checkout', true, $args );

        if ( ! is_wp_error( $user_id ) ) {
            $this->student = llms_get_student( $user_id );
        }

        return $user_id;

    }

    /**
     * Returns an error object.
     *
     * This method accepts an error code and message and passes them directly to `WP_Error` and
     * adds all class variables to the error objects `$data` parameter.
     *
     * @since 7.0.0
     *
     * @param string $code       Error code.
     * @param string $message    Error message.
     * @param array  $extra_data Additional data to pass to WP_Error's 3rd parameter.
     * @return WP_Error
     */
    protected function error( $code, $message, $extra_data = array() ) {

        $data = get_class_vars( __CLASS__ );
        foreach ( $data as $key => &$val ) {
            $val = $this->{$key};
        }

        return new WP_Error( $code, $message, array_merge( $data, $extra_data ) );

    }

    /**
     * Attempts to locate a user ID.
     *
     * Uses the logged in user's information and falls back to a lookup by email address if available.
     *
     * @since 7.0.0
     *
     * @param string|null $email An email address, if available.
     * @return null|integer Returns the WP_User ID or null if not found.
     */
    private function find_user_id( $email = null ) {

        if ( is_user_logged_in() ) {
            return get_current_user_id();
        }

        if ( $email ) {
            $user = get_user_by( 'email', $email );
            return $user ? $user->ID : null;
        }

        return null;

    }

    /**
     * Generates an order.
     *
     * Uses data submitted during class construction and performs all necessary
     * validations. If validations pass, creates the order.
     *
     * @since 7.0.0
     *
     * @param string $user_action The user action, accepts `LLMS_Order_Generator::UA_COMMIT` or `LLMS_Order_Generator::UA_VALIDATE`.
     * @return WP_Error|LLMS_Order
     */
    public function generate( $user_action = self::UA_COMMIT ) {

        $validate = $this->validate();
        if ( is_wp_error( $validate ) ) {
            return $validate;
        }

        if ( self::UA_COMMIT === $user_action ) {
            $user = $this->commit_user();
            if ( is_wp_error( $user ) ) {
                return $user;
            }
        }

        return $this->create();

    }

    /**
     * Retrieves the coupon object for the order.
     *
     * @since 7.0.0
     *
     * @return LLMS_Coupon|null
     */
    public function get_coupon() {
        return $this->coupon;
    }

    /**
     * Retrieves the payment gateway instance for the order.
     *
     * @since 7.0.0
     *
     * @return LLMS_Payment_Gateway|null
     */
    public function get_gateway() {
        return $this->gateway;
    }

    /**
     * Retrieves the order id to use for the order.
     *
     * Attempts to locate an existing pending order by order key if it was submitted,
     * otherwise returns `new` which denotes a new order should be created.
     *
     * @since 7.0.0
     *
     * @return integer|string
     */
    protected function get_order_id() {

        $order_id = null;
        $key      = $this->data['llms_order_key'] ?? null;
        $email    = $this->data['email_address'] ?? null;
        $plan_id  = $this->data['llms_plan_id'] ?? null;

        // Try to lookup using the order key if it was supplied.
        if ( $key ) {
            $order_id = $this->sanitize_retrieved_order_id( llms_get_order_by_key( $key, 'id' ) );
        }

        // Try to lookup by user ID.
        if ( ! $order_id ) {

            $user_id  = $this->find_user_id( $email );
            $order_id = $user_id ? $this->sanitize_retrieved_order_id( llms_locate_order_for_user_and_plan( $user_id, $plan_id ) ) : null;

        }

        // Lookup by email address.
        if ( ! $order_id && $email ) {
            $order_id = $this->sanitize_retrieved_order_id( llms_locate_order_for_email_and_plan( $email, $plan_id ) );
        }

        return $order_id ? $order_id : 'new';

    }

    /**
     * Retrieves the access plan for the order.
     *
     * @since 7.0.0
     *
     * @return LLMS_Access_Plan|null
     */
    public function get_plan() {
        return $this->plan;
    }

    /**
     * Retrieves the order object.
     *
     * @since 7.0.0
     *
     * @return LLMS_Order|null
     */
    public function get_order() {
        return $this->order;
    }

    /**
     * Retrieves the student for the order.
     *
     * @since 7.0.0
     *
     * @return LLMS_Student|null
     */
    public function get_student() {
        return $this->student;
    }

    /**
     * Retrieves an array of data representing the student.
     *
     * The resulting array is intended to be used for setting up the `LLMS_Order` post's
     * user metadata, ideally passed to `LLMS_Order::init()`.
     *
     * @since 7.0.0
     *
     * @return array
     */
    public function get_user_data() {

        $map = array(
            'billing_email'      => 'email_address',
            'billing_first_name' => 'first_name',
            'billing_last_name'  => 'last_name',
            'billing_phone'      => 'llms_phone',
        );

        $data = array(
            'billing_email'      => '',
            'billing_first_name' => '',
            'billing_last_name'  => '',
            'billing_address_1'  => '',
            'billing_address_2'  => '',
            'billing_city'       => '',
            'billing_state'      => '',
            'billing_zip'        => '',
            'billing_country'    => '',
            'billing_phone'      => '',
        );

        foreach ( $data as $key => &$val ) {
            $data_key = $map[ $key ] ?? "llms_{$key}";
            $val      = $this->data[ $data_key ] ?? '';
        }

        $data['user_id'] = $this->student ? $this->student->get( 'id' ) : '';

        return $data;

    }

    /**
     * Sanitizes the order_id retrieved by {@see LLMS_Order_Generator::get_order_id()} to ensure it can be resumed or confirmed during checkout.
     *
     * Only orders with the `llms-pending` status can be resumed or confirmed.
     *
     * @since 7.0.0
     *
     * @param null|int $order_id The order ID or `null` if the lookup didn't yield a result.
     * @return int|null Returns the submitted order ID if it's valid or `null`.
     */
    private function sanitize_retrieved_order_id( $order_id ) {
        return $order_id && 'llms-pending' === get_post_status( $order_id ) ? $order_id : null;
    }

    /**
     * Performs all required data validations necessary to create the order.
     *
     * @since 7.0.0
     *
     * @param boolean $validate_order Whether or not order data should be validated. This is `true` when running `confirm()` and `false` otherwise.
     * @return boolean|WP_Error Returns `true` if all validations pass or an error object.
     */
    protected function validate( $validate_order = false ) {

        /**
         * Allows 3rd party validation prior to generation of an order.
         *
         * This validation hook runs prior to all default validation.
         *
         * @since 7.0.0
         *
         * @param null|WP_Error $validation_error Halts checkout and returns the supplied error.
         */
        $before_validation = apply_filters( 'llms_before_generate_order_validation', null );
        if ( is_wp_error( $before_validation ) ) {
            return $before_validation;
        }

        $validations = array(
            'validate_plan',
            'validate_coupon',
            'validate_gateway',
            'validate_terms',
            'validate_user',
        );

        if ( $validate_order ) {
            array_unshift( $validations, 'validate_order' );
        }

        foreach ( $validations as $func ) {
            $res = $this->{$func}();
            if ( is_wp_error( $res ) ) {
                return $res;
            }
        }

        /**
         * Allows 3rd party validation prior to generation of an order.
         *
         * This validation hook runs after all default validation.
         *
         * @since 7.0.0
         *
         * @param boolean|WP_Error $validation_error Halts checkout and returns the supplied error.
         */
        return apply_filters( 'llms_after_generate_order_validation', true );

    }

    /**
     * Validates the coupon.
     *
     * @since 7.0.0
     *
     * @return boolean|WP_Error Returns `true` on success or an error object.
     */
    protected function validate_coupon() {

        // If a coupon is being used, validate it.
        if ( ! empty( $this->data['llms_coupon_code'] ) ) {

            $code = sanitize_text_field( $this->data['llms_coupon_code'] );

            // Locate the coupon post ID.
            $coupon_id = llms_find_coupon( $code );
            if ( ! $coupon_id ) {
                return $this->error(
                    self::E_COUPON_NOT_FOUND,
                    sprintf(
                        // Translators: %s = The user-submitted coupon code.
                        __( 'Coupon code "%s" not found.', 'lifterlms' ),
                        $code
                    )
                );
            }

            // Validate the coupon for the current plan.
            $coupon = llms_get_post( $coupon_id );
            $valid  = $coupon->is_valid( $this->plan->get( 'id' ) );
            if ( is_wp_error( $valid ) ) {
                return $this->error( self::E_COUPON_INVALID, $valid->get_error_message() );
            }

            $this->coupon = $coupon;

        }

        return true;
    }

    /**
     * Validates the payment gateway.
     *
     * @since 7.0.0
     *
     * @return boolean|WP_Error Returns `true` on success or an error object.
     */
    protected function validate_gateway() {

        $coupon_id = $this->coupon ? $this->coupon->get( 'id' ) : null;

        /**
         * If payment is required, verify we have a gateway.
         *
         * For free plans the manual gateway is automatically used, whether or not it's enabled.
         */
        if ( $this->plan->requires_payment( $coupon_id ) && empty( $this->data['llms_payment_gateway'] ) ) {
            return $this->error( self::E_GATEWAY_REQUIRED, __( 'No payment method selected.', 'lifterlms' ) );
        }

        $gateway_id = $this->data['llms_payment_gateway'] ?? 'manual';
        $is_valid   = llms_can_gateway_be_used_for_plan( $gateway_id, $this->plan );
        if ( is_wp_error( $is_valid ) ) {
            return $is_valid;
        }

        $this->gateway = llms()->payment_gateways()->get_gateway_by_id( $gateway_id );
        return true;

    }

    /**
     * Validates the order.
     *
     * Ensures the submitted order key is valid and that the order can be confirmed.
     *
     * @since 7.0.0
     *
     * @return boolean|WP_Error Returns `true` on success or an error object.
     */
    protected function validate_order() {

        $order_id = $this->get_order_id();

        if ( 'new' === $order_id || 'llms_order' !== get_post_type( $order_id ) ) {
            return $this->error(
                self::E_ORDER_NOT_FOUND,
                __( 'Could not locate an order to confirm.', 'lifterlms' )
            );
        }

        $order = llms_get_post( $order_id );
        if ( ! $order->can_be_confirmed() ) {
            return $this->error(
                self::E_ORDER_NOT_CONFIRMABLE,
                __( 'Could not locate an order to confirm.', 'lifterlms' )
            );
        }

        $this->order = $order;
        return true;

    }

    /**
     * Validates the access plan.
     *
     * Ensures the access plan data was submitted and that it's a valid plan.
     *
     * @since 7.0.0
     *
     * @return boolean|WP_Error Returns `true` on success or an error object.
     */
    protected function validate_plan() {

        $plan_id = $this->data['llms_plan_id'] ?? null;
        if ( ! $plan_id ) {
            return $this->error( self::E_PLAN_REQUIRED, __( 'Missing access plan ID.', 'lifterlms' ) );
        }

        $plan = llms_get_post( $plan_id );
        if ( ! $plan || 'llms_access_plan' !== $plan->get( 'type' ) ) {
            return $this->error( self::E_PLAN_NOT_FOUND, __( 'Access plan not found.', 'lifterlms' ) );
        }

        $this->plan = $plan;
        return true;

    }

    /**
     * Validates the site's terms and conditions were submitted.
     *
     * @since 7.0.0
     *
     * @return boolean|WP_Error Returns `true` on success or an error object.
     */
    protected function validate_terms() {

        if ( llms_are_terms_and_conditions_required() && ! llms_parse_bool( $this->data['llms_agree_to_terms'] ?? 'no' ) ) {
            return $this->error(
                self::E_SITE_TERMS,
                sprintf(
                    // Translators: %s = The title of the site's LifterLMS Terms and Conditions page.
                    __( 'You must agree to the %s.', 'lifterlms' ),
                    get_the_title( get_option( 'lifterlms_terms_page_id' ) )
                )
            );
        }

        return true;

    }

    /**
     * Validates the submitted user data.
     *
     * @since 7.0.0
     *
     * @return boolean|WP_Error Returns `true` on success or an error object.
     */
    protected function validate_user() {

        $validate = llms_validate_user( $this->data );
        if ( is_wp_error( $validate ) ) {
            return $validate;
        }

        // If validation passes, determine if the user already exists and, if they do, validate their enrollment.
        $email = $this->data['email_address'] ?? null;
        $user  = $email ? get_user_by( 'email', $email ) : false;
        if ( $user && llms_is_user_enrolled( $user->ID, $this->plan->get( 'product_id' ) ) ) {
            return $this->error(
                self::E_USER_ENROLLED,
                sprintf(
                    // Translators: %s = The title of the course or membership.
                    __( 'You already have access to %s.', 'lifterlms' ),
                    get_the_title( $this->plan->get( 'product_id' ) )
                )
            );
        }

        return true;
    }

}