From 8b16fa3fc0e70b9a23e7675d648e98ab5a349de2 Mon Sep 17 00:00:00 2001 From: Brian Hogg Date: Mon, 15 Jun 2026 12:18:54 -0400 Subject: [PATCH 01/14] Initial commit. --- .../admin/class-llms-admin-page-orders.php | 226 +++++++ .../tables/llms.table.orders.transactions.php | 635 ++++++++++++++++++ .../tables/llms.table.subscriptions.php | 594 ++++++++++++++++ includes/class-llms-loader.php | 3 + templates/admin/receipt-transaction.php | 145 ++++ 5 files changed, 1603 insertions(+) create mode 100644 includes/admin/class-llms-admin-page-orders.php create mode 100644 includes/admin/reporting/tables/llms.table.orders.transactions.php create mode 100644 includes/admin/reporting/tables/llms.table.subscriptions.php create mode 100644 templates/admin/receipt-transaction.php diff --git a/includes/admin/class-llms-admin-page-orders.php b/includes/admin/class-llms-admin-page-orders.php new file mode 100644 index 0000000000..06286a2960 --- /dev/null +++ b/includes/admin/class-llms-admin-page-orders.php @@ -0,0 +1,226 @@ +get_results(); + echo '
'; + $table->output_table_html(); + echo '
'; + } + + /** + * Render the Subscriptions page. + * + * @since [version] + * + * @return void + */ + public function render_subscriptions_page() { + $table = new LLMS_Table_Subscriptions(); + $table->get_results(); + echo '
'; + $table->output_table_html(); + echo '
'; + } + + /** + * Serve a single transaction receipt (HTML or PDF via lifterlms-pdfs). + * + * Triggered via URL parameter: ?llms_receipt_txn={transaction_id}&_wpnonce={nonce} + * + * @since [version] + * + * @return void + */ + public function maybe_serve_transaction_receipt() { + + $txn_id = absint( llms_filter_input( INPUT_GET, 'llms_receipt_txn', FILTER_SANITIZE_NUMBER_INT ) ); + if ( ! $txn_id ) { + return; + } + + $nonce = llms_filter_input( INPUT_GET, '_wpnonce' ); + if ( ! wp_verify_nonce( $nonce, 'llms_txn_receipt_' . $txn_id ) ) { + wp_die( esc_html__( 'Invalid request.', 'lifterlms' ) ); + } + + if ( ! current_user_can( 'view_lifterlms_reports' ) ) { + wp_die( esc_html__( 'You do not have permission to view this receipt.', 'lifterlms' ) ); + } + + $transaction = llms_get_post( $txn_id ); + if ( ! $transaction instanceof LLMS_Transaction ) { + wp_die( esc_html__( 'Transaction not found.', 'lifterlms' ) ); + } + + $order = llms_get_post( $transaction->get( 'order_id' ) ); + if ( ! $order instanceof LLMS_Order ) { + wp_die( esc_html__( 'Order not found.', 'lifterlms' ) ); + } + + /** + * Allow the LifterLMS PDFs plugin to handle single-transaction PDF generation. + * + * If a plugin hooks in and handles this action (e.g. generates a PDF), it should + * call exit() to prevent the HTML fallback from rendering. + * + * @since [version] + * + * @param LLMS_Transaction $transaction The transaction object. + * @param LLMS_Order $order The parent order object. + */ + do_action( 'llms_serve_transaction_receipt', $transaction, $order ); + + // HTML fallback: render the printable receipt template. + include LLMS_PLUGIN_DIR . 'templates/admin/receipt-transaction.php'; + exit; + } + + /** + * Get the URL for downloading a transaction receipt. + * + * @since [version] + * + * @param int $txn_id Transaction post ID. + * @return string + */ + public static function get_receipt_url( $txn_id ) { + return wp_nonce_url( + admin_url( '?llms_receipt_txn=' . $txn_id ), + 'llms_txn_receipt_' . $txn_id + ); + } +} + +return new LLMS_Admin_Page_Orders(); diff --git a/includes/admin/reporting/tables/llms.table.orders.transactions.php b/includes/admin/reporting/tables/llms.table.orders.transactions.php new file mode 100644 index 0000000000..538ad77237 --- /dev/null +++ b/includes/admin/reporting/tables/llms.table.orders.transactions.php @@ -0,0 +1,635 @@ +get_order_for_transaction( $data ); + $value = ''; + + switch ( $key ) { + + case 'transaction_id': + $txn_id = $data->get( 'id' ); + $receipt_url = LLMS_Admin_Page_Orders::get_receipt_url( $txn_id ); + $value = '#' . $txn_id; + $value .= '
'; + $value .= '' . esc_html__( 'Receipt', 'lifterlms' ) . ''; + if ( $order ) { + $order_url = admin_url( 'post.php?post=' . $order->get( 'id' ) . '&action=edit' ); + $value .= ' | ' . esc_html__( 'View Order', 'lifterlms' ) . ''; + } + $value .= '
'; + break; + + case 'order': + if ( $order ) { + $order_id = $order->get( 'id' ); + $url = esc_url( admin_url( 'post.php?post=' . $order_id . '&action=edit' ) ); + $value = '#' . $order_id . ''; + } + break; + + case 'customer': + if ( $order ) { + $name = $order->get_customer_name(); + $email = $order->get( 'billing_email' ); + $value = esc_html( $name ); + if ( $email ) { + $value .= '
' . esc_html( $email ) . ''; + } + } + break; + + case 'product': + if ( $order ) { + $product_id = $order->get( 'product_id' ); + if ( llms_get_post( $product_id ) ) { + $value = '' . esc_html( $order->get( 'product_title' ) ) . ''; + } else { + $value = esc_html__( '[DELETED]', 'lifterlms' ) . ' ' . esc_html( $order->get( 'product_title' ) ); + } + } + break; + + case 'amount': + $amount = $data->get( 'amount' ); + $value = wp_kses( llms_price( $amount ), LLMS_ALLOWED_HTML_PRICES ); + break; + + case 'status': + $status = $data->get( 'status' ); + $status_obj = get_post_status_object( $status ); + $status_name = $status_obj ? $status_obj->label : $status; + $value = '' . esc_html( $status_name ) . ''; + break; + + case 'payment_type': + $type = $data->get( 'payment_type' ); + $types = array( + 'single' => __( 'One-time', 'lifterlms' ), + 'recurring' => __( 'Recurring', 'lifterlms' ), + 'trial' => __( 'Trial', 'lifterlms' ), + ); + $value = isset( $types[ $type ] ) ? $types[ $type ] : $type; + break; + + case 'date': + $value = $data->get_date( 'date', get_option( 'date_format' ) . ' ' . get_option( 'time_format' ) ); + break; + + default: + $value = ''; + } + + return $this->filter_get_data( $value, $key, $data ); + } + + /** + * Retrieve data for a cell in an export file. + * + * @since [version] + * + * @param string $key The column id / key. + * @param LLMS_Transaction $data Transaction object. + * @return mixed + */ + public function get_export_data( $key, $data ) { + + $order = $this->get_order_for_transaction( $data ); + + switch ( $key ) { + + case 'transaction_id': + return $data->get( 'id' ); + + case 'order': + return $order ? $order->get( 'id' ) : ''; + + case 'customer': + return $order ? $order->get_customer_name() : ''; + + case 'customer_first_name': + return $order ? $order->get( 'billing_first_name' ) : ''; + + case 'customer_last_name': + return $order ? $order->get( 'billing_last_name' ) : ''; + + case 'customer_email': + return $order ? $order->get( 'billing_email' ) : ''; + + case 'billing_address_1': + return $order ? $order->get( 'billing_address_1' ) : ''; + + case 'billing_address_2': + return $order ? $order->get( 'billing_address_2' ) : ''; + + case 'billing_city': + return $order ? $order->get( 'billing_city' ) : ''; + + case 'billing_state': + return $order ? $order->get( 'billing_state' ) : ''; + + case 'billing_zip': + return $order ? $order->get( 'billing_zip' ) : ''; + + case 'billing_country': + return $order ? $order->get( 'billing_country' ) : ''; + + case 'gateway_transaction_id': + return $data->get( 'gateway_transaction_id' ); + + case 'amount': + return $data->get( 'amount' ); + + case 'status': + $status_obj = get_post_status_object( $data->get( 'status' ) ); + return $status_obj ? $status_obj->label : $data->get( 'status' ); + + case 'payment_type': + $type = $data->get( 'payment_type' ); + $types = array( + 'single' => __( 'One-time', 'lifterlms' ), + 'recurring' => __( 'Recurring', 'lifterlms' ), + 'trial' => __( 'Trial', 'lifterlms' ), + ); + return isset( $types[ $type ] ) ? $types[ $type ] : $type; + + case 'product': + return $order ? $order->get( 'product_title' ) : ''; + + case 'date': + return $data->get_date( 'date', 'Y-m-d H:i:s' ); + + default: + return $this->get_data( $key, $data ); + } + } + + /** + * Get the search placeholder text. + * + * @since [version] + * + * @return string + */ + public function get_table_search_form_placeholder() { + return apply_filters( 'llms_table_get_' . $this->id . '_search_placeholder', __( 'Search by order number, customer name, or email...', 'lifterlms' ) ); + } + + /** + * Get HTML for the filters displayed in the head of the table. + * + * @since [version] + * + * @return void + */ + public function output_table_filters_html() { + $statuses = llms_get_transaction_statuses(); + $current = $this->get_filter(); + ?> +
+
+ + +
+
+ parse_args( $args ); + + $query_args = array( + 'post_type' => 'llms_transaction', + 'posts_per_page' => $this->get_per_page(), + 'paged' => $this->get_current_page(), + 'order' => $this->get_order(), + 'orderby' => $this->get_orderby(), + 'post_status' => 'any', + 'meta_query' => array(), + ); + + // Filter by transaction status. + if ( 'status' === $this->get_filterby() && '' !== $this->get_filter() ) { + $query_args['post_status'] = $this->get_filter(); + } + + // Search handling. + $search = $this->get_search(); + if ( $search ) { + $order_ids = $this->search_orders( $search ); + if ( ! empty( $order_ids ) ) { + $query_args['meta_query'][] = array( + 'key' => '_llms_order_id', + 'value' => $order_ids, + 'compare' => 'IN', + ); + } else { + // No matching orders found, return empty. + $this->tbody_data = array(); + return; + } + } + + $query = new WP_Query( $query_args ); + + $this->max_pages = $query->max_num_pages; + $this->is_last_page = ( $query->max_num_pages <= $this->get_current_page() ); + + $transactions = array(); + foreach ( $query->posts as $post ) { + $txn = llms_get_post( $post ); + if ( $txn instanceof LLMS_Transaction ) { + $transactions[] = $txn; + } + } + + $this->tbody_data = $transactions; + } + + /** + * Search orders by number or customer name/email. + * + * @since [version] + * + * @param string $term Search term. + * @return int[] Array of matching order IDs. + */ + private function search_orders( $term ) { + + // Numeric search: treat as order ID. + if ( is_numeric( $term ) ) { + $order_id = absint( $term ); + if ( 'llms_order' === get_post_type( $order_id ) ) { + return array( $order_id ); + } + return array(); + } + + // Search users by name/email. + $user_query = new WP_User_Query( + array( + 'search' => '*' . esc_attr( $term ) . '*', + 'search_columns' => array( 'user_login', 'user_email', 'user_nicename', 'display_name' ), + 'fields' => 'ID', + ) + ); + + $user_query2 = new WP_User_Query( + array( + 'fields' => 'ID', + 'meta_query' => array( + 'relation' => 'OR', + array( + 'key' => 'first_name', + 'value' => $term, + 'compare' => 'LIKE', + ), + array( + 'key' => 'last_name', + 'value' => $term, + 'compare' => 'LIKE', + ), + ), + ) + ); + + $user_ids = wp_parse_id_list( + array_merge( + (array) $user_query->get_results(), + (array) $user_query2->get_results() + ) + ); + + if ( empty( $user_ids ) ) { + return array(); + } + + // Find orders belonging to these users. + $order_query = new WP_Query( + array( + 'post_type' => 'llms_order', + 'posts_per_page' => -1, + 'fields' => 'ids', + 'post_status' => 'any', + 'meta_query' => array( + array( + 'key' => '_llms_user_id', + 'value' => $user_ids, + 'compare' => 'IN', + ), + ), + ) + ); + + return $order_query->posts; + } + + /** + * Get the LLMS_Order for a given transaction (with basic caching). + * + * @since [version] + * + * @param LLMS_Transaction $txn Transaction object. + * @return LLMS_Order|false + */ + private function get_order_for_transaction( $txn ) { + + static $cache = array(); + + $order_id = $txn->get( 'order_id' ); + if ( ! $order_id ) { + return false; + } + + if ( ! isset( $cache[ $order_id ] ) ) { + $order = llms_get_post( $order_id ); + $cache[ $order_id ] = ( $order instanceof LLMS_Order ) ? $order : false; + } + + return $cache[ $order_id ]; + } + + /** + * Parse arguments passed to get_results(). + * + * @since [version] + * + * @param array $args Array of arguments. + * @return void + */ + protected function parse_args( $args = array() ) { + + if ( ! $args ) { + $args = $this->get_args(); + } + + $args = $this->clean_args( $args ); + + if ( isset( $args['page'] ) ) { + $this->current_page = absint( $args['page'] ); + } + + $this->order = isset( $args['order'] ) ? $args['order'] : $this->get_order(); + $this->orderby = isset( $args['orderby'] ) ? $args['orderby'] : $this->get_orderby(); + $this->per_page = isset( $args['per_page'] ) ? $args['per_page'] : $this->get_per_page(); + + if ( $this->is_filterable ) { + $this->filterby = isset( $args['filterby'] ) ? $args['filterby'] : $this->get_filterby(); + $this->filter = isset( $args['filter'] ) ? $args['filter'] : $this->get_filter(); + } + + if ( isset( $args['search'] ) ) { + $this->search = $args['search']; + } + } + + /** + * Define the structure of arguments used to pass to the get_results method. + * + * @since [version] + * + * @return array + */ + public function set_args() { + return array( + 'per_page' => apply_filters( 'llms_table_' . $this->id . '_per_page', $this->per_page ), + ); + } + + /** + * Define the structure of the table. + * + * @since [version] + * + * @return array + */ + protected function set_columns() { + return array( + 'transaction_id' => array( + 'exportable' => true, + 'sortable' => true, + 'title' => __( 'Transaction', 'lifterlms' ), + ), + 'order' => array( + 'exportable' => true, + 'sortable' => false, + 'title' => __( 'Order', 'lifterlms' ), + ), + 'customer' => array( + 'exportable' => true, + 'sortable' => false, + 'title' => __( 'Customer', 'lifterlms' ), + ), + 'customer_first_name' => array( + 'exportable' => true, + 'export_only' => true, + 'title' => __( 'First Name', 'lifterlms' ), + ), + 'customer_last_name' => array( + 'exportable' => true, + 'export_only' => true, + 'title' => __( 'Last Name', 'lifterlms' ), + ), + 'customer_email' => array( + 'exportable' => true, + 'export_only' => true, + 'title' => __( 'Email', 'lifterlms' ), + ), + 'product' => array( + 'exportable' => true, + 'sortable' => false, + 'title' => __( 'Product', 'lifterlms' ), + ), + 'amount' => array( + 'exportable' => true, + 'sortable' => true, + 'title' => __( 'Amount', 'lifterlms' ), + ), + 'status' => array( + 'exportable' => true, + 'sortable' => false, + 'title' => __( 'Status', 'lifterlms' ), + ), + 'payment_type' => array( + 'exportable' => true, + 'sortable' => false, + 'title' => __( 'Payment Type', 'lifterlms' ), + ), + 'date' => array( + 'exportable' => true, + 'sortable' => true, + 'title' => __( 'Date', 'lifterlms' ), + ), + 'gateway_transaction_id' => array( + 'exportable' => true, + 'export_only' => true, + 'title' => __( 'Gateway Transaction ID', 'lifterlms' ), + ), + 'billing_address_1' => array( + 'exportable' => true, + 'export_only' => true, + 'title' => __( 'Billing Address 1', 'lifterlms' ), + ), + 'billing_address_2' => array( + 'exportable' => true, + 'export_only' => true, + 'title' => __( 'Billing Address 2', 'lifterlms' ), + ), + 'billing_city' => array( + 'exportable' => true, + 'export_only' => true, + 'title' => __( 'Billing City', 'lifterlms' ), + ), + 'billing_state' => array( + 'exportable' => true, + 'export_only' => true, + 'title' => __( 'Billing State', 'lifterlms' ), + ), + 'billing_zip' => array( + 'exportable' => true, + 'export_only' => true, + 'title' => __( 'Billing Zip', 'lifterlms' ), + ), + 'billing_country' => array( + 'exportable' => true, + 'export_only' => true, + 'title' => __( 'Billing Country', 'lifterlms' ), + ), + ); + } + + /** + * Set the table's title. + * + * @since [version] + * + * @return string + */ + protected function set_title() { + return __( 'Orders & Transactions', 'lifterlms' ); + } +} diff --git a/includes/admin/reporting/tables/llms.table.subscriptions.php b/includes/admin/reporting/tables/llms.table.subscriptions.php new file mode 100644 index 0000000000..8469780909 --- /dev/null +++ b/includes/admin/reporting/tables/llms.table.subscriptions.php @@ -0,0 +1,594 @@ +get( 'id' ); + $url = esc_url( admin_url( 'post.php?post=' . $order_id . '&action=edit' ) ); + $name = $data->get_customer_name(); + $value = '#' . $order_id . ' '; + $value .= esc_html__( 'by', 'lifterlms' ) . ' '; + $value .= esc_html( $name ); + break; + + case 'customer': + $name = $data->get_customer_name(); + $email = $data->get( 'billing_email' ); + $value = esc_html( $name ); + if ( $email ) { + $value .= '
' . esc_html( $email ) . ''; + } + break; + + case 'product': + $product_id = $data->get( 'product_id' ); + if ( llms_get_post( $product_id ) ) { + $value = '' . esc_html( $data->get( 'product_title' ) ) . ''; + } else { + $value = esc_html__( '[DELETED]', 'lifterlms' ) . ' ' . esc_html( $data->get( 'product_title' ) ); + } + break; + + case 'status': + $status = $data->get( 'status' ); + $value = '' . esc_html( llms_get_order_status_name( $status ) ) . ''; + break; + + case 'plan': + $frequency = $data->get( 'billing_frequency' ); + $period = $data->get( 'billing_period' ); + $length = $data->get( 'billing_length' ); + $price = $data->get_price( 'total' ); + + $value = $price . ' / '; + if ( $frequency > 1 ) { + $value .= $frequency . ' '; + } + $value .= $period; + if ( $length > 0 ) { + /* translators: %d: billing length (number of payments) */ + $value .= ' ' . sprintf( __( '(%d payments)', 'lifterlms' ), $length ); + } + break; + + case 'revenue': + $grosse = $data->get_revenue( 'grosse' ); + $net = $data->get_revenue( 'net' ); + + if ( $grosse !== $net ) { + $value = '' . wp_kses( llms_price( $grosse ), LLMS_ALLOWED_HTML_PRICES ) . ' '; + } + $value .= wp_kses( llms_price( $net ), LLMS_ALLOWED_HTML_PRICES ); + break; + + case 'next_payment': + $next = $data->get_next_payment_due_date( get_option( 'date_format' ) ); + if ( is_wp_error( $next ) ) { + $value = '–'; + } else { + $value = $next; + } + break; + + case 'date': + $value = $data->get_date( 'date', get_option( 'date_format' ) ); + break; + + default: + $value = ''; + } + + return $this->filter_get_data( $value, $key, $data ); + } + + /** + * Retrieve data for a cell in an export file. + * + * @since [version] + * + * @param string $key The column id / key. + * @param LLMS_Order $data Order object. + * @return mixed + */ + public function get_export_data( $key, $data ) { + + switch ( $key ) { + + case 'order': + return $data->get( 'id' ); + + case 'customer': + return $data->get_customer_name(); + + case 'customer_first_name': + return $data->get( 'billing_first_name' ); + + case 'customer_last_name': + return $data->get( 'billing_last_name' ); + + case 'customer_email': + return $data->get( 'billing_email' ); + + case 'billing_address_1': + return $data->get( 'billing_address_1' ); + + case 'billing_address_2': + return $data->get( 'billing_address_2' ); + + case 'billing_city': + return $data->get( 'billing_city' ); + + case 'billing_state': + return $data->get( 'billing_state' ); + + case 'billing_zip': + return $data->get( 'billing_zip' ); + + case 'billing_country': + return $data->get( 'billing_country' ); + + case 'status': + return llms_get_order_status_name( $data->get( 'status' ) ); + + case 'revenue': + return $data->get_revenue( 'net' ); + + case 'next_payment': + $next = $data->get_next_payment_due_date( 'Y-m-d H:i:s' ); + return is_wp_error( $next ) ? '' : $next; + + case 'date': + return $data->get_date( 'date', 'Y-m-d H:i:s' ); + + case 'billing_frequency': + return $data->get( 'billing_frequency' ); + + case 'billing_period': + return $data->get( 'billing_period' ); + + case 'billing_length': + return $data->get( 'billing_length' ); + + case 'trial_offer': + return $data->has_trial() ? __( 'Yes', 'lifterlms' ) : __( 'No', 'lifterlms' ); + + default: + return $this->get_data( $key, $data ); + } + } + + /** + * Get the search placeholder text. + * + * @since [version] + * + * @return string + */ + public function get_table_search_form_placeholder() { + return apply_filters( 'llms_table_get_' . $this->id . '_search_placeholder', __( 'Search by order number, customer name, or email...', 'lifterlms' ) ); + } + + /** + * Get HTML for the filters displayed in the head of the table. + * + * @since [version] + * + * @return void + */ + public function output_table_filters_html() { + $statuses = llms_get_order_statuses( 'recurring' ); + $current = $this->get_filter(); + ?> +
+
+ + +
+
+ parse_args( $args ); + + $query_args = array( + 'post_type' => 'llms_order', + 'posts_per_page' => $this->get_per_page(), + 'paged' => $this->get_current_page(), + 'order' => $this->get_order(), + 'orderby' => $this->get_orderby(), + 'post_status' => 'any', + 'meta_query' => array( + array( + 'key' => '_llms_order_type', + 'value' => 'recurring', + ), + ), + ); + + // Filter by order status. + if ( 'status' === $this->get_filterby() && '' !== $this->get_filter() ) { + $query_args['post_status'] = $this->get_filter(); + } + + // Search handling. + $search = $this->get_search(); + if ( $search ) { + if ( is_numeric( $search ) ) { + $query_args['p'] = absint( $search ); + } else { + $user_ids = $this->search_users( $search ); + if ( ! empty( $user_ids ) ) { + $query_args['meta_query'][] = array( + 'key' => '_llms_user_id', + 'value' => $user_ids, + 'compare' => 'IN', + ); + } else { + $this->tbody_data = array(); + return; + } + } + } + + $query = new WP_Query( $query_args ); + + $this->max_pages = $query->max_num_pages; + $this->is_last_page = ( $query->max_num_pages <= $this->get_current_page() ); + + $orders = array(); + foreach ( $query->posts as $post ) { + $order = llms_get_post( $post ); + if ( $order instanceof LLMS_Order ) { + $orders[] = $order; + } + } + + $this->tbody_data = $orders; + } + + /** + * Search users by name or email. + * + * @since [version] + * + * @param string $term Search term. + * @return int[] Array of matching user IDs. + */ + private function search_users( $term ) { + + $user_query = new WP_User_Query( + array( + 'search' => '*' . esc_attr( $term ) . '*', + 'search_columns' => array( 'user_login', 'user_email', 'user_nicename', 'display_name' ), + 'fields' => 'ID', + ) + ); + + $user_query2 = new WP_User_Query( + array( + 'fields' => 'ID', + 'meta_query' => array( + 'relation' => 'OR', + array( + 'key' => 'first_name', + 'value' => $term, + 'compare' => 'LIKE', + ), + array( + 'key' => 'last_name', + 'value' => $term, + 'compare' => 'LIKE', + ), + ), + ) + ); + + return wp_parse_id_list( + array_merge( + (array) $user_query->get_results(), + (array) $user_query2->get_results() + ) + ); + } + + /** + * Parse arguments passed to get_results(). + * + * @since [version] + * + * @param array $args Array of arguments. + * @return void + */ + protected function parse_args( $args = array() ) { + + if ( ! $args ) { + $args = $this->get_args(); + } + + $args = $this->clean_args( $args ); + + if ( isset( $args['page'] ) ) { + $this->current_page = absint( $args['page'] ); + } + + $this->order = isset( $args['order'] ) ? $args['order'] : $this->get_order(); + $this->orderby = isset( $args['orderby'] ) ? $args['orderby'] : $this->get_orderby(); + $this->per_page = isset( $args['per_page'] ) ? $args['per_page'] : $this->get_per_page(); + + if ( $this->is_filterable ) { + $this->filterby = isset( $args['filterby'] ) ? $args['filterby'] : $this->get_filterby(); + $this->filter = isset( $args['filter'] ) ? $args['filter'] : $this->get_filter(); + } + + if ( isset( $args['search'] ) ) { + $this->search = $args['search']; + } + } + + /** + * Define the structure of arguments used to pass to the get_results method. + * + * @since [version] + * + * @return array + */ + public function set_args() { + return array( + 'per_page' => apply_filters( 'llms_table_' . $this->id . '_per_page', $this->per_page ), + ); + } + + /** + * Define the structure of the table. + * + * @since [version] + * + * @return array + */ + protected function set_columns() { + return array( + 'order' => array( + 'exportable' => true, + 'sortable' => true, + 'title' => __( 'Order', 'lifterlms' ), + ), + 'customer' => array( + 'exportable' => true, + 'sortable' => false, + 'title' => __( 'Customer', 'lifterlms' ), + ), + 'customer_first_name' => array( + 'exportable' => true, + 'export_only' => true, + 'title' => __( 'First Name', 'lifterlms' ), + ), + 'customer_last_name' => array( + 'exportable' => true, + 'export_only' => true, + 'title' => __( 'Last Name', 'lifterlms' ), + ), + 'customer_email' => array( + 'exportable' => true, + 'export_only' => true, + 'title' => __( 'Email', 'lifterlms' ), + ), + 'product' => array( + 'exportable' => true, + 'sortable' => false, + 'title' => __( 'Product', 'lifterlms' ), + ), + 'status' => array( + 'exportable' => true, + 'sortable' => false, + 'title' => __( 'Status', 'lifterlms' ), + ), + 'plan' => array( + 'exportable' => true, + 'sortable' => false, + 'title' => __( 'Plan', 'lifterlms' ), + ), + 'revenue' => array( + 'exportable' => true, + 'sortable' => false, + 'title' => __( 'Revenue', 'lifterlms' ), + ), + 'next_payment' => array( + 'exportable' => true, + 'sortable' => true, + 'title' => __( 'Next Payment', 'lifterlms' ), + ), + 'date' => array( + 'exportable' => true, + 'sortable' => true, + 'title' => __( 'Date', 'lifterlms' ), + ), + 'billing_frequency' => array( + 'exportable' => true, + 'export_only' => true, + 'title' => __( 'Billing Frequency', 'lifterlms' ), + ), + 'billing_period' => array( + 'exportable' => true, + 'export_only' => true, + 'title' => __( 'Billing Period', 'lifterlms' ), + ), + 'billing_length' => array( + 'exportable' => true, + 'export_only' => true, + 'title' => __( 'Billing Length', 'lifterlms' ), + ), + 'trial_offer' => array( + 'exportable' => true, + 'export_only' => true, + 'title' => __( 'Has Trial', 'lifterlms' ), + ), + 'billing_address_1' => array( + 'exportable' => true, + 'export_only' => true, + 'title' => __( 'Billing Address 1', 'lifterlms' ), + ), + 'billing_address_2' => array( + 'exportable' => true, + 'export_only' => true, + 'title' => __( 'Billing Address 2', 'lifterlms' ), + ), + 'billing_city' => array( + 'exportable' => true, + 'export_only' => true, + 'title' => __( 'Billing City', 'lifterlms' ), + ), + 'billing_state' => array( + 'exportable' => true, + 'export_only' => true, + 'title' => __( 'Billing State', 'lifterlms' ), + ), + 'billing_zip' => array( + 'exportable' => true, + 'export_only' => true, + 'title' => __( 'Billing Zip', 'lifterlms' ), + ), + 'billing_country' => array( + 'exportable' => true, + 'export_only' => true, + 'title' => __( 'Billing Country', 'lifterlms' ), + ), + ); + } + + /** + * Set the table's title. + * + * @since [version] + * + * @return string + */ + protected function set_title() { + return __( 'Subscriptions', 'lifterlms' ); + } +} diff --git a/includes/class-llms-loader.php b/includes/class-llms-loader.php index 5bf432ad26..00e51b5892 100644 --- a/includes/class-llms-loader.php +++ b/includes/class-llms-loader.php @@ -367,6 +367,9 @@ public function includes_admin() { require_once LLMS_PLUGIN_DIR . 'includes/admin/class-llms-admin-profile.php'; require_once LLMS_PLUGIN_DIR . 'includes/admin/class.llms.student.bulk.enroll.php'; + // Orders & Transactions admin pages. + require_once LLMS_PLUGIN_DIR . 'includes/admin/class-llms-admin-page-orders.php'; + // Post types. require_once LLMS_PLUGIN_DIR . 'includes/admin/post-types/class.llms.post.tables.php'; diff --git a/templates/admin/receipt-transaction.php b/templates/admin/receipt-transaction.php new file mode 100644 index 0000000000..d983f545d5 --- /dev/null +++ b/templates/admin/receipt-transaction.php @@ -0,0 +1,145 @@ + + +> + + + + + <?php + /* translators: %d: Transaction ID */ + printf( esc_html__( 'Receipt - Transaction #%d', 'lifterlms' ), $transaction->get( 'id' ) ); + ?> + + + + +
+
+

+ get( 'id' ) ); + ?> +

+

+
+
+ +
+
+ +

+ + + + + + + + + + + + + + + + + + + + + + + get( 'gateway_transaction_id' ) ) : ?> + + + + + + +
#get( 'id' ) ); ?>
get_date( 'date', get_option( 'date_format' ) . ' ' . get_option( 'time_format' ) ) ); ?>
get( 'amount' ) ), LLMS_ALLOWED_HTML_PRICES ); ?>
+ get( 'status' ); + $status_obj = get_post_status_object( $status ); + ?> + + label : $status ); ?> + +
get( 'gateway_source_description' ) ); ?>
get( 'gateway_transaction_id' ) ); ?>
+ +

+ + + + + + + + + + + +
#get( 'id' ) ); ?>
get( 'product_title' ) ); ?>
+ +

+ + + + + + + + + + + get( 'billing_address_1' ) ) : ?> + + + + + + +
get_customer_name() ); ?>
get( 'billing_email' ) ); ?>
+ get( 'billing_address_1' ) ); ?>
+ get( 'billing_address_2' ) ) : ?> + get( 'billing_address_2' ) ); ?>
+ + get( 'billing_city' ) ); ?>, + get( 'billing_state' ) ); ?> + get( 'billing_zip' ) ); ?>
+ get( 'billing_country' ) ) ); ?> +
+ + From 294cafb0e5753e435c6c17deb1f25a6117bc8b7b Mon Sep 17 00:00:00 2001 From: Brian Hogg Date: Mon, 15 Jun 2026 13:01:37 -0400 Subject: [PATCH 02/14] Fix to view the new table. --- .../admin/class-llms-admin-page-orders.php | 93 ++++++++++++++++--- .../tables/llms.table.orders.transactions.php | 16 +++- .../tables/llms.table.subscriptions.php | 16 +++- 3 files changed, 109 insertions(+), 16 deletions(-) diff --git a/includes/admin/class-llms-admin-page-orders.php b/includes/admin/class-llms-admin-page-orders.php index 06286a2960..51649a930d 100644 --- a/includes/admin/class-llms-admin-page-orders.php +++ b/includes/admin/class-llms-admin-page-orders.php @@ -20,6 +20,18 @@ */ class LLMS_Admin_Page_Orders { + /** + * Hook suffixes for the registered pages. + * + * These match the `WP_Screen::$id` values and are used to enqueue the + * admin tables JS on the correct screens. + * + * @since [version] + * + * @var string[] + */ + protected $page_hooks = array(); + /** * Constructor. * @@ -29,29 +41,71 @@ class LLMS_Admin_Page_Orders { */ public function __construct() { add_action( 'admin_menu', array( $this, 'register_pages' ) ); - add_action( 'admin_menu', array( $this, 'hide_default_orders_submenu' ), 999 ); + add_action( 'admin_menu', array( $this, 'reorder_orders_menu' ), 999 ); add_filter( 'llms_load_table_resources_pages', array( $this, 'add_table_resource_pages' ) ); add_action( 'admin_init', array( $this, 'maybe_redirect_old_listing' ) ); add_action( 'admin_init', array( $this, 'maybe_serve_transaction_receipt' ) ); } /** - * Hide the default "All Orders" submenu item from the Orders CPT menu. + * Reorder the Orders submenu so the new views land first. * - * The CPT edit screen remains accessible by direct URL for editing individual orders. + * WordPress points a CPT's top-level menu link at its first submenu item. By + * removing the default "All Orders" list-table link and promoting the new + * "Orders & Transactions" page to the top, clicking the top-level "Orders" + * menu opens the new view instead of the raw CPT listing (or Coupons). + * + * The single-order edit screen (`post.php`) remains fully accessible. * * @since [version] * * @return void */ - public function hide_default_orders_submenu() { - remove_submenu_page( 'edit.php?post_type=llms_order', 'edit.php?post_type=llms_order' ); + public function reorder_orders_menu() { + + global $submenu; + + $parent = 'edit.php?post_type=llms_order'; + + if ( empty( $submenu[ $parent ] ) ) { + return; + } + + // Desired leading order, keyed by page slug. + $priority = array( + 'llms-orders-transactions' => 0, + 'llms-subscriptions' => 1, + ); + + $front = array(); + $rest = array(); + + foreach ( $submenu[ $parent ] as $item ) { + + $slug = $item[2]; + + // Drop the default "All Orders" list-table link (slug equals the parent slug). + if ( $parent === $slug ) { + continue; + } + + if ( isset( $priority[ $slug ] ) ) { + $front[ $priority[ $slug ] ] = $item; + } else { + $rest[] = $item; + } + } + + ksort( $front ); + + $submenu[ $parent ] = array_merge( array_values( $front ), $rest ); } /** * Redirect the old CPT listing URL to the new Orders & Transactions page. * - * Only redirects when viewing the listing (not the single edit screen). + * Only redirects when viewing the raw CPT listing (not the single edit screen + * and not one of our own custom pages). * * @since [version] * @@ -70,6 +124,11 @@ public function maybe_redirect_old_listing() { return; } + // Never redirect our own custom pages (prevents a redirect loop). + if ( llms_filter_input( INPUT_GET, 'page' ) ) { + return; + } + // Don't redirect if there's a specific post status filter (e.g. Trash view). $post_status = llms_filter_input( INPUT_GET, 'post_status' ); if ( $post_status && 'all' !== $post_status ) { @@ -91,20 +150,25 @@ public function register_pages() { $parent_slug = 'edit.php?post_type=llms_order'; - add_submenu_page( + // The LLMS_Admin_Table AJAX handlers (pagination/search/export) require + // `view_lifterlms_reports`, so gate the pages with the same capability to + // keep the initial render and subsequent AJAX requests consistent. + $capability = 'view_lifterlms_reports'; + + $this->page_hooks[] = add_submenu_page( $parent_slug, __( 'Orders & Transactions', 'lifterlms' ), - __( 'Transactions', 'lifterlms' ), - apply_filters( 'lifterlms_admin_order_access', 'manage_lifterlms' ), + __( 'Orders & Transactions', 'lifterlms' ), + $capability, 'llms-orders-transactions', array( $this, 'render_orders_transactions_page' ) ); - add_submenu_page( + $this->page_hooks[] = add_submenu_page( $parent_slug, __( 'Subscriptions', 'lifterlms' ), __( 'Subscriptions', 'lifterlms' ), - apply_filters( 'lifterlms_admin_order_access', 'manage_lifterlms' ), + $capability, 'llms-subscriptions', array( $this, 'render_subscriptions_page' ) ); @@ -113,15 +177,16 @@ public function register_pages() { /** * Add the new pages to the list of pages that load table JS resources. * + * The hook suffixes returned by `add_submenu_page()` match the + * `WP_Screen::$id` of each page, so they can be used directly. + * * @since [version] * * @param string[] $pages Array of screen IDs. * @return string[] */ public function add_table_resource_pages( $pages ) { - $pages[] = 'llms_order_page_llms-orders-transactions'; - $pages[] = 'llms_order_page_llms-subscriptions'; - return $pages; + return array_merge( $pages, $this->page_hooks ); } /** diff --git a/includes/admin/reporting/tables/llms.table.orders.transactions.php b/includes/admin/reporting/tables/llms.table.orders.transactions.php index 538ad77237..1bfb5b581d 100644 --- a/includes/admin/reporting/tables/llms.table.orders.transactions.php +++ b/includes/admin/reporting/tables/llms.table.orders.transactions.php @@ -327,11 +327,25 @@ public function get_results( $args = array() ) { 'posts_per_page' => $this->get_per_page(), 'paged' => $this->get_current_page(), 'order' => $this->get_order(), - 'orderby' => $this->get_orderby(), 'post_status' => 'any', 'meta_query' => array(), ); + // Map the sortable column to valid WP_Query ordering arguments. + switch ( $this->get_orderby() ) { + case 'transaction_id': + $query_args['orderby'] = 'ID'; + break; + case 'amount': + $query_args['orderby'] = 'meta_value_num'; + $query_args['meta_key'] = '_llms_amount'; + break; + case 'date': + default: + $query_args['orderby'] = 'date'; + break; + } + // Filter by transaction status. if ( 'status' === $this->get_filterby() && '' !== $this->get_filter() ) { $query_args['post_status'] = $this->get_filter(); diff --git a/includes/admin/reporting/tables/llms.table.subscriptions.php b/includes/admin/reporting/tables/llms.table.subscriptions.php index 8469780909..2e18038dc8 100644 --- a/includes/admin/reporting/tables/llms.table.subscriptions.php +++ b/includes/admin/reporting/tables/llms.table.subscriptions.php @@ -321,7 +321,6 @@ public function get_results( $args = array() ) { 'posts_per_page' => $this->get_per_page(), 'paged' => $this->get_current_page(), 'order' => $this->get_order(), - 'orderby' => $this->get_orderby(), 'post_status' => 'any', 'meta_query' => array( array( @@ -331,6 +330,21 @@ public function get_results( $args = array() ) { ), ); + // Map the sortable column to valid WP_Query ordering arguments. + switch ( $this->get_orderby() ) { + case 'order': + $query_args['orderby'] = 'ID'; + break; + case 'next_payment': + $query_args['orderby'] = 'meta_value'; + $query_args['meta_key'] = '_llms_date_next_payment'; + break; + case 'date': + default: + $query_args['orderby'] = 'date'; + break; + } + // Filter by order status. if ( 'status' === $this->get_filterby() && '' !== $this->get_filter() ) { $query_args['post_status'] = $this->get_filter(); From 2c110493812c1d113ca85f24e8c002b334e4b5c6 Mon Sep 17 00:00:00 2001 From: Brian Hogg Date: Mon, 15 Jun 2026 14:51:25 -0400 Subject: [PATCH 03/14] Sort by product. Labels just Orders vs. Orders & Transactions. Formatting. --- assets/js/llms-admin-tables.js | 11 +- .../scss/admin/post-tables/_llms_orders.scss | 27 +- .../admin/class-llms-admin-page-orders.php | 10 +- .../tables/llms.table.orders.transactions.php | 254 ++++++++++++++++-- 4 files changed, 277 insertions(+), 25 deletions(-) diff --git a/assets/js/llms-admin-tables.js b/assets/js/llms-admin-tables.js index c238509fd8..109214607d 100644 --- a/assets/js/llms-admin-tables.js +++ b/assets/js/llms-admin-tables.js @@ -147,12 +147,21 @@ this.change_filter = function( $table, $select ) { - this.reload( $table, { + var args = { filter: $select.val(), filterby: $select.attr( 'name' ), page: 1, + }; + + // Also pass every filter's current value keyed by its name so tables that + // support multiple simultaneous filters (e.g. orders/transactions) can combine + // them. Tables using the single filter/filterby mechanism ignore the extras. + $table.parent().find( '.llms-table-filters select.llms-table-filter' ).each( function() { + args[ $( this ).attr( 'name' ) ] = $( this ).val(); } ); + this.reload( $table, args ); + }; /** diff --git a/assets/scss/admin/post-tables/_llms_orders.scss b/assets/scss/admin/post-tables/_llms_orders.scss index 27bf992fb0..4b196b9e33 100644 --- a/assets/scss/admin/post-tables/_llms_orders.scss +++ b/assets/scss/admin/post-tables/_llms_orders.scss @@ -1,7 +1,32 @@ -.wp-list-table { +.wp-list-table, +.llms-orders-transactions-wrap, +.llms-subscriptions-wrap { @include order_status_badges(); } #lifterlms-order-transactions .llms-table tfoot th { text-align: right; } + +// New Orders / Subscriptions reporting pages (LLMS_Admin_Page_Orders). +.llms-orders-transactions-wrap, +.llms-subscriptions-wrap { + + .llms-table-header { + align-items: center; + display: flex; + flex-wrap: wrap; + gap: 8px 12px; + margin: 12px 0 16px; + } + + .llms-table-search { + margin-left: auto; + } + + .llms-table-filters { + display: flex; + flex-wrap: wrap; + gap: 8px; + } +} diff --git a/includes/admin/class-llms-admin-page-orders.php b/includes/admin/class-llms-admin-page-orders.php index 51649a930d..62c8e5096f 100644 --- a/includes/admin/class-llms-admin-page-orders.php +++ b/includes/admin/class-llms-admin-page-orders.php @@ -157,8 +157,8 @@ public function register_pages() { $this->page_hooks[] = add_submenu_page( $parent_slug, - __( 'Orders & Transactions', 'lifterlms' ), - __( 'Orders & Transactions', 'lifterlms' ), + __( 'Orders', 'lifterlms' ), + __( 'Orders', 'lifterlms' ), $capability, 'llms-orders-transactions', array( $this, 'render_orders_transactions_page' ) @@ -199,7 +199,10 @@ public function add_table_resource_pages( $pages ) { public function render_orders_transactions_page() { $table = new LLMS_Table_Orders_Transactions(); $table->get_results(); + // The page renders its own H1, so suppress the table's duplicate title. + $table->set( 'title', '' ); echo '
'; + echo '

' . esc_html__( 'Orders', 'lifterlms' ) . '

'; $table->output_table_html(); echo '
'; } @@ -214,7 +217,10 @@ public function render_orders_transactions_page() { public function render_subscriptions_page() { $table = new LLMS_Table_Subscriptions(); $table->get_results(); + // The page renders its own H1, so suppress the table's duplicate title. + $table->set( 'title', '' ); echo '
'; + echo '

' . esc_html__( 'Subscriptions', 'lifterlms' ) . '

'; $table->output_table_html(); echo '
'; } diff --git a/includes/admin/reporting/tables/llms.table.orders.transactions.php b/includes/admin/reporting/tables/llms.table.orders.transactions.php index 1bfb5b581d..a19cbff888 100644 --- a/includes/admin/reporting/tables/llms.table.orders.transactions.php +++ b/includes/admin/reporting/tables/llms.table.orders.transactions.php @@ -90,6 +90,27 @@ class LLMS_Table_Orders_Transactions extends LLMS_Admin_Table { */ protected $filterby = 'status'; + /** + * Currently selected transaction status filter. + * + * @var string + */ + protected $status_filter = ''; + + /** + * Currently selected month filter, formatted `YYYYMM`. + * + * @var string + */ + protected $date_filter = ''; + + /** + * Currently selected coupon ID filter. + * + * @var int + */ + protected $coupon_filter = 0; + /** * Retrieve data for a cell. * @@ -129,11 +150,19 @@ protected function get_data( $key, $data ) { case 'customer': if ( $order ) { - $name = $order->get_customer_name(); - $email = $order->get( 'billing_email' ); - $value = esc_html( $name ); - if ( $email ) { - $value .= '
' . esc_html( $email ) . ''; + $name = $order->get_customer_name(); + + // Link to the customer's user profile (and email) unless the order is + // anonymized or has no associated WordPress user, mirroring the legacy orders table. + if ( llms_parse_bool( $order->get( 'anonymized' ) ) || empty( llms_get_student( $order->get( 'user_id' ) ) ) ) { + $value = esc_html( $name ); + } else { + $edit_user_link = $order->get( 'user_id' ) ? get_edit_user_link( $order->get( 'user_id' ) ) : ''; + $value = $edit_user_link ? '' . esc_html( $name ) . '' : esc_html( $name ); + $email = $order->get( 'billing_email' ); + if ( $email ) { + $value .= '
' . esc_html( $email ) . ''; + } } } break; @@ -283,7 +312,6 @@ public function get_table_search_form_placeholder() { */ public function output_table_filters_html() { $statuses = llms_get_transaction_statuses(); - $current = $this->get_filter(); ?>
@@ -293,7 +321,7 @@ public function output_table_filters_html() {
+
+ + +
+
+ + +
'llms_coupon', + 'posts_per_page' => -1, + 'post_status' => array( 'publish', 'pending', 'draft', 'private' ), + 'orderby' => 'title', + 'order' => 'ASC', + 'suppress_filters' => false, + ) + ); + } + /** * Execute a query to retrieve results from the table. * @@ -331,6 +409,8 @@ public function get_results( $args = array() ) { 'meta_query' => array(), ); + $sort_by_product = false; + // Map the sortable column to valid WP_Query ordering arguments. switch ( $this->get_orderby() ) { case 'transaction_id': @@ -340,6 +420,11 @@ public function get_results( $args = array() ) { $query_args['orderby'] = 'meta_value_num'; $query_args['meta_key'] = '_llms_amount'; break; + case 'product': + // The product title lives on the parent order, so sorting requires a + // custom join applied via the `posts_clauses` filter below. + $sort_by_product = true; + break; case 'date': default: $query_args['orderby'] = 'date'; @@ -347,29 +432,59 @@ public function get_results( $args = array() ) { } // Filter by transaction status. - if ( 'status' === $this->get_filterby() && '' !== $this->get_filter() ) { - $query_args['post_status'] = $this->get_filter(); + if ( '' !== $this->status_filter ) { + $query_args['post_status'] = $this->status_filter; } - // Search handling. + // Filter by month (YYYYMM). + if ( $this->date_filter && preg_match( '/^(\d{4})(\d{2})$/', $this->date_filter, $matches ) ) { + $query_args['date_query'] = array( + array( + 'year' => absint( $matches[1] ), + 'month' => absint( $matches[2] ), + ), + ); + } + + // Build a set of order IDs to restrict to when searching and/or filtering by coupon. + $order_id_sets = array(); + $search = $this->get_search(); if ( $search ) { - $order_ids = $this->search_orders( $search ); - if ( ! empty( $order_ids ) ) { - $query_args['meta_query'][] = array( - 'key' => '_llms_order_id', - 'value' => $order_ids, - 'compare' => 'IN', - ); - } else { - // No matching orders found, return empty. + $order_id_sets[] = $this->search_orders( $search ); + } + + if ( $this->coupon_filter ) { + $order_id_sets[] = $this->get_order_ids_for_coupon( $this->coupon_filter ); + } + + if ( ! empty( $order_id_sets ) ) { + + // Intersect so combined filters (e.g. coupon + search) narrow the results. + $order_ids = count( $order_id_sets ) > 1 ? array_values( call_user_func_array( 'array_intersect', $order_id_sets ) ) : $order_id_sets[0]; + + if ( empty( $order_ids ) ) { $this->tbody_data = array(); return; } + + $query_args['meta_query'][] = array( + 'key' => '_llms_order_id', + 'value' => $order_ids, + 'compare' => 'IN', + ); + } + + if ( $sort_by_product ) { + add_filter( 'posts_clauses', array( $this, 'product_orderby_clauses' ), 10, 2 ); } $query = new WP_Query( $query_args ); + if ( $sort_by_product ) { + remove_filter( 'posts_clauses', array( $this, 'product_orderby_clauses' ), 10 ); + } + $this->max_pages = $query->max_num_pages; $this->is_last_page = ( $query->max_num_pages <= $this->get_current_page() ); @@ -487,6 +602,88 @@ private function get_order_for_transaction( $txn ) { return $cache[ $order_id ]; } + /** + * Modify the query clauses to sort transactions by their parent order's product title. + * + * The product title is stored as meta on the parent order (`_llms_product_title`), which is + * itself referenced by the transaction's `_llms_order_id` meta, so two joins are required. + * + * @since [version] + * + * @param array $clauses Array of SQL clauses. + * @param WP_Query $query The WP_Query instance (passed by reference). + * @return array + */ + public function product_orderby_clauses( $clauses, $query ) { + + global $wpdb; + + $order = ( 'ASC' === strtoupper( $this->get_order() ) ) ? 'ASC' : 'DESC'; + + $clauses['join'] .= " LEFT JOIN {$wpdb->postmeta} AS llms_txn_oid ON ( {$wpdb->posts}.ID = llms_txn_oid.post_id AND llms_txn_oid.meta_key = '_llms_order_id' )"; + $clauses['join'] .= " LEFT JOIN {$wpdb->postmeta} AS llms_txn_pt ON ( llms_txn_oid.meta_value = llms_txn_pt.post_id AND llms_txn_pt.meta_key = '_llms_product_title' )"; + $clauses['orderby'] = "llms_txn_pt.meta_value {$order}"; + + return $clauses; + } + + /** + * Retrieve order IDs associated with a given coupon. + * + * @since [version] + * + * @param int $coupon_id WP_Post ID of the coupon. + * @return int[] Array of matching order IDs. + */ + private function get_order_ids_for_coupon( $coupon_id ) { + + $query = new WP_Query( + array( + 'post_type' => 'llms_order', + 'posts_per_page' => -1, + 'fields' => 'ids', + 'post_status' => 'any', + 'no_found_rows' => true, + 'meta_query' => array( + array( + 'key' => '_llms_coupon_id', + 'value' => absint( $coupon_id ), + ), + ), + ) + ); + + return $query->posts; + } + + /** + * Retrieve the distinct year/month combinations that have transactions. + * + * @since [version] + * + * @return object[] Array of objects with `year` and `month` properties. + */ + private function get_available_months() { + + global $wpdb; + + $cache_key = 'transaction_months'; + $months = wp_cache_get( $cache_key, 'llms_orders_transactions' ); + + if ( false === $months ) { + $months = $wpdb->get_results( + "SELECT DISTINCT YEAR( post_date ) AS year, MONTH( post_date ) AS month + FROM {$wpdb->posts} + WHERE post_type = 'llms_transaction' + AND post_status NOT IN ( 'auto-draft', 'trash' ) + ORDER BY post_date DESC" + ); + wp_cache_set( $cache_key, $months, 'llms_orders_transactions', HOUR_IN_SECONDS ); + } + + return $months; + } + /** * Parse arguments passed to get_results(). * @@ -516,6 +713,18 @@ protected function parse_args( $args = array() ) { $this->filter = isset( $args['filter'] ) ? $args['filter'] : $this->get_filter(); } + if ( isset( $args['status'] ) ) { + $this->status_filter = sanitize_text_field( $args['status'] ); + } + + if ( isset( $args['date'] ) ) { + $this->date_filter = preg_replace( '/[^0-9]/', '', $args['date'] ); + } + + if ( isset( $args['coupon'] ) ) { + $this->coupon_filter = absint( $args['coupon'] ); + } + if ( isset( $args['search'] ) ) { $this->search = $args['search']; } @@ -531,6 +740,9 @@ protected function parse_args( $args = array() ) { public function set_args() { return array( 'per_page' => apply_filters( 'llms_table_' . $this->id . '_per_page', $this->per_page ), + 'status' => $this->status_filter, + 'date' => $this->date_filter, + 'coupon' => $this->coupon_filter, ); } @@ -575,7 +787,7 @@ protected function set_columns() { ), 'product' => array( 'exportable' => true, - 'sortable' => false, + 'sortable' => true, 'title' => __( 'Product', 'lifterlms' ), ), 'amount' => array( @@ -644,6 +856,6 @@ protected function set_columns() { * @return string */ protected function set_title() { - return __( 'Orders & Transactions', 'lifterlms' ); + return __( 'Orders', 'lifterlms' ); } } From d7c4f58b8e02a2b869ea57002c78319130420f1c Mon Sep 17 00:00:00 2001 From: Brian Hogg Date: Mon, 15 Jun 2026 15:43:18 -0400 Subject: [PATCH 04/14] Formatting. Adding mailto link. Include free transactions/orders. --- assets/scss/admin/_llms-table.scss | 1 + .../tables/llms.table.orders.transactions.php | 472 ++++++++++++++---- .../tables/llms.table.subscriptions.php | 48 +- 3 files changed, 421 insertions(+), 100 deletions(-) diff --git a/assets/scss/admin/_llms-table.scss b/assets/scss/admin/_llms-table.scss index 9d02556df3..253b299f1b 100644 --- a/assets/scss/admin/_llms-table.scss +++ b/assets/scss/admin/_llms-table.scss @@ -46,6 +46,7 @@ border-bottom: 1px solid #c3c4c7; padding: 10px 12px; text-align: center; + vertical-align: top; &.expandable.closed { display: none; diff --git a/includes/admin/reporting/tables/llms.table.orders.transactions.php b/includes/admin/reporting/tables/llms.table.orders.transactions.php index a19cbff888..f5bd13331b 100644 --- a/includes/admin/reporting/tables/llms.table.orders.transactions.php +++ b/includes/admin/reporting/tables/llms.table.orders.transactions.php @@ -111,6 +111,13 @@ class LLMS_Table_Orders_Transactions extends LLMS_Admin_Table { */ protected $coupon_filter = 0; + /** + * Active custom sort mode for the `posts_clauses` filter ('product' or 'amount'). + * + * @var string + */ + protected $sort_mode = ''; + /** * Retrieve data for a cell. * @@ -122,6 +129,11 @@ class LLMS_Table_Orders_Transactions extends LLMS_Admin_Table { */ protected function get_data( $key, $data ) { + // Rows can be either a transaction or a transaction-less order (free, trial, pending payment). + if ( $data instanceof LLMS_Order ) { + return $this->filter_get_data( $this->get_order_row_data( $key, $data ), $key, $data ); + } + $order = $this->get_order_for_transaction( $data ); $value = ''; @@ -142,39 +154,19 @@ protected function get_data( $key, $data ) { case 'order': if ( $order ) { - $order_id = $order->get( 'id' ); - $url = esc_url( admin_url( 'post.php?post=' . $order_id . '&action=edit' ) ); - $value = '#' . $order_id . ''; + $value = $this->get_order_link( $order ); } break; case 'customer': if ( $order ) { - $name = $order->get_customer_name(); - - // Link to the customer's user profile (and email) unless the order is - // anonymized or has no associated WordPress user, mirroring the legacy orders table. - if ( llms_parse_bool( $order->get( 'anonymized' ) ) || empty( llms_get_student( $order->get( 'user_id' ) ) ) ) { - $value = esc_html( $name ); - } else { - $edit_user_link = $order->get( 'user_id' ) ? get_edit_user_link( $order->get( 'user_id' ) ) : ''; - $value = $edit_user_link ? '' . esc_html( $name ) . '' : esc_html( $name ); - $email = $order->get( 'billing_email' ); - if ( $email ) { - $value .= '
' . esc_html( $email ) . ''; - } - } + $value = $this->get_customer_html( $order ); } break; case 'product': if ( $order ) { - $product_id = $order->get( 'product_id' ); - if ( llms_get_post( $product_id ) ) { - $value = '' . esc_html( $order->get( 'product_title' ) ) . ''; - } else { - $value = esc_html__( '[DELETED]', 'lifterlms' ) . ' ' . esc_html( $order->get( 'product_title' ) ); - } + $value = $this->get_product_html( $order ); } break; @@ -184,20 +176,11 @@ protected function get_data( $key, $data ) { break; case 'status': - $status = $data->get( 'status' ); - $status_obj = get_post_status_object( $status ); - $status_name = $status_obj ? $status_obj->label : $status; - $value = '' . esc_html( $status_name ) . ''; + $value = $this->get_status_html( $data->get( 'status' ) ); break; case 'payment_type': - $type = $data->get( 'payment_type' ); - $types = array( - 'single' => __( 'One-time', 'lifterlms' ), - 'recurring' => __( 'Recurring', 'lifterlms' ), - 'trial' => __( 'Trial', 'lifterlms' ), - ); - $value = isset( $types[ $type ] ) ? $types[ $type ] : $type; + $value = $this->get_payment_type_label( $data->get( 'payment_type' ) ); break; case 'date': @@ -211,6 +194,163 @@ protected function get_data( $key, $data ) { return $this->filter_get_data( $value, $key, $data ); } + /** + * Retrieve cell data for a transaction-less order row (free, trial, or pending-payment order). + * + * @since [version] + * + * @param string $key The column id / key. + * @param LLMS_Order $order Order object. + * @return string + */ + protected function get_order_row_data( $key, $order ) { + + switch ( $key ) { + + case 'transaction_id': + $order_url = admin_url( 'post.php?post=' . $order->get( 'id' ) . '&action=edit' ); + $value = '–'; + $value .= '
'; + $value .= '' . esc_html__( 'View Order', 'lifterlms' ) . ''; + $value .= '
'; + return $value; + + case 'order': + return $this->get_order_link( $order ); + + case 'customer': + return $this->get_customer_html( $order ); + + case 'product': + return $this->get_product_html( $order ); + + case 'amount': + return wp_kses( $order->get_initial_price( array(), 'html' ), LLMS_ALLOWED_HTML_PRICES ); + + case 'status': + return $this->get_status_html( $order->get( 'status' ), llms_get_order_status_name( $order->get( 'status' ) ) ); + + case 'payment_type': + return $this->get_order_payment_type_label( $order ); + + case 'date': + return $order->get_date( 'date', get_option( 'date_format' ) . ' ' . get_option( 'time_format' ) ); + + default: + return ''; + } + } + + /** + * Build a linked order number. + * + * @since [version] + * + * @param LLMS_Order $order Order object. + * @return string + */ + protected function get_order_link( $order ) { + $order_id = $order->get( 'id' ); + $url = esc_url( admin_url( 'post.php?post=' . $order_id . '&action=edit' ) ); + return '#' . $order_id . ''; + } + + /** + * Build the customer cell HTML: name linked to the user profile, email as a mailto link. + * + * @since [version] + * + * @param LLMS_Order $order Order object. + * @return string + */ + protected function get_customer_html( $order ) { + + $name = $order->get_customer_name(); + + // Link to the customer's user profile (and email) unless the order is + // anonymized or has no associated WordPress user, mirroring the legacy orders table. + if ( llms_parse_bool( $order->get( 'anonymized' ) ) || empty( llms_get_student( $order->get( 'user_id' ) ) ) ) { + return esc_html( $name ); + } + + $edit_user_link = $order->get( 'user_id' ) ? get_edit_user_link( $order->get( 'user_id' ) ) : ''; + $value = $edit_user_link ? '' . esc_html( $name ) . '' : esc_html( $name ); + $email = $order->get( 'billing_email' ); + if ( $email ) { + $value .= '
' . esc_html( $email ) . ''; + } + + return $value; + } + + /** + * Build the product cell HTML. + * + * @since [version] + * + * @param LLMS_Order $order Order object. + * @return string + */ + protected function get_product_html( $order ) { + $product_id = $order->get( 'product_id' ); + if ( llms_get_post( $product_id ) ) { + return '' . esc_html( $order->get( 'product_title' ) ) . ''; + } + return esc_html__( '[DELETED]', 'lifterlms' ) . ' ' . esc_html( $order->get( 'product_title' ) ); + } + + /** + * Build a status badge. + * + * @since [version] + * + * @param string $status Status slug (transaction or order post status). + * @param string $label Optional. Pre-resolved label. Defaults to the registered post status label. + * @return string + */ + protected function get_status_html( $status, $label = '' ) { + if ( ! $label ) { + $status_obj = get_post_status_object( $status ); + $label = $status_obj ? $status_obj->label : $status; + } + return '' . esc_html( $label ) . ''; + } + + /** + * Get the human-readable payment type label for a transaction. + * + * @since [version] + * + * @param string $type Transaction payment type. + * @return string + */ + protected function get_payment_type_label( $type ) { + $types = array( + 'single' => __( 'One-time', 'lifterlms' ), + 'recurring' => __( 'Recurring', 'lifterlms' ), + 'trial' => __( 'Trial', 'lifterlms' ), + ); + return isset( $types[ $type ] ) ? $types[ $type ] : $type; + } + + /** + * Derive a payment type label for a transaction-less order row. + * + * @since [version] + * + * @param LLMS_Order $order Order object. + * @return string + */ + protected function get_order_payment_type_label( $order ) { + if ( $order->has_trial() ) { + return __( 'Trial', 'lifterlms' ); + } + if ( 0 >= (float) $order->get( 'total' ) ) { + return __( 'Free', 'lifterlms' ); + } + return $order->is_recurring() ? __( 'Recurring', 'lifterlms' ) : __( 'One-time', 'lifterlms' ); + } + /** * Retrieve data for a cell in an export file. * @@ -222,6 +362,11 @@ protected function get_data( $key, $data ) { */ public function get_export_data( $key, $data ) { + // Transaction-less order row (free, trial, or pending-payment order). + if ( $data instanceof LLMS_Order ) { + return $this->get_order_row_export_data( $key, $data ); + } + $order = $this->get_order_for_transaction( $data ); switch ( $key ) { @@ -273,13 +418,7 @@ public function get_export_data( $key, $data ) { return $status_obj ? $status_obj->label : $data->get( 'status' ); case 'payment_type': - $type = $data->get( 'payment_type' ); - $types = array( - 'single' => __( 'One-time', 'lifterlms' ), - 'recurring' => __( 'Recurring', 'lifterlms' ), - 'trial' => __( 'Trial', 'lifterlms' ), - ); - return isset( $types[ $type ] ) ? $types[ $type ] : $type; + return $this->get_payment_type_label( $data->get( 'payment_type' ) ); case 'product': return $order ? $order->get( 'product_title' ) : ''; @@ -292,6 +431,68 @@ public function get_export_data( $key, $data ) { } } + /** + * Retrieve export cell data for a transaction-less order row. + * + * @since [version] + * + * @param string $key The column id / key. + * @param LLMS_Order $order Order object. + * @return string + */ + protected function get_order_row_export_data( $key, $order ) { + + switch ( $key ) { + + case 'transaction_id': + return ''; + + case 'order': + return $order->get( 'id' ); + + case 'customer': + return $order->get_customer_name(); + + case 'customer_first_name': + return $order->get( 'billing_first_name' ); + + case 'customer_last_name': + return $order->get( 'billing_last_name' ); + + case 'customer_email': + return $order->get( 'billing_email' ); + + case 'billing_address_1': + case 'billing_address_2': + case 'billing_city': + case 'billing_state': + case 'billing_zip': + case 'billing_country': + return $order->get( $key ); + + case 'gateway_transaction_id': + return ''; + + case 'amount': + return $order->get_initial_price( array(), 'float' ); + + case 'status': + return llms_get_order_status_name( $order->get( 'status' ) ); + + case 'payment_type': + return $this->get_order_payment_type_label( $order ); + + case 'product': + return $order->get( 'product_title' ); + + case 'date': + return $order->get_date( 'date', 'Y-m-d H:i:s' ); + + default: + return ''; + } + } + /** * Get the search placeholder text. * @@ -311,7 +512,8 @@ public function get_table_search_form_placeholder() { * @return void */ public function output_table_filters_html() { - $statuses = llms_get_transaction_statuses(); + $txn_statuses = llms_get_transaction_statuses(); + $order_statuses = llms_get_order_statuses(); ?>
@@ -320,14 +522,23 @@ public function output_table_filters_html() {
@@ -400,30 +611,25 @@ public function get_results( $args = array() ) { $this->parse_args( $args ); + // Query both transactions and orders. Orders that already have at least one + // transaction are represented by their transaction rows, so they're excluded + // below; orders with no transaction (free, trial, pending payment) appear as + // their own row. $query_args = array( - 'post_type' => 'llms_transaction', + 'post_type' => array( 'llms_transaction', 'llms_order' ), 'posts_per_page' => $this->get_per_page(), 'paged' => $this->get_current_page(), 'order' => $this->get_order(), 'post_status' => 'any', - 'meta_query' => array(), ); - $sort_by_product = false; - - // Map the sortable column to valid WP_Query ordering arguments. + // Map the sortable column to valid WP_Query ordering arguments. Product and + // amount span both post types, so they're handled via `posts_clauses` below. + $this->sort_mode = ''; switch ( $this->get_orderby() ) { - case 'transaction_id': - $query_args['orderby'] = 'ID'; - break; - case 'amount': - $query_args['orderby'] = 'meta_value_num'; - $query_args['meta_key'] = '_llms_amount'; - break; case 'product': - // The product title lives on the parent order, so sorting requires a - // custom join applied via the `posts_clauses` filter below. - $sort_by_product = true; + case 'amount': + $this->sort_mode = $this->get_orderby(); break; case 'date': default: @@ -431,7 +637,7 @@ public function get_results( $args = array() ) { break; } - // Filter by transaction status. + // Filter by status (applies to post_status for both transactions and orders). if ( '' !== $this->status_filter ) { $query_args['post_status'] = $this->status_filter; } @@ -446,6 +652,9 @@ public function get_results( $args = array() ) { ); } + // Order IDs that already have at least one transaction. + $orders_with_txns = $this->get_order_ids_with_transactions(); + // Build a set of order IDs to restrict to when searching and/or filtering by coupon. $order_id_sets = array(); @@ -468,35 +677,112 @@ public function get_results( $args = array() ) { return; } - $query_args['meta_query'][] = array( - 'key' => '_llms_order_id', - 'value' => $order_ids, - 'compare' => 'IN', + // Restrict to the matching orders' transactions plus the matching orders + // that have no transaction. Using an explicit post__in keeps both post + // types correctly scoped within a single query. + $post_in = array_map( + 'absint', + array_merge( + $this->get_transaction_ids_for_orders( $order_ids ), + array_values( array_diff( $order_ids, $orders_with_txns ) ) + ) ); + + if ( empty( $post_in ) ) { + $this->tbody_data = array(); + return; + } + + $query_args['post__in'] = $post_in; + + } elseif ( ! empty( $orders_with_txns ) ) { + + // No search/coupon filter: exclude order posts that are already represented + // by their transaction rows. + $query_args['post__not_in'] = array_map( 'absint', $orders_with_txns ); } - if ( $sort_by_product ) { - add_filter( 'posts_clauses', array( $this, 'product_orderby_clauses' ), 10, 2 ); + if ( $this->sort_mode ) { + add_filter( 'posts_clauses', array( $this, 'mixed_orderby_clauses' ), 10, 2 ); } $query = new WP_Query( $query_args ); - if ( $sort_by_product ) { - remove_filter( 'posts_clauses', array( $this, 'product_orderby_clauses' ), 10 ); + if ( $this->sort_mode ) { + remove_filter( 'posts_clauses', array( $this, 'mixed_orderby_clauses' ), 10 ); } $this->max_pages = $query->max_num_pages; $this->is_last_page = ( $query->max_num_pages <= $this->get_current_page() ); - $transactions = array(); + $rows = array(); foreach ( $query->posts as $post ) { - $txn = llms_get_post( $post ); - if ( $txn instanceof LLMS_Transaction ) { - $transactions[] = $txn; + $obj = llms_get_post( $post ); + if ( $obj instanceof LLMS_Transaction || $obj instanceof LLMS_Order ) { + $rows[] = $obj; } } - $this->tbody_data = $transactions; + $this->tbody_data = $rows; + } + + /** + * Retrieve the IDs of all orders that have at least one transaction. + * + * @since [version] + * + * @return int[] Array of order IDs. + */ + private function get_order_ids_with_transactions() { + + global $wpdb; + + $cache_key = 'order_ids_with_transactions'; + $ids = wp_cache_get( $cache_key, 'llms_orders_transactions' ); + + if ( false === $ids ) { + $ids = $wpdb->get_col( + "SELECT DISTINCT meta_value FROM {$wpdb->postmeta} WHERE meta_key = '_llms_order_id'" + ); + $ids = array_map( 'absint', (array) $ids ); + wp_cache_set( $cache_key, $ids, 'llms_orders_transactions', HOUR_IN_SECONDS ); + } + + return $ids; + } + + /** + * Retrieve transaction IDs belonging to a set of orders. + * + * @since [version] + * + * @param int[] $order_ids Array of order IDs. + * @return int[] Array of transaction IDs. + */ + private function get_transaction_ids_for_orders( $order_ids ) { + + if ( empty( $order_ids ) ) { + return array(); + } + + $query = new WP_Query( + array( + 'post_type' => 'llms_transaction', + 'posts_per_page' => -1, + 'fields' => 'ids', + 'post_status' => 'any', + 'no_found_rows' => true, + 'meta_query' => array( + array( + 'key' => '_llms_order_id', + 'value' => $order_ids, + 'compare' => 'IN', + ), + ), + ) + ); + + return $query->posts; } /** @@ -603,10 +889,12 @@ private function get_order_for_transaction( $txn ) { } /** - * Modify the query clauses to sort transactions by their parent order's product title. + * Modify the query clauses to sort the mixed transaction/order result set. * - * The product title is stored as meta on the parent order (`_llms_product_title`), which is - * itself referenced by the transaction's `_llms_order_id` meta, so two joins are required. + * Both the product title and the amount can live on either the row's own post + * (for order rows) or on the parent order referenced by the transaction's + * `_llms_order_id` meta (for transaction rows). A `COALESCE` over the row's own + * ID and that meta value resolves the correct order in both cases. * * @since [version] * @@ -614,15 +902,23 @@ private function get_order_for_transaction( $txn ) { * @param WP_Query $query The WP_Query instance (passed by reference). * @return array */ - public function product_orderby_clauses( $clauses, $query ) { + public function mixed_orderby_clauses( $clauses, $query ) { global $wpdb; $order = ( 'ASC' === strtoupper( $this->get_order() ) ) ? 'ASC' : 'DESC'; - $clauses['join'] .= " LEFT JOIN {$wpdb->postmeta} AS llms_txn_oid ON ( {$wpdb->posts}.ID = llms_txn_oid.post_id AND llms_txn_oid.meta_key = '_llms_order_id' )"; - $clauses['join'] .= " LEFT JOIN {$wpdb->postmeta} AS llms_txn_pt ON ( llms_txn_oid.meta_value = llms_txn_pt.post_id AND llms_txn_pt.meta_key = '_llms_product_title' )"; - $clauses['orderby'] = "llms_txn_pt.meta_value {$order}"; + // Resolve the effective order ID: the transaction's parent order, or the row itself when it is an order. + $clauses['join'] .= " LEFT JOIN {$wpdb->postmeta} AS llms_oid ON ( {$wpdb->posts}.ID = llms_oid.post_id AND llms_oid.meta_key = '_llms_order_id' )"; + + if ( 'amount' === $this->sort_mode ) { + $clauses['join'] .= " LEFT JOIN {$wpdb->postmeta} AS llms_amt ON ( {$wpdb->posts}.ID = llms_amt.post_id AND llms_amt.meta_key = '_llms_amount' )"; + $clauses['join'] .= " LEFT JOIN {$wpdb->postmeta} AS llms_tot ON ( COALESCE( llms_oid.meta_value, {$wpdb->posts}.ID ) = llms_tot.post_id AND llms_tot.meta_key = '_llms_total' )"; + $clauses['orderby'] = "CAST( COALESCE( llms_amt.meta_value, llms_tot.meta_value, 0 ) AS DECIMAL(20,2) ) {$order}"; + } else { + $clauses['join'] .= " LEFT JOIN {$wpdb->postmeta} AS llms_pt ON ( COALESCE( llms_oid.meta_value, {$wpdb->posts}.ID ) = llms_pt.post_id AND llms_pt.meta_key = '_llms_product_title' )"; + $clauses['orderby'] = "llms_pt.meta_value {$order}"; + } return $clauses; } @@ -674,7 +970,7 @@ private function get_available_months() { $months = $wpdb->get_results( "SELECT DISTINCT YEAR( post_date ) AS year, MONTH( post_date ) AS month FROM {$wpdb->posts} - WHERE post_type = 'llms_transaction' + WHERE post_type IN ( 'llms_transaction', 'llms_order' ) AND post_status NOT IN ( 'auto-draft', 'trash' ) ORDER BY post_date DESC" ); @@ -757,7 +1053,7 @@ protected function set_columns() { return array( 'transaction_id' => array( 'exportable' => true, - 'sortable' => true, + 'sortable' => false, 'title' => __( 'Transaction', 'lifterlms' ), ), 'order' => array( diff --git a/includes/admin/reporting/tables/llms.table.subscriptions.php b/includes/admin/reporting/tables/llms.table.subscriptions.php index 2e18038dc8..15a5213904 100644 --- a/includes/admin/reporting/tables/llms.table.subscriptions.php +++ b/includes/admin/reporting/tables/llms.table.subscriptions.php @@ -108,19 +108,11 @@ protected function get_data( $key, $data ) { case 'order': $order_id = $data->get( 'id' ); $url = esc_url( admin_url( 'post.php?post=' . $order_id . '&action=edit' ) ); - $name = $data->get_customer_name(); - $value = '#' . $order_id . ' '; - $value .= esc_html__( 'by', 'lifterlms' ) . ' '; - $value .= esc_html( $name ); + $value = '#' . $order_id . ''; break; case 'customer': - $name = $data->get_customer_name(); - $email = $data->get( 'billing_email' ); - $value = esc_html( $name ); - if ( $email ) { - $value .= '
' . esc_html( $email ) . ''; - } + $value = $this->get_customer_html( $data ); break; case 'product': @@ -174,7 +166,7 @@ protected function get_data( $key, $data ) { break; case 'date': - $value = $data->get_date( 'date', get_option( 'date_format' ) ); + $value = $data->get_date( 'date', get_option( 'date_format' ) . ' ' . get_option( 'time_format' ) ); break; default: @@ -184,6 +176,34 @@ protected function get_data( $key, $data ) { return $this->filter_get_data( $value, $key, $data ); } + /** + * Build the customer cell HTML: name linked to the user profile, email as a mailto link. + * + * @since [version] + * + * @param LLMS_Order $order Order object. + * @return string + */ + protected function get_customer_html( $order ) { + + $name = $order->get_customer_name(); + + // Link to the customer's user profile (and email) unless the order is + // anonymized or has no associated WordPress user, mirroring the legacy orders table. + if ( llms_parse_bool( $order->get( 'anonymized' ) ) || empty( llms_get_student( $order->get( 'user_id' ) ) ) ) { + return esc_html( $name ); + } + + $edit_user_link = $order->get( 'user_id' ) ? get_edit_user_link( $order->get( 'user_id' ) ) : ''; + $value = $edit_user_link ? '' . esc_html( $name ) . '' : esc_html( $name ); + $email = $order->get( 'billing_email' ); + if ( $email ) { + $value .= '
' . esc_html( $email ) . ''; + } + + return $value; + } + /** * Retrieve data for a cell in an export file. * @@ -335,6 +355,10 @@ public function get_results( $args = array() ) { case 'order': $query_args['orderby'] = 'ID'; break; + case 'product': + $query_args['orderby'] = 'meta_value'; + $query_args['meta_key'] = '_llms_product_title'; + break; case 'next_payment': $query_args['orderby'] = 'meta_value'; $query_args['meta_key'] = '_llms_date_next_payment'; @@ -514,7 +538,7 @@ protected function set_columns() { ), 'product' => array( 'exportable' => true, - 'sortable' => false, + 'sortable' => true, 'title' => __( 'Product', 'lifterlms' ), ), 'status' => array( From 44fbbd66200e1ff36057a47ca9e6ae226da83ce6 Mon Sep 17 00:00:00 2001 From: Brian Hogg Date: Mon, 15 Jun 2026 16:04:41 -0400 Subject: [PATCH 05/14] Clearing of cache on order/txn save. Only use transaction statuses for filtering etc. --- .../tables/llms.table.orders.transactions.php | 118 ++++++++++++++---- .../class.llms.controller.orders.php | 33 +++++ 2 files changed, 126 insertions(+), 25 deletions(-) diff --git a/includes/admin/reporting/tables/llms.table.orders.transactions.php b/includes/admin/reporting/tables/llms.table.orders.transactions.php index f5bd13331b..34856026fc 100644 --- a/includes/admin/reporting/tables/llms.table.orders.transactions.php +++ b/includes/admin/reporting/tables/llms.table.orders.transactions.php @@ -20,6 +20,13 @@ */ class LLMS_Table_Orders_Transactions extends LLMS_Admin_Table { + /** + * Object cache group for this table's cached aggregate queries. + * + * @var string + */ + const CACHE_GROUP = 'llms_orders_transactions'; + /** * Unique ID for the Table. * @@ -228,6 +235,10 @@ protected function get_order_row_data( $key, $order ) { return wp_kses( $order->get_initial_price( array(), 'html' ), LLMS_ALLOWED_HTML_PRICES ); case 'status': + $txn_status = $this->map_order_status_to_transaction_status( $order->get( 'status' ) ); + if ( $txn_status ) { + return $this->get_status_html( $txn_status ); + } return $this->get_status_html( $order->get( 'status' ), llms_get_order_status_name( $order->get( 'status' ) ) ); case 'payment_type': @@ -316,6 +327,46 @@ protected function get_status_html( $status, $label = '' ) { return '' . esc_html( $label ) . ''; } + /** + * Map the table's transaction statuses onto the equivalent order post statuses. + * + * The combined table presents a single, transaction-centric set of statuses for + * both row types: a transaction-less order is shown using the transaction status + * it corresponds to (e.g. a free/completed enrollment reads as "Succeeded", a + * never-paid order reads as "Pending"). Subscription lifecycle statuses (active, + * on-hold, pending cancellation, expired, etc.) intentionally live in the + * Subscriptions table -- a transaction itself is never "pending cancellation". + * + * @since [version] + * + * @return array Map of transaction status slug => array of equivalent order status slugs. + */ + protected function get_status_groups() { + return array( + 'llms-txn-succeeded' => array( 'llms-completed', 'llms-active' ), + 'llms-txn-failed' => array( 'llms-failed' ), + 'llms-txn-pending' => array( 'llms-pending' ), + 'llms-txn-refunded' => array( 'llms-refunded' ), + ); + } + + /** + * Resolve the transaction status that an order status maps to in this table. + * + * @since [version] + * + * @param string $order_status Order post status slug. + * @return string The equivalent transaction status slug, or empty string if there's no mapping. + */ + protected function map_order_status_to_transaction_status( $order_status ) { + foreach ( $this->get_status_groups() as $txn_status => $order_statuses ) { + if ( in_array( $order_status, $order_statuses, true ) ) { + return $txn_status; + } + } + return ''; + } + /** * Get the human-readable payment type label for a transaction. * @@ -477,6 +528,11 @@ protected function get_order_row_export_data( $key, $order ) { return $order->get_initial_price( array(), 'float' ); case 'status': + $txn_status = $this->map_order_status_to_transaction_status( $order->get( 'status' ) ); + if ( $txn_status ) { + $status_obj = get_post_status_object( $txn_status ); + return $status_obj ? $status_obj->label : $txn_status; + } return llms_get_order_status_name( $order->get( 'status' ) ); case 'payment_type': @@ -512,8 +568,7 @@ public function get_table_search_form_placeholder() { * @return void */ public function output_table_filters_html() { - $txn_statuses = llms_get_transaction_statuses(); - $order_statuses = llms_get_order_statuses(); + $statuses = llms_get_transaction_statuses(); ?>
@@ -522,23 +577,14 @@ public function output_table_filters_html() {
@@ -637,9 +683,14 @@ public function get_results( $args = array() ) { break; } - // Filter by status (applies to post_status for both transactions and orders). + // Filter by status. Each transaction status also matches the equivalent order + // statuses so transaction-less orders are included (e.g. "Succeeded" matches + // completed/active orders, "Pending" matches never-paid orders). if ( '' !== $this->status_filter ) { - $query_args['post_status'] = $this->status_filter; + $groups = $this->get_status_groups(); + $query_args['post_status'] = isset( $groups[ $this->status_filter ] ) + ? array_merge( array( $this->status_filter ), $groups[ $this->status_filter ] ) + : $this->status_filter; } // Filter by month (YYYYMM). @@ -738,19 +789,36 @@ private function get_order_ids_with_transactions() { global $wpdb; $cache_key = 'order_ids_with_transactions'; - $ids = wp_cache_get( $cache_key, 'llms_orders_transactions' ); + $ids = wp_cache_get( $cache_key, self::CACHE_GROUP ); if ( false === $ids ) { $ids = $wpdb->get_col( "SELECT DISTINCT meta_value FROM {$wpdb->postmeta} WHERE meta_key = '_llms_order_id'" ); $ids = array_map( 'absint', (array) $ids ); - wp_cache_set( $cache_key, $ids, 'llms_orders_transactions', HOUR_IN_SECONDS ); + wp_cache_set( $cache_key, $ids, self::CACHE_GROUP, HOUR_IN_SECONDS ); } return $ids; } + /** + * Flush the table's cached aggregate queries. + * + * Called when an order or transaction is created, updated, or deleted so the + * report (which may sit behind a persistent object cache) doesn't show stale + * rows -- e.g. an order showing both as its own row and as a transaction row + * after its first payment is recorded. + * + * @since [version] + * + * @return void + */ + public static function clear_cache() { + wp_cache_delete( 'order_ids_with_transactions', self::CACHE_GROUP ); + wp_cache_delete( 'transaction_months', self::CACHE_GROUP ); + } + /** * Retrieve transaction IDs belonging to a set of orders. * @@ -964,7 +1032,7 @@ private function get_available_months() { global $wpdb; $cache_key = 'transaction_months'; - $months = wp_cache_get( $cache_key, 'llms_orders_transactions' ); + $months = wp_cache_get( $cache_key, self::CACHE_GROUP ); if ( false === $months ) { $months = $wpdb->get_results( @@ -974,7 +1042,7 @@ private function get_available_months() { AND post_status NOT IN ( 'auto-draft', 'trash' ) ORDER BY post_date DESC" ); - wp_cache_set( $cache_key, $months, 'llms_orders_transactions', HOUR_IN_SECONDS ); + wp_cache_set( $cache_key, $months, self::CACHE_GROUP, HOUR_IN_SECONDS ); } return $months; diff --git a/includes/controllers/class.llms.controller.orders.php b/includes/controllers/class.llms.controller.orders.php index 28e58a0895..601f8a87e4 100644 --- a/includes/controllers/class.llms.controller.orders.php +++ b/includes/controllers/class.llms.controller.orders.php @@ -50,6 +50,11 @@ public function __construct() { // This action is meant to do specific actions on orders when an enrollment, with an order as trigger, is deleted. add_action( 'llms_user_enrollment_deleted', array( $this, 'on_user_enrollment_deleted' ), 10, 3 ); + // Keep the Orders & Transactions report's cached aggregates fresh when orders/transactions change. + add_action( 'save_post_llms_order', array( $this, 'clear_orders_transactions_report_cache' ) ); + add_action( 'save_post_llms_transaction', array( $this, 'clear_orders_transactions_report_cache' ) ); + add_action( 'before_delete_post', array( $this, 'maybe_clear_orders_transactions_report_cache' ) ); + // Transaction status changes cascade up to the order to change the order status. add_action( 'lifterlms_transaction_status_failed', array( $this, 'transaction_failed' ), 10, 1 ); add_action( 'lifterlms_transaction_status_refunded', array( $this, 'transaction_refunded' ), 10, 1 ); @@ -237,6 +242,34 @@ public function on_delete_order( $post_id ) { } } + /** + * Flush the Orders & Transactions admin report's cached aggregate queries. + * + * @since [version] + * + * @return void + */ + public function clear_orders_transactions_report_cache() { + if ( class_exists( 'LLMS_Table_Orders_Transactions' ) ) { + LLMS_Table_Orders_Transactions::clear_cache(); + } + } + + /** + * Flush the Orders & Transactions report cache when an order or transaction is deleted. + * + * @since [version] + * + * @param int $post_id WP_Post ID of the post being deleted. + * @return void + */ + public function maybe_clear_orders_transactions_report_cache( $post_id ) { + $post_type = get_post_type( $post_id ); + if ( 'llms_order' === $post_type || 'llms_transaction' === $post_type ) { + $this->clear_orders_transactions_report_cache(); + } + } + /** * Called when an user enrollment is deleted. * From 52522b5bc5bbb8654e43605888525bc4eb32cc03 Mon Sep 17 00:00:00 2001 From: Brian Hogg Date: Tue, 16 Jun 2026 08:54:37 -0400 Subject: [PATCH 06/14] Adding note about transaction to top of edit order page. --- .../admin/class-llms-admin-page-orders.php | 48 +++++++++++++++++++ .../tables/llms.table.orders.transactions.php | 32 +++++++++++-- 2 files changed, 76 insertions(+), 4 deletions(-) diff --git a/includes/admin/class-llms-admin-page-orders.php b/includes/admin/class-llms-admin-page-orders.php index 62c8e5096f..7dcaa3d6be 100644 --- a/includes/admin/class-llms-admin-page-orders.php +++ b/includes/admin/class-llms-admin-page-orders.php @@ -45,6 +45,7 @@ public function __construct() { add_filter( 'llms_load_table_resources_pages', array( $this, 'add_table_resource_pages' ) ); add_action( 'admin_init', array( $this, 'maybe_redirect_old_listing' ) ); add_action( 'admin_init', array( $this, 'maybe_serve_transaction_receipt' ) ); + add_action( 'admin_notices', array( $this, 'maybe_render_transaction_parent_notice' ) ); } /** @@ -225,6 +226,53 @@ public function render_subscriptions_page() { echo '
'; } + /** + * Render a note on the order edit screen when arriving from a transaction row. + * + * When a transaction in the Orders & Transactions table links to its parent + * order, the link carries `llms_txn_id`. This surfaces a note at the top of the + * order edit screen letting the user know the transaction itself lives in the + * Transactions list below, with a jump link to it. + * + * @since [version] + * + * @return void + */ + public function maybe_render_transaction_parent_notice() { + + global $pagenow; + + if ( 'post.php' !== $pagenow ) { + return; + } + + $txn_id = absint( llms_filter_input( INPUT_GET, 'llms_txn_id', FILTER_SANITIZE_NUMBER_INT ) ); + if ( ! $txn_id ) { + return; + } + + $post_id = absint( llms_filter_input( INPUT_GET, 'post', FILTER_SANITIZE_NUMBER_INT ) ); + if ( ! $post_id || 'llms_order' !== get_post_type( $post_id ) ) { + return; + } + + // Only show the note when the transaction actually belongs to this order. + $transaction = llms_get_post( $txn_id ); + if ( ! $transaction instanceof LLMS_Transaction || absint( $transaction->get( 'order_id' ) ) !== $post_id ) { + return; + } + + printf( + '

%1$s %2$s

', + sprintf( + /* translators: %d: transaction ID */ + esc_html__( 'Viewing details on the parent order for transaction #%d. The transaction can be found below.', 'lifterlms' ), + $txn_id + ), + esc_html__( 'View transactions', 'lifterlms' ) + ); + } + /** * Serve a single transaction receipt (HTML or PDF via lifterlms-pdfs). * diff --git a/includes/admin/reporting/tables/llms.table.orders.transactions.php b/includes/admin/reporting/tables/llms.table.orders.transactions.php index 34856026fc..2c7772985e 100644 --- a/includes/admin/reporting/tables/llms.table.orders.transactions.php +++ b/includes/admin/reporting/tables/llms.table.orders.transactions.php @@ -153,7 +153,7 @@ protected function get_data( $key, $data ) { $value .= '
'; $value .= '' . esc_html__( 'Receipt', 'lifterlms' ) . ''; if ( $order ) { - $order_url = admin_url( 'post.php?post=' . $order->get( 'id' ) . '&action=edit' ); + $order_url = $this->get_order_url( $order->get( 'id' ), $txn_id ); $value .= ' | ' . esc_html__( 'View Order', 'lifterlms' ) . ''; } $value .= '
'; @@ -161,7 +161,7 @@ protected function get_data( $key, $data ) { case 'order': if ( $order ) { - $value = $this->get_order_link( $order ); + $value = $this->get_order_link( $order, $data->get( 'id' ) ); } break; @@ -260,12 +260,36 @@ protected function get_order_row_data( $key, $order ) { * @param LLMS_Order $order Order object. * @return string */ - protected function get_order_link( $order ) { + protected function get_order_link( $order, $txn_id = 0 ) { $order_id = $order->get( 'id' ); - $url = esc_url( admin_url( 'post.php?post=' . $order_id . '&action=edit' ) ); + $url = esc_url( $this->get_order_url( $order_id, $txn_id ) ); return '#' . $order_id . ''; } + /** + * Build the edit URL for an order. + * + * When a transaction ID is provided, it's appended so the order edit screen can + * surface a "you're viewing the parent order for transaction #x" note with a + * jump link to the transactions list. + * + * @since [version] + * + * @param int $order_id Order post ID. + * @param int $txn_id Optional. Transaction post ID the link originated from. + * @return string + */ + protected function get_order_url( $order_id, $txn_id = 0 ) { + $args = array( + 'post' => $order_id, + 'action' => 'edit', + ); + if ( $txn_id ) { + $args['llms_txn_id'] = $txn_id; + } + return add_query_arg( $args, admin_url( 'post.php' ) ); + } + /** * Build the customer cell HTML: name linked to the user profile, email as a mailto link. * From b16e66c64720fe99a940bef2ff894bbcadf1a662 Mon Sep 17 00:00:00 2001 From: Brian Hogg Date: Tue, 16 Jun 2026 13:02:58 -0400 Subject: [PATCH 07/14] changelog --- .changelogs/combined-orders-transactions-view.yml | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changelogs/combined-orders-transactions-view.yml diff --git a/.changelogs/combined-orders-transactions-view.yml b/.changelogs/combined-orders-transactions-view.yml new file mode 100644 index 0000000000..6bc8f2a91a --- /dev/null +++ b/.changelogs/combined-orders-transactions-view.yml @@ -0,0 +1,5 @@ +significance: minor +type: changed +entry: Orders now lists transactions (and free orders) with a separate + Subscriptions listing, allowing export of transactions and subscriptions to + csv. From 5c28641c742d06b9c0c3651cc74e69e2c46d99a7 Mon Sep 17 00:00:00 2001 From: Brian Hogg Date: Thu, 18 Jun 2026 13:26:55 -0400 Subject: [PATCH 08/14] Formatting. Rename back to Orders & Transactions. Search by transaction ID. --- assets/scss/admin/_llms-table.scss | 16 +++ .../admin/class-llms-admin-page-orders.php | 14 +-- .../tables/llms.table.orders.transactions.php | 106 +++++++++++++----- 3 files changed, 101 insertions(+), 35 deletions(-) diff --git a/assets/scss/admin/_llms-table.scss b/assets/scss/admin/_llms-table.scss index 253b299f1b..41caa47e68 100644 --- a/assets/scss/admin/_llms-table.scss +++ b/assets/scss/admin/_llms-table.scss @@ -225,3 +225,19 @@ text-align: left; } } + +// Orders & Transactions and Subscriptions tables: left-align text, right-align monetary columns. +.llms-gb-table-orders_transactions, +.llms-gb-table-subscriptions { + td, + th { + text-align: left; + } + + td.amount, + th.amount, + td.revenue, + th.revenue { + text-align: right; + } +} diff --git a/includes/admin/class-llms-admin-page-orders.php b/includes/admin/class-llms-admin-page-orders.php index 7dcaa3d6be..b9a14a05ae 100644 --- a/includes/admin/class-llms-admin-page-orders.php +++ b/includes/admin/class-llms-admin-page-orders.php @@ -158,8 +158,8 @@ public function register_pages() { $this->page_hooks[] = add_submenu_page( $parent_slug, - __( 'Orders', 'lifterlms' ), - __( 'Orders', 'lifterlms' ), + __( 'Orders & Transactions', 'lifterlms' ), + __( 'Orders & Transactions', 'lifterlms' ), $capability, 'llms-orders-transactions', array( $this, 'render_orders_transactions_page' ) @@ -200,10 +200,9 @@ public function add_table_resource_pages( $pages ) { public function render_orders_transactions_page() { $table = new LLMS_Table_Orders_Transactions(); $table->get_results(); - // The page renders its own H1, so suppress the table's duplicate title. - $table->set( 'title', '' ); echo '
'; - echo '

' . esc_html__( 'Orders', 'lifterlms' ) . '

'; + // Hidden heading gives admin notices a home; the table renders its own visible title. + echo '

' . esc_html__( 'Orders & Transactions', 'lifterlms' ) . '

'; $table->output_table_html(); echo '
'; } @@ -218,10 +217,9 @@ public function render_orders_transactions_page() { public function render_subscriptions_page() { $table = new LLMS_Table_Subscriptions(); $table->get_results(); - // The page renders its own H1, so suppress the table's duplicate title. - $table->set( 'title', '' ); echo '
'; - echo '

' . esc_html__( 'Subscriptions', 'lifterlms' ) . '

'; + // Hidden heading gives admin notices a home; the table renders its own visible title. + echo '

' . esc_html__( 'Subscriptions', 'lifterlms' ) . '

'; $table->output_table_html(); echo '
'; } diff --git a/includes/admin/reporting/tables/llms.table.orders.transactions.php b/includes/admin/reporting/tables/llms.table.orders.transactions.php index 2c7772985e..05e16fc82d 100644 --- a/includes/admin/reporting/tables/llms.table.orders.transactions.php +++ b/includes/admin/reporting/tables/llms.table.orders.transactions.php @@ -581,7 +581,7 @@ protected function get_order_row_export_data( $key, $order ) { * @return string */ public function get_table_search_form_placeholder() { - return apply_filters( 'llms_table_get_' . $this->id . '_search_placeholder', __( 'Search by order number, customer name, or email...', 'lifterlms' ) ); + return apply_filters( 'llms_table_get_' . $this->id . '_search_placeholder', __( 'Search by order or transaction number, customer name, or email...', 'lifterlms' ) ); } /** @@ -730,38 +730,43 @@ public function get_results( $args = array() ) { // Order IDs that already have at least one transaction. $orders_with_txns = $this->get_order_ids_with_transactions(); - // Build a set of order IDs to restrict to when searching and/or filtering by coupon. - $order_id_sets = array(); - $search = $this->get_search(); - if ( $search ) { - $order_id_sets[] = $this->search_orders( $search ); - } - if ( $this->coupon_filter ) { - $order_id_sets[] = $this->get_order_ids_for_coupon( $this->coupon_filter ); - } + if ( $search || $this->coupon_filter ) { - if ( ! empty( $order_id_sets ) ) { + // Resolve the search term to the post IDs (transactions and/or + // transaction-less orders) that should appear. + $search_post_ids = null; + if ( $search ) { + $search_post_ids = $this->get_search_post_ids( $search, $orders_with_txns ); + if ( empty( $search_post_ids ) ) { + $this->tbody_data = array(); + return; + } + } - // Intersect so combined filters (e.g. coupon + search) narrow the results. - $order_ids = count( $order_id_sets ) > 1 ? array_values( call_user_func_array( 'array_intersect', $order_id_sets ) ) : $order_id_sets[0]; + // Resolve the coupon filter to the same kind of post ID set. + $coupon_post_ids = null; + if ( $this->coupon_filter ) { + $coupon_order_ids = $this->get_order_ids_for_coupon( $this->coupon_filter ); + if ( empty( $coupon_order_ids ) ) { + $this->tbody_data = array(); + return; + } + $coupon_post_ids = array_merge( + $this->get_transaction_ids_for_orders( $coupon_order_ids ), + array_values( array_diff( $coupon_order_ids, $orders_with_txns ) ) + ); + } - if ( empty( $order_ids ) ) { - $this->tbody_data = array(); - return; + // Intersect when both constraints are present so they narrow the results. + if ( ! is_null( $search_post_ids ) && ! is_null( $coupon_post_ids ) ) { + $post_in = array_intersect( $search_post_ids, $coupon_post_ids ); + } else { + $post_in = is_null( $search_post_ids ) ? $coupon_post_ids : $search_post_ids; } - // Restrict to the matching orders' transactions plus the matching orders - // that have no transaction. Using an explicit post__in keeps both post - // types correctly scoped within a single query. - $post_in = array_map( - 'absint', - array_merge( - $this->get_transaction_ids_for_orders( $order_ids ), - array_values( array_diff( $order_ids, $orders_with_txns ) ) - ) - ); + $post_in = array_map( 'absint', (array) $post_in ); if ( empty( $post_in ) ) { $this->tbody_data = array(); @@ -877,6 +882,53 @@ private function get_transaction_ids_for_orders( $order_ids ) { return $query->posts; } + /** + * Resolve a search term to the post IDs (transactions and/or transaction-less + * orders) that should appear in the table. + * + * A numeric term is treated as a post ID and can match either a transaction + * (shows just that transaction) or an order (shows that order's transactions, + * plus the order itself when it has none). A text term matches customers and + * expands to their orders' rows. + * + * @since [version] + * + * @param string $term Search term. + * @param int[] $orders_with_txns Order IDs that have at least one transaction. + * @return int[] Array of matching post IDs. + */ + private function get_search_post_ids( $term, $orders_with_txns ) { + + if ( is_numeric( $term ) ) { + + $id = absint( $term ); + $type = get_post_type( $id ); + + if ( 'llms_transaction' === $type ) { + return array( $id ); + } + + if ( 'llms_order' === $type ) { + return array_merge( + $this->get_transaction_ids_for_orders( array( $id ) ), + in_array( $id, $orders_with_txns, true ) ? array() : array( $id ) + ); + } + + return array(); + } + + $order_ids = $this->search_orders( $term ); + if ( empty( $order_ids ) ) { + return array(); + } + + return array_merge( + $this->get_transaction_ids_for_orders( $order_ids ), + array_values( array_diff( $order_ids, $orders_with_txns ) ) + ); + } + /** * Search orders by number or customer name/email. * @@ -1244,6 +1296,6 @@ protected function set_columns() { * @return string */ protected function set_title() { - return __( 'Orders', 'lifterlms' ); + return __( 'Orders & Transactions', 'lifterlms' ); } } From c422019b9c6a3e8eb7da3f0599c494ae5e43905b Mon Sep 17 00:00:00 2001 From: Brian Hogg Date: Thu, 18 Jun 2026 14:07:03 -0400 Subject: [PATCH 09/14] Fix subscriptions export --- .../admin/reporting/tables/llms.table.subscriptions.php | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/includes/admin/reporting/tables/llms.table.subscriptions.php b/includes/admin/reporting/tables/llms.table.subscriptions.php index 15a5213904..8dd41cae54 100644 --- a/includes/admin/reporting/tables/llms.table.subscriptions.php +++ b/includes/admin/reporting/tables/llms.table.subscriptions.php @@ -250,6 +250,12 @@ public function get_export_data( $key, $data ) { case 'billing_country': return $data->get( 'billing_country' ); + case 'product': + return $data->get( 'product_title' ); + + case 'plan': + return wp_strip_all_tags( $this->get_data( 'plan', $data ) ); + case 'status': return llms_get_order_status_name( $data->get( 'status' ) ); From d39019fdd35086d704b7e782110df2e81a81bd44 Mon Sep 17 00:00:00 2001 From: Brian Hogg Date: Thu, 18 Jun 2026 15:09:15 -0400 Subject: [PATCH 10/14] Transaction+order view in Order History, new "My Subscriptions" dashboard endpoint if any subscription orders. --- .../student-subscriptions-and-receipts.yml | 7 + .../settings/class.llms.settings.accounts.php | 8 + includes/class.llms.student.dashboard.php | 225 +++++++++++++++++- .../class.llms.controller.account.php | 63 +++++ includes/functions/llms.functions.order.php | 29 +++ includes/models/model.llms.student.php | 71 ++++++ templates/myaccount/my-subscriptions.php | 72 ++++++ templates/myaccount/my-transactions.php | 101 ++++++++ templates/myaccount/receipt-transaction.php | 139 +++++++++++ .../myaccount/view-order-transactions.php | 4 + 10 files changed, 712 insertions(+), 7 deletions(-) create mode 100644 .changelogs/student-subscriptions-and-receipts.yml create mode 100644 templates/myaccount/my-subscriptions.php create mode 100644 templates/myaccount/my-transactions.php create mode 100644 templates/myaccount/receipt-transaction.php diff --git a/.changelogs/student-subscriptions-and-receipts.yml b/.changelogs/student-subscriptions-and-receipts.yml new file mode 100644 index 0000000000..951313503c --- /dev/null +++ b/.changelogs/student-subscriptions-and-receipts.yml @@ -0,0 +1,7 @@ +significance: minor +type: added +entry: Added a "My Subscriptions" student dashboard tab (shown only when the student + has at least one subscription) for managing recurring orders, converted the Order + History endpoint to list individual transactions and transaction-less orders, and + added per-transaction receipt downloads (PDF via the LifterLMS PDFs add-on, HTML + otherwise). diff --git a/includes/admin/settings/class.llms.settings.accounts.php b/includes/admin/settings/class.llms.settings.accounts.php index 849b3fd47b..b66508ccdb 100644 --- a/includes/admin/settings/class.llms.settings.accounts.php +++ b/includes/admin/settings/class.llms.settings.accounts.php @@ -221,6 +221,14 @@ public function get_settings() { 'default' => 'orders', 'sanitize' => 'slug', ), + array( + 'title' => __( 'Subscriptions', 'lifterlms' ), + 'desc' => __( 'Students can review and manage their subscriptions on this page', 'lifterlms' ), + 'id' => 'lifterlms_myaccount_subscriptions_endpoint', + 'type' => 'text', + 'default' => 'subscriptions', + 'sanitize' => 'slug', + ), array( 'id' => 'course_account_endpoint_options_end', 'type' => 'sectionend', diff --git a/includes/class.llms.student.dashboard.php b/includes/class.llms.student.dashboard.php index aa9aad3acb..71e995ab20 100644 --- a/includes/class.llms.student.dashboard.php +++ b/includes/class.llms.student.dashboard.php @@ -30,9 +30,63 @@ public function __construct() { add_filter( 'llms_get_endpoints', array( $this, 'add_endpoints' ) ); add_filter( 'lifterlms_student_dashboard_title', array( $this, 'modify_dashboard_title' ), 5 ); add_filter( 'rewrite_rules_array', array( $this, 'modify_rewrite_rules_order' ) ); + add_filter( 'llms_get_student_dashboard_tabs_for_nav', array( $this, 'maybe_hide_subscriptions_nav' ) ); } + /** + * Hide the "My Subscriptions" navigation item when the current student has no subscriptions. + * + * The endpoint itself remains registered (and accessible by direct URL); it is only + * removed from the dashboard navigation when the student has zero recurring orders. + * + * @since [version] + * + * @param array $tabs Navigation tabs from {@see LLMS_Student_Dashboard::get_tabs_for_nav()}. + * @return array + */ + public function maybe_hide_subscriptions_nav( $tabs ) { + + if ( ! isset( $tabs['subscriptions'] ) ) { + return $tabs; + } + + if ( ! self::current_student_has_subscriptions() ) { + unset( $tabs['subscriptions'] ); + } + + return $tabs; + } + + /** + * Determine whether the currently logged in student has at least one subscription. + * + * Result is cached for the duration of the request to avoid repeated queries. + * + * @since [version] + * + * @return bool + */ + private static function current_student_has_subscriptions() { + + static $has_subscriptions = null; + + if ( null !== $has_subscriptions ) { + return $has_subscriptions; + } + + $has_subscriptions = false; + + $user_id = get_current_user_id(); + if ( $user_id ) { + $student = new LLMS_Student( $user_id ); + $subscriptions = $student->get_subscriptions( array( 'count' => 1 ) ); + $has_subscriptions = ! empty( $subscriptions['count'] ); + } + + return $has_subscriptions; + } + /** * Add endpoints to the LLMS_Query class to be automatically registered * @@ -213,6 +267,12 @@ public static function get_tabs() { 'nav_item' => true, 'title' => __( 'Order History', 'lifterlms' ), ), + 'subscriptions' => array( + 'content' => array( __CLASS__, 'output_subscriptions_content' ), + 'endpoint' => get_option( 'lifterlms_myaccount_subscriptions_endpoint', 'subscriptions' ), + 'nav_item' => true, + 'title' => __( 'My Subscriptions', 'lifterlms' ), + ), 'signout' => array( 'endpoint' => false, 'title' => __( 'Sign Out', 'lifterlms' ), @@ -374,23 +434,174 @@ public static function output_orders_content() { $order = llms_get_post( $wp->query_vars['orders'] ); llms_template_view_order( $order ); + return; - } else { + } + + $student = new LLMS_Student(); + + /** + * Filters whether the Order History endpoint lists individual transactions (and + * transaction-less orders) or the legacy list of orders. + * + * When `true` (default) the transaction + order list (`my-transactions.php`) is + * rendered. Return `false` to restore the legacy order list (`my-orders.php`). + * + * @since [version] + * + * @param bool $use_transaction_view Whether to use the transaction list view. + */ + if ( apply_filters( 'llms_student_dashboard_orders_use_transaction_view', true ) ) { - $student = new LLMS_Student(); llms_get_template( - 'myaccount/my-orders.php', + 'myaccount/my-transactions.php', array( - 'orders' => $student->get_orders( - array( - 'page' => isset( $_GET['opage'] ) ? intval( $_GET['opage'] ) : 1, - ) + 'transactions' => self::get_transactions_list( + isset( $_GET['txlpage'] ) ? intval( $_GET['txlpage'] ) : 1 ), ) ); + return; } + llms_get_template( + 'myaccount/my-orders.php', + array( + 'orders' => $student->get_orders( + array( + 'page' => isset( $_GET['opage'] ) ? intval( $_GET['opage'] ) : 1, + ) + ), + ) + ); + } + + /** + * Assemble a paginated list of the current student's transactions and transaction-less orders. + * + * Each row is either an {@see LLMS_Transaction} (for orders that have transactions) or an + * {@see LLMS_Order} (for orders without any transactions, e.g. free enrollments or pending + * payment orders). Rows are sorted by date, newest first. + * + * @since [version] + * + * @param int $page Page number. Default `1`. + * @param int $per_page Number of rows per page. Default `25`. + * @return array { + * @type int $count Number of rows on the current page. + * @type int $page Current page number. + * @type int $pages Total number of pages. + * @type array $rows Array of {@see LLMS_Transaction} and/or {@see LLMS_Order} objects. + * } + */ + private static function get_transactions_list( $page = 1, $per_page = 25 ) { + + $empty = array( + 'count' => 0, + 'page' => max( 1, $page ), + 'pages' => 0, + 'rows' => array(), + ); + + $user_id = get_current_user_id(); + if ( ! $user_id ) { + return $empty; + } + + // All of the student's order IDs. + $order_ids = get_posts( + array( + 'fields' => 'ids', + 'meta_key' => '_llms_user_id', + 'meta_value' => $user_id, + 'post_status' => array_keys( llms_get_order_statuses() ), + 'post_type' => 'llms_order', + 'posts_per_page' => -1, + ) + ); + + if ( ! $order_ids ) { + return $empty; + } + + // Transactions belonging to those orders. + $transaction_posts = get_posts( + array( + 'meta_query' => array( + array( + 'compare' => 'IN', + 'key' => '_llms_order_id', + 'value' => $order_ids, + ), + ), + 'order' => 'DESC', + 'orderby' => 'date', + 'post_status' => 'any', + 'post_type' => 'llms_transaction', + 'posts_per_page' => -1, + ) + ); + + $rows = array(); + $orders_with_txns = array(); + + foreach ( $transaction_posts as $post ) { + $order_id = (int) get_post_meta( $post->ID, '_llms_order_id', true ); + + $rows[] = new LLMS_Transaction( $post ); + $orders_with_txns[ $order_id ] = true; + } + + // Include orders that have no transactions (e.g. free, trial, or pending payment orders). + foreach ( $order_ids as $order_id ) { + if ( ! isset( $orders_with_txns[ (int) $order_id ] ) ) { + $rows[] = new LLMS_Order( $order_id ); + } + } + + // Sort all rows by their post date, newest first. + usort( + $rows, + function ( $a, $b ) { + return strcmp( $b->get( 'date' ), $a->get( 'date' ) ); + } + ); + + $total = count( $rows ); + $pages = (int) ceil( $total / $per_page ); + $page = max( 1, min( $page, max( 1, $pages ) ) ); + $rows = array_slice( $rows, ( $page - 1 ) * $per_page, $per_page ); + + return array( + 'count' => count( $rows ), + 'page' => $page, + 'pages' => $pages, + 'rows' => $rows, + ); + } + + /** + * Endpoint to output the student's subscriptions (recurring orders). + * + * @since [version] + * + * @return void + */ + public static function output_subscriptions_content() { + + $student = new LLMS_Student(); + + llms_get_template( + 'myaccount/my-subscriptions.php', + array( + 'subscriptions' => $student->get_subscriptions( + array( + 'page' => isset( $_GET['subspage'] ) ? intval( $_GET['subspage'] ) : 1, + ) + ), + ) + ); } /** diff --git a/includes/forms/controllers/class.llms.controller.account.php b/includes/forms/controllers/class.llms.controller.account.php index 56ca9bf0de..03fceb439b 100644 --- a/includes/forms/controllers/class.llms.controller.account.php +++ b/includes/forms/controllers/class.llms.controller.account.php @@ -37,6 +37,69 @@ public function __construct() { add_action( 'init', array( $this, 'reset_password' ) ); add_action( 'init', array( $this, 'cancel_subscription' ) ); add_action( 'init', array( $this, 'redeem_voucher' ) ); + add_action( 'template_redirect', array( $this, 'maybe_serve_transaction_receipt' ) ); + } + + /** + * Serve a single transaction receipt to the student who owns it. + * + * Triggered on the front-end via URL parameter: ?llms_receipt_txn={transaction_id}&_wpnonce={nonce} + * (see {@see llms_get_transaction_receipt_url()}). Generates a PDF when the LifterLMS PDFs + * add-on is active (via the `llms_serve_transaction_receipt` action) or an HTML receipt otherwise. + * + * @since [version] + * + * @return void + */ + public function maybe_serve_transaction_receipt() { + + $txn_id = absint( llms_filter_input( INPUT_GET, 'llms_receipt_txn', FILTER_SANITIZE_NUMBER_INT ) ); + if ( ! $txn_id ) { + return; + } + + $nonce = llms_filter_input( INPUT_GET, '_wpnonce' ); + if ( ! wp_verify_nonce( $nonce, 'llms_txn_receipt_' . $txn_id ) ) { + wp_die( esc_html__( 'Invalid request.', 'lifterlms' ) ); + } + + $transaction = llms_get_post( $txn_id ); + if ( ! $transaction instanceof LLMS_Transaction ) { + wp_die( esc_html__( 'Transaction not found.', 'lifterlms' ) ); + } + + $order = llms_get_post( $transaction->get( 'order_id' ) ); + if ( ! $order instanceof LLMS_Order ) { + wp_die( esc_html__( 'Order not found.', 'lifterlms' ) ); + } + + // Only the student who owns the order (or a user who can view reports) may download the receipt. + if ( get_current_user_id() !== (int) $order->get( 'user_id' ) && ! current_user_can( 'view_lifterlms_reports' ) ) { + wp_die( esc_html__( 'You do not have permission to view this receipt.', 'lifterlms' ) ); + } + + /** + * Allow the LifterLMS PDFs add-on to handle single-transaction PDF generation. + * + * If a plugin hooks in and handles this action (e.g. generates a PDF), it should + * call exit() to prevent the HTML fallback from rendering. + * + * @since [version] + * + * @param LLMS_Transaction $transaction The transaction object. + * @param LLMS_Order $order The parent order object. + */ + do_action( 'llms_serve_transaction_receipt', $transaction, $order ); + + // HTML fallback: render the printable receipt template. + llms_get_template( + 'myaccount/receipt-transaction.php', + array( + 'transaction' => $transaction, + 'order' => $order, + ) + ); + exit; } /** diff --git a/includes/functions/llms.functions.order.php b/includes/functions/llms.functions.order.php index 45b16c4f13..60bb230d0f 100644 --- a/includes/functions/llms.functions.order.php +++ b/includes/functions/llms.functions.order.php @@ -143,6 +143,35 @@ function llms_get_order_status_name( $status ) { return apply_filters( 'lifterlms_get_order_status_name', $status ); } +/** + * Retrieve the front-end (student dashboard) URL for downloading a single transaction receipt. + * + * The resulting URL is served by {@see LLMS_Controller_Account::maybe_serve_transaction_receipt()} + * which generates a PDF (when the LifterLMS PDFs add-on is active) or an HTML receipt otherwise. + * + * @since [version] + * + * @param int $txn_id Transaction post ID. + * @return string + */ +function llms_get_transaction_receipt_url( $txn_id ) { + + $url = wp_nonce_url( + add_query_arg( 'llms_receipt_txn', $txn_id, llms_get_page_url( 'myaccount' ) ), + 'llms_txn_receipt_' . $txn_id + ); + + /** + * Filters the front-end transaction receipt download URL. + * + * @since [version] + * + * @param string $url The receipt URL. + * @param int $txn_id Transaction post ID. + */ + return apply_filters( 'llms_transaction_receipt_url', $url, $txn_id ); +} + /** * Retrieve an array of registered and available LifterLMS Order Post Statuses. * diff --git a/includes/models/model.llms.student.php b/includes/models/model.llms.student.php index 364aed3b96..8ef47067f2 100644 --- a/includes/models/model.llms.student.php +++ b/includes/models/model.llms.student.php @@ -1020,6 +1020,77 @@ public function get_orders( $params = array() ) { ); } + /** + * Retrieve the student's recurring orders (subscriptions). + * + * Mirrors {@see LLMS_Student::get_orders()} but restricts results to recurring + * orders via the `_llms_order_type` meta and to recurring-specific statuses. + * + * @since [version] + * + * @param array $params { + * Optional. Query arguments. + * + * @type int $count Number of orders per page. Default `25`. + * @type int $page Current page number. Default `1`. + * @type string[] $statuses Array of order post statuses to include. Defaults to all recurring statuses. + * } + * @return array { + * @type int $count Number of orders on the current page. + * @type int $page Current page number. + * @type int $pages Total number of pages. + * @type LLMS_Order[] $orders Array of order objects keyed by post ID. + * } + */ + public function get_subscriptions( $params = array() ) { + + $params = wp_parse_args( + $params, + array( + 'count' => 25, + 'page' => 1, + 'statuses' => array_keys( llms_get_order_statuses( 'recurring' ) ), + ) + ); + + $q = new WP_Query( + array( + 'order' => 'DESC', + 'orderby' => 'date', + 'meta_query' => array( + 'relation' => 'AND', + array( + 'key' => '_llms_user_id', + 'value' => $this->get_id(), + ), + array( + 'key' => '_llms_order_type', + 'value' => 'recurring', + ), + ), + 'paged' => $params['page'], + 'posts_per_page' => $params['count'], + 'post_status' => $params['statuses'], + 'post_type' => 'llms_order', + ) + ); + + $orders = array(); + + if ( $q->have_posts() ) { + foreach ( $q->posts as $post ) { + $orders[ $post->ID ] = new LLMS_Order( $post ); + } + } + + return array( + 'count' => count( $q->posts ), + 'page' => $params['page'], + 'pages' => $q->max_num_pages, + 'orders' => $orders, + ); + } + /** * Get students progress through a course or track * diff --git a/templates/myaccount/my-subscriptions.php b/templates/myaccount/my-subscriptions.php new file mode 100644 index 0000000000..8007b65666 --- /dev/null +++ b/templates/myaccount/my-subscriptions.php @@ -0,0 +1,72 @@ + + +
+ + +

+ + + + + + + + + + + + + + + + + + + + + + + +
+ #get( 'id' ) ); ?> + get( 'product_title' ) ); ?> + get_status_name() ); ?> + + has_scheduled_payment() ) : ?> + get_next_payment_due_date( 'F j, Y' ) ); ?> + + – + + + +
+ +
+ 1 ) : ?> + + + + + + +
+ + +
diff --git a/templates/myaccount/my-transactions.php b/templates/myaccount/my-transactions.php new file mode 100644 index 0000000000..ddaf8fea57 --- /dev/null +++ b/templates/myaccount/my-transactions.php @@ -0,0 +1,101 @@ + + +
+ + +

+ + + + + + + + + + + + + + get( 'order_id' ) ); + $row_id = $row->get( 'id' ); + /* translators: %d: transaction ID */ + $row_label = sprintf( __( 'Transaction #%d', 'lifterlms' ), $row_id ); + $status = $row->get( 'status' ); + $status_obj = get_post_status_object( $status ); + $status_lbl = $status_obj ? $status_obj->label : $status; + $amount = $row->get_price( 'amount' ); + $date = $row->get_date( 'date', 'F j, Y' ); + } else { + $order = $row; + $row_id = $row->get( 'id' ); + /* translators: %d: order ID */ + $row_label = sprintf( __( 'Order #%d', 'lifterlms' ), $row_id ); + $status = $row->get( 'status' ); + $status_lbl = $row->get_status_name(); + $amount = $row->get_price( 'total' ); + $date = $row->get_date( 'date', 'F j, Y' ); + } + + if ( ! $order instanceof LLMS_Order ) { + continue; + } + ?> + + + + + + + + + +
+ + + +
+ get( 'id' ) ); + ?> + + +
get( 'product_title' ) ); ?> + +
+ + 1 ) : ?> +
+ 1 ) : ?> + + + + + + +
+ + + +
diff --git a/templates/myaccount/receipt-transaction.php b/templates/myaccount/receipt-transaction.php new file mode 100644 index 0000000000..608de2476d --- /dev/null +++ b/templates/myaccount/receipt-transaction.php @@ -0,0 +1,139 @@ + + +> + + + + + <?php + /* translators: %d: Transaction ID */ + printf( esc_html__( 'Receipt - Transaction #%d', 'lifterlms' ), (int) $transaction->get( 'id' ) ); + ?> + + + + +
+
+

+ get( 'id' ) ); + ?> +

+

+
+
+ +
+
+ +

+ + + + + + + + + + + + + + + + + + + + + + + +
#get( 'id' ) ); ?>
get_date( 'date', get_option( 'date_format' ) . ' ' . get_option( 'time_format' ) ) ); ?>
get( 'amount' ) ), LLMS_ALLOWED_HTML_PRICES ); ?>
+ get( 'status' ); + $status_obj = get_post_status_object( $status ); + ?> + + label : $status ); ?> + +
get( 'gateway_source_description' ) ); ?>
+ +

+ + + + + + + + + + + +
#get( 'id' ) ); ?>
get( 'product_title' ) ); ?>
+ +

+ + + + + + + + + + + get( 'billing_address_1' ) ) : ?> + + + + + + +
get_customer_name() ); ?>
get( 'billing_email' ) ); ?>
+ get( 'billing_address_1' ) ); ?>
+ get( 'billing_address_2' ) ) : ?> + get( 'billing_address_2' ) ); ?>
+ + get( 'billing_city' ) ); ?>, + get( 'billing_state' ) ); ?> + get( 'billing_zip' ) ); ?>
+ get( 'billing_country' ) ) ); ?> +
+ + diff --git a/templates/myaccount/view-order-transactions.php b/templates/myaccount/view-order-transactions.php index 6679379f1a..40312fca1c 100644 --- a/templates/myaccount/view-order-transactions.php +++ b/templates/myaccount/view-order-transactions.php @@ -21,6 +21,7 @@ + @@ -41,6 +42,9 @@ get( 'gateway_source_description' ) ); ?> + + + From 81e6e2d5ab66c1ff8b32fe753f6a76d0fa752926 Mon Sep 17 00:00:00 2001 From: Brian Hogg Date: Fri, 19 Jun 2026 11:02:05 -0400 Subject: [PATCH 11/14] Fixing test. Cache per student in case there's more than one per request. --- includes/class.llms.student.dashboard.php | 11 +- .../class-llms-test-settings-accounts.php | 4 + .../class-llms-test-student-dashboard.php | 104 ++++++++++++++++++ 3 files changed, 115 insertions(+), 4 deletions(-) create mode 100644 tests/phpunit/unit-tests/class-llms-test-student-dashboard.php diff --git a/includes/class.llms.student.dashboard.php b/includes/class.llms.student.dashboard.php index 71e995ab20..a654f9bb38 100644 --- a/includes/class.llms.student.dashboard.php +++ b/includes/class.llms.student.dashboard.php @@ -69,21 +69,24 @@ public function maybe_hide_subscriptions_nav( $tabs ) { */ private static function current_student_has_subscriptions() { - static $has_subscriptions = null; + static $cache = array(); - if ( null !== $has_subscriptions ) { - return $has_subscriptions; + $user_id = get_current_user_id(); + + if ( isset( $cache[ $user_id ] ) ) { + return $cache[ $user_id ]; } $has_subscriptions = false; - $user_id = get_current_user_id(); if ( $user_id ) { $student = new LLMS_Student( $user_id ); $subscriptions = $student->get_subscriptions( array( 'count' => 1 ) ); $has_subscriptions = ! empty( $subscriptions['count'] ); } + $cache[ $user_id ] = $has_subscriptions; + return $has_subscriptions; } diff --git a/tests/phpunit/unit-tests/admin/settings/class-llms-test-settings-accounts.php b/tests/phpunit/unit-tests/admin/settings/class-llms-test-settings-accounts.php index 79d8cee9fb..d58ed7a44e 100644 --- a/tests/phpunit/unit-tests/admin/settings/class-llms-test-settings-accounts.php +++ b/tests/phpunit/unit-tests/admin/settings/class-llms-test-settings-accounts.php @@ -112,6 +112,10 @@ protected function get_mock_settings() { 'orders', 'custom-order-history', ), + 'lifterlms_myaccount_subscriptions_endpoint' => array( + 'subscriptions', + 'custom-subscriptions', + ), 'lifterlms_registration_require_agree_to_terms' => array( 'yes', ), diff --git a/tests/phpunit/unit-tests/class-llms-test-student-dashboard.php b/tests/phpunit/unit-tests/class-llms-test-student-dashboard.php new file mode 100644 index 0000000000..66f9d5a0f7 --- /dev/null +++ b/tests/phpunit/unit-tests/class-llms-test-student-dashboard.php @@ -0,0 +1,104 @@ +get_mock_plan( 25.99, $recurring ? 1 : 0 ); + return $this->get_mock_order( $plan, false, $student ); + } + + /** + * Test LLMS_Student::get_subscriptions() returns only recurring orders. + * + * @since [version] + * + * @return void + */ + public function test_get_subscriptions_returns_only_recurring_orders() { + + $student = $this->get_mock_student(); + + $recurring = $this->create_order_for_student( $student, true ); + $single = $this->create_order_for_student( $student, false ); + + $subscriptions = $student->get_subscriptions(); + + $this->assertEquals( 1, $subscriptions['count'] ); + $this->assertArrayHasKey( $recurring->get( 'id' ), $subscriptions['orders'] ); + $this->assertArrayNotHasKey( $single->get( 'id' ), $subscriptions['orders'] ); + } + + /** + * Test that the "My Subscriptions" nav item is hidden when the student has no subscriptions. + * + * A student with only a one-time (non-recurring) order should not see the tab. + * + * @since [version] + * + * @return void + */ + public function test_subscriptions_nav_hidden_without_subscription() { + + $student = $this->get_mock_student(); + $this->create_order_for_student( $student, false ); + + wp_set_current_user( $student->get( 'id' ) ); + + $tabs = LLMS_Student_Dashboard::get_tabs_for_nav(); + + $this->assertArrayNotHasKey( 'subscriptions', $tabs ); + } + + /** + * Test that the "My Subscriptions" nav item is visible when the student has a subscription. + * + * @since [version] + * + * @return void + */ + public function test_subscriptions_nav_visible_with_subscription() { + + $student = $this->get_mock_student(); + $this->create_order_for_student( $student, true ); + + wp_set_current_user( $student->get( 'id' ) ); + + $tabs = LLMS_Student_Dashboard::get_tabs_for_nav(); + + $this->assertArrayHasKey( 'subscriptions', $tabs ); + } + + /** + * Test that the "My Subscriptions" endpoint is always registered (reachable by direct URL). + * + * @since [version] + * + * @return void + */ + public function test_subscriptions_endpoint_is_registered() { + + $dashboard = new LLMS_Student_Dashboard(); + $endpoints = $dashboard->get_endpoints(); + + $this->assertArrayHasKey( 'subscriptions', $endpoints ); + $this->assertEquals( 'subscriptions', $endpoints['subscriptions'] ); + } + +} From 7ccb1496997adf9783cd36db9830e95101c2cf46 Mon Sep 17 00:00:00 2001 From: Brian Hogg Date: Fri, 19 Jun 2026 11:05:58 -0400 Subject: [PATCH 12/14] Edit changelog (too long). --- .changelogs/student-subscriptions-and-receipts.yml | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/.changelogs/student-subscriptions-and-receipts.yml b/.changelogs/student-subscriptions-and-receipts.yml index 951313503c..82731ad042 100644 --- a/.changelogs/student-subscriptions-and-receipts.yml +++ b/.changelogs/student-subscriptions-and-receipts.yml @@ -1,7 +1,3 @@ significance: minor type: added -entry: Added a "My Subscriptions" student dashboard tab (shown only when the student - has at least one subscription) for managing recurring orders, converted the Order - History endpoint to list individual transactions and transaction-less orders, and - added per-transaction receipt downloads (PDF via the LifterLMS PDFs add-on, HTML - otherwise). +entry: Adds a "My Subscriptions" student dashboard tab and per-transaction receipt downloads. From e73cfb12bb16253cead3946d5a930a2b210f93f2 Mon Sep 17 00:00:00 2001 From: Brian Hogg Date: Fri, 19 Jun 2026 15:28:47 -0400 Subject: [PATCH 13/14] Migration to avoid large post__not_in query --- ...ders-transactions-has-transaction-flag.yml | 5 + .../tables/llms.table.orders.transactions.php | 94 ++++++++++-- .../class.llms.controller.orders.php | 49 ++++++ .../updates/llms-functions-updates-1010.php | 88 +++++++++++ includes/models/model.llms.order.php | 9 ++ includes/schemas/llms-db-updates.php | 8 + ...class-llms-test-functions-updates-1010.php | 145 ++++++++++++++++++ .../class-llms-test-model-llms-order.php | 65 ++++++++ 8 files changed, 449 insertions(+), 14 deletions(-) create mode 100644 .changelogs/orders-transactions-has-transaction-flag.yml create mode 100644 includes/functions/updates/llms-functions-updates-1010.php create mode 100644 tests/phpunit/unit-tests/functions/updates/class-llms-test-functions-updates-1010.php diff --git a/.changelogs/orders-transactions-has-transaction-flag.yml b/.changelogs/orders-transactions-has-transaction-flag.yml new file mode 100644 index 0000000000..de2b8aab1d --- /dev/null +++ b/.changelogs/orders-transactions-has-transaction-flag.yml @@ -0,0 +1,5 @@ +significance: patch +type: performance +entry: Replaced the unbounded order exclusion list in the Orders & Transactions + report with an indexed `_llms_has_transaction` order flag, backfilled via a + batched background migration, for better performance on large stores. diff --git a/includes/admin/reporting/tables/llms.table.orders.transactions.php b/includes/admin/reporting/tables/llms.table.orders.transactions.php index 05e16fc82d..f98a18cb48 100644 --- a/includes/admin/reporting/tables/llms.table.orders.transactions.php +++ b/includes/admin/reporting/tables/llms.table.orders.transactions.php @@ -727,9 +727,6 @@ public function get_results( $args = array() ) { ); } - // Order IDs that already have at least one transaction. - $orders_with_txns = $this->get_order_ids_with_transactions(); - $search = $this->get_search(); if ( $search || $this->coupon_filter ) { @@ -738,7 +735,7 @@ public function get_results( $args = array() ) { // transaction-less orders) that should appear. $search_post_ids = null; if ( $search ) { - $search_post_ids = $this->get_search_post_ids( $search, $orders_with_txns ); + $search_post_ids = $this->get_search_post_ids( $search ); if ( empty( $search_post_ids ) ) { $this->tbody_data = array(); return; @@ -755,7 +752,7 @@ public function get_results( $args = array() ) { } $coupon_post_ids = array_merge( $this->get_transaction_ids_for_orders( $coupon_order_ids ), - array_values( array_diff( $coupon_order_ids, $orders_with_txns ) ) + $this->get_orders_without_transactions( $coupon_order_ids ) ); } @@ -775,11 +772,27 @@ public function get_results( $args = array() ) { $query_args['post__in'] = $post_in; - } elseif ( ! empty( $orders_with_txns ) ) { + } elseif ( $this->is_has_transaction_backfilled() ) { + + // Backfill complete: every order with a transaction carries the + // `_llms_has_transaction` flag, so an indexed `NOT EXISTS` lookup excludes + // them (transactions and transaction-less orders both pass) without + // materializing a large `post__not_in` list. + $query_args['meta_query'] = array( + array( + 'key' => '_llms_has_transaction', + 'compare' => 'NOT EXISTS', + ), + ); + + } else { - // No search/coupon filter: exclude order posts that are already represented - // by their transaction rows. - $query_args['post__not_in'] = array_map( 'absint', $orders_with_txns ); + // Backfill still pending: fall back to the explicit exclusion list so + // not-yet-flagged legacy orders don't double-list as their own order row. + $orders_with_txns = $this->get_order_ids_with_transactions(); + if ( ! empty( $orders_with_txns ) ) { + $query_args['post__not_in'] = array_map( 'absint', $orders_with_txns ); + } } if ( $this->sort_mode ) { @@ -893,11 +906,10 @@ private function get_transaction_ids_for_orders( $order_ids ) { * * @since [version] * - * @param string $term Search term. - * @param int[] $orders_with_txns Order IDs that have at least one transaction. + * @param string $term Search term. * @return int[] Array of matching post IDs. */ - private function get_search_post_ids( $term, $orders_with_txns ) { + private function get_search_post_ids( $term ) { if ( is_numeric( $term ) ) { @@ -911,7 +923,7 @@ private function get_search_post_ids( $term, $orders_with_txns ) { if ( 'llms_order' === $type ) { return array_merge( $this->get_transaction_ids_for_orders( array( $id ) ), - in_array( $id, $orders_with_txns, true ) ? array() : array( $id ) + $this->get_orders_without_transactions( array( $id ) ) ); } @@ -925,10 +937,64 @@ private function get_search_post_ids( $term, $orders_with_txns ) { return array_merge( $this->get_transaction_ids_for_orders( $order_ids ), - array_values( array_diff( $order_ids, $orders_with_txns ) ) + $this->get_orders_without_transactions( $order_ids ) ); } + /** + * Given a bounded set of order IDs, return those that have NO transaction. + * + * Used by the search/coupon paths to decide which matched orders should appear + * as their own (transaction-less) row. Detection is done directly against + * `_llms_order_id` on the bounded set rather than the global flag, so it stays + * correct regardless of `_llms_has_transaction` backfill state. + * + * @since [version] + * + * @param int[] $order_ids Bounded set of order IDs to test. + * @return int[] Subset of `$order_ids` that have no transaction. + */ + private function get_orders_without_transactions( $order_ids ) { + + $order_ids = array_values( array_filter( array_map( 'absint', (array) $order_ids ) ) ); + + if ( empty( $order_ids ) ) { + return array(); + } + + // Map the bounded set's transactions back to their parent order to determine + // which of the matched orders actually have a transaction. + $with_txns = array(); + foreach ( $this->get_transaction_ids_for_orders( $order_ids ) as $txn_id ) { + $with_txns[] = absint( get_post_meta( $txn_id, '_llms_order_id', true ) ); + } + + return array_values( array_diff( $order_ids, $with_txns ) ); + } + + /** + * Whether the `_llms_has_transaction` backfill migration has completed. + * + * Gates the indexed `NOT EXISTS` query path. Until the backfill finishes, legacy + * orders may lack the flag and would double-list, so the explicit `post__not_in` + * fallback is used instead. New installs report the current db version and use the + * flag path immediately. + * + * @since [version] + * + * @return bool + */ + private function is_has_transaction_backfilled() { + + static $backfilled = null; + + if ( null === $backfilled ) { + $backfilled = version_compare( get_option( 'lifterlms_db_version' ), '10.1.0', '>=' ); + } + + return $backfilled; + } + /** * Search orders by number or customer name/email. * diff --git a/includes/controllers/class.llms.controller.orders.php b/includes/controllers/class.llms.controller.orders.php index 601f8a87e4..e1dee65328 100644 --- a/includes/controllers/class.llms.controller.orders.php +++ b/includes/controllers/class.llms.controller.orders.php @@ -55,6 +55,9 @@ public function __construct() { add_action( 'save_post_llms_transaction', array( $this, 'clear_orders_transactions_report_cache' ) ); add_action( 'before_delete_post', array( $this, 'maybe_clear_orders_transactions_report_cache' ) ); + // Clear the order's `_llms_has_transaction` flag when its last transaction is deleted. + add_action( 'before_delete_post', array( $this, 'maybe_clear_order_has_transaction_flag' ) ); + // Transaction status changes cascade up to the order to change the order status. add_action( 'lifterlms_transaction_status_failed', array( $this, 'transaction_failed' ), 10, 1 ); add_action( 'lifterlms_transaction_status_refunded', array( $this, 'transaction_refunded' ), 10, 1 ); @@ -270,6 +273,52 @@ public function maybe_clear_orders_transactions_report_cache( $post_id ) { } } + /** + * Clear an order's `_llms_has_transaction` flag when its last transaction is deleted. + * + * Keeps the Orders & Transactions report accurate: an order whose only transaction(s) + * are deleted should reappear as a transaction-less order row. + * + * @since [version] + * + * @param int $post_id WP_Post ID of the post being deleted. + * @return void + */ + public function maybe_clear_order_has_transaction_flag( $post_id ) { + + if ( 'llms_transaction' !== get_post_type( $post_id ) ) { + return; + } + + $order_id = absint( get_post_meta( $post_id, '_llms_order_id', true ) ); + if ( ! $order_id ) { + return; + } + + // `before_delete_post` fires before the post is removed, so exclude the + // transaction being deleted when checking for remaining transactions. + $remaining = new WP_Query( + array( + 'post_type' => 'llms_transaction', + 'post_status' => 'any', + 'fields' => 'ids', + 'posts_per_page' => 1, + 'no_found_rows' => true, + 'post__not_in' => array( $post_id ), + 'meta_query' => array( + array( + 'key' => '_llms_order_id', + 'value' => $order_id, + ), + ), + ) + ); + + if ( empty( $remaining->posts ) ) { + delete_post_meta( $order_id, '_llms_has_transaction' ); + } + } + /** * Called when an user enrollment is deleted. * diff --git a/includes/functions/updates/llms-functions-updates-1010.php b/includes/functions/updates/llms-functions-updates-1010.php new file mode 100644 index 0000000000..0c880597e2 --- /dev/null +++ b/includes/functions/updates/llms-functions-updates-1010.php @@ -0,0 +1,88 @@ +get_col( + $wpdb->prepare( + " + SELECT DISTINCT txn.meta_value + FROM {$wpdb->postmeta} AS txn + WHERE txn.meta_key = '_llms_order_id' + AND txn.meta_value <> '' + AND NOT EXISTS ( + SELECT 1 FROM {$wpdb->postmeta} AS flag + WHERE flag.post_id = txn.meta_value + AND flag.meta_key = '_llms_has_transaction' + ) + LIMIT %d + ", + $per_page + ) + );// db call ok; no-cache ok. + + if ( empty( $order_ids ) ) { + return false; + } + + foreach ( $order_ids as $order_id ) { + \update_post_meta( (int) $order_id, '_llms_has_transaction', 'yes' ); + } + + // If a full page was processed, assume there might be more. + return count( $order_ids ) === $per_page; +} + +/** + * Update db version to 10.1.0. + * + * @since [version] + * + * @return false + */ +function update_db_version() { + \LLMS_Install::update_db_version( _get_db_version() ); + return false; +} diff --git a/includes/models/model.llms.order.php b/includes/models/model.llms.order.php index b3875edd4b..756032da1a 100644 --- a/includes/models/model.llms.order.php +++ b/includes/models/model.llms.order.php @@ -152,6 +152,7 @@ class LLMS_Order extends LLMS_Post_Model { 'coupon_type' => 'text', 'coupon_used' => 'text', 'currency' => 'text', + 'has_transaction' => 'yesno', 'on_sale' => 'text', 'order_key' => 'text', 'order_type' => 'text', @@ -1826,6 +1827,14 @@ public function record_transaction( $data = array() ) { $txn->set( 'payment_type', $payment_type ); $txn->set( 'status', $status ); + // Flag the order as having at least one transaction so reporting queries can + // cheaply exclude it from the "transaction-less orders" set via an indexed + // `NOT EXISTS` lookup (see LLMS_Table_Orders_Transactions). Guarded to avoid a + // redundant write on every recurring charge. + if ( 'yes' !== $this->get( 'has_transaction' ) ) { + $this->set( 'has_transaction', 'yes' ); + } + return $txn; } diff --git a/includes/schemas/llms-db-updates.php b/includes/schemas/llms-db-updates.php index 8ed7a54d20..caecee79ca 100644 --- a/includes/schemas/llms-db-updates.php +++ b/includes/schemas/llms-db-updates.php @@ -212,4 +212,12 @@ 'update_db_version', ), ), + '10.1.0' => array( + 'type' => 'auto', + 'namespace' => true, + 'updates' => array( + 'backfill_has_transaction_flag', + 'update_db_version', + ), + ), ); diff --git a/tests/phpunit/unit-tests/functions/updates/class-llms-test-functions-updates-1010.php b/tests/phpunit/unit-tests/functions/updates/class-llms-test-functions-updates-1010.php new file mode 100644 index 0000000000..2e88b274f7 --- /dev/null +++ b/tests/phpunit/unit-tests/functions/updates/class-llms-test-functions-updates-1010.php @@ -0,0 +1,145 @@ +factory->post->create( array( 'post_type' => 'llms_order' ) ); + $txn_id = $this->factory->post->create( array( 'post_type' => 'llms_transaction' ) ); + update_post_meta( $txn_id, '_llms_order_id', $order_id ); + // Simulate legacy data: flag not yet set. + delete_post_meta( $order_id, '_llms_has_transaction' ); + return $order_id; + } + + /** + * Test backfill_has_transaction_flag() flags only orders with transactions and + * paginates, returning true while more remain and false when complete. + * + * @since [version] + * + * @return void + */ + public function test_backfill_has_transaction_flag() { + + // 3 orders with transactions (per_page is 2, so this requires two passes). + $with_txns = array( + $this->create_legacy_order_with_transaction(), + $this->create_legacy_order_with_transaction(), + $this->create_legacy_order_with_transaction(), + ); + + // 1 order without a transaction (should never be flagged). + $without_txn = $this->factory->post->create( array( 'post_type' => 'llms_order' ) ); + + // First pass: full page processed, more remain. + $this->assertTrue( \LLMS\Updates\Version_10_1_0\backfill_has_transaction_flag() ); + + // Second pass: remaining order processed, none left -> returns false. + $this->assertFalse( \LLMS\Updates\Version_10_1_0\backfill_has_transaction_flag() ); + + foreach ( $with_txns as $order_id ) { + $this->assertEquals( 'yes', get_post_meta( $order_id, '_llms_has_transaction', true ), "Order {$order_id} should be flagged." ); + } + + $this->assertEmpty( get_post_meta( $without_txn, '_llms_has_transaction', true ) ); + } + + /** + * Test backfill_has_transaction_flag() returns false immediately with nothing to do. + * + * @since [version] + * + * @return void + */ + public function test_backfill_has_transaction_flag_noop() { + $this->assertFalse( \LLMS\Updates\Version_10_1_0\backfill_has_transaction_flag() ); + } + + /** + * Test update_db_version(). + * + * @since [version] + * + * @return void + */ + public function test_update_db_version() { + + $orig = get_option( 'lifterlms_db_version' ); + + delete_option( 'lifterlms_db_version' ); + + \LLMS\Updates\Version_10_1_0\update_db_version(); + + $this->assertEquals( \LLMS\Updates\Version_10_1_0\_get_db_version(), get_option( 'lifterlms_db_version' ) ); + + update_option( 'lifterlms_db_version', $orig ); + } + +} diff --git a/tests/phpunit/unit-tests/models/class-llms-test-model-llms-order.php b/tests/phpunit/unit-tests/models/class-llms-test-model-llms-order.php index d641665ea2..c62a9cd102 100644 --- a/tests/phpunit/unit-tests/models/class-llms-test-model-llms-order.php +++ b/tests/phpunit/unit-tests/models/class-llms-test-model-llms-order.php @@ -1662,6 +1662,71 @@ public function test_record_transaction() { $this->assertEquals( 1, did_action( 'lifterlms_transaction_status_succeeded' ) ); $this->assertEquals( 1, did_action( 'lifterlms_order_status_active' ) ); + // Recording a transaction flags the order as having one. + $this->assertEquals( 'yes', $order->get( 'has_transaction' ) ); + + } + + /** + * Test that record_transaction() sets the `_llms_has_transaction` order flag. + * + * @since [version] + * + * @return void + */ + public function test_record_transaction_sets_has_transaction_flag() { + + $order = $this->get_order(); + $this->assertEmpty( get_post_meta( $order->get( 'id' ), '_llms_has_transaction', true ) ); + + $order->record_transaction( + array( + 'amount' => 10.00, + 'status' => 'llms-txn-succeeded', + 'payment_type' => 'single', + ) + ); + + $this->assertEquals( 'yes', get_post_meta( $order->get( 'id' ), '_llms_has_transaction', true ) ); + } + + /** + * Test that the `_llms_has_transaction` flag is cleared only when an order's + * last transaction is deleted. + * + * @since [version] + * + * @return void + */ + public function test_has_transaction_flag_cleared_on_last_transaction_delete() { + + $order = $this->get_order(); + $order_id = $order->get( 'id' ); + + $txn_one = $order->record_transaction( + array( + 'amount' => 10.00, + 'status' => 'llms-txn-succeeded', + 'payment_type' => 'recurring', + ) + ); + $txn_two = $order->record_transaction( + array( + 'amount' => 10.00, + 'status' => 'llms-txn-succeeded', + 'payment_type' => 'recurring', + ) + ); + + $this->assertEquals( 'yes', get_post_meta( $order_id, '_llms_has_transaction', true ) ); + + // Deleting one of two transactions retains the flag. + wp_delete_post( $txn_one->get( 'id' ), true ); + $this->assertEquals( 'yes', get_post_meta( $order_id, '_llms_has_transaction', true ) ); + + // Deleting the last transaction clears the flag. + wp_delete_post( $txn_two->get( 'id' ), true ); + $this->assertEmpty( get_post_meta( $order_id, '_llms_has_transaction', true ) ); } /** From a85e5b4bcd0eb0631f58d742f5a575e24c6f77bd Mon Sep 17 00:00:00 2001 From: Brian Hogg Date: Fri, 19 Jun 2026 15:43:20 -0400 Subject: [PATCH 14/14] Fix test --- tests/phpunit/unit-tests/class-llms-test-query.php | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/phpunit/unit-tests/class-llms-test-query.php b/tests/phpunit/unit-tests/class-llms-test-query.php index a592986fcb..c9fab1cf5a 100644 --- a/tests/phpunit/unit-tests/class-llms-test-query.php +++ b/tests/phpunit/unit-tests/class-llms-test-query.php @@ -86,6 +86,7 @@ public function test_add_endpoints() { 'edit-account' => 'lifterlms_myaccount_edit_account_endpoint', 'redeem-voucher' => 'lifterlms_myaccount_redeem_vouchers_endpoint', 'orders' => 'lifterlms_myaccount_orders_endpoint', + 'subscriptions' => 'lifterlms_myaccount_subscriptions_endpoint', ); $non_latin = array( @@ -99,6 +100,7 @@ public function test_add_endpoints() { 'edit-account' => 'חשבון-עריכה', // Hebrew. 'redeem-voucher' => 'چھڑانا', // Urdu. 'orders' => 'आदेश', // Hindi. + 'subscriptions' => 'συνδρομές', // Greek. ); foreach ( LLMS_Student_Dashboard::get_tabs() as $id => $tab ) {