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. 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/.changelogs/student-subscriptions-and-receipts.yml b/.changelogs/student-subscriptions-and-receipts.yml new file mode 100644 index 0000000000..82731ad042 --- /dev/null +++ b/.changelogs/student-subscriptions-and-receipts.yml @@ -0,0 +1,3 @@ +significance: minor +type: added +entry: Adds a "My Subscriptions" student dashboard tab and per-transaction receipt downloads. 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/_llms-table.scss b/assets/scss/admin/_llms-table.scss index 9d02556df3..41caa47e68 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; @@ -224,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/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 new file mode 100644 index 0000000000..b9a14a05ae --- /dev/null +++ b/includes/admin/class-llms-admin-page-orders.php @@ -0,0 +1,343 @@ + 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 raw CPT listing (not the single edit screen + * and not one of our own custom pages). + * + * @since [version] + * + * @return void + */ + public function maybe_redirect_old_listing() { + + global $pagenow; + + if ( 'edit.php' !== $pagenow ) { + return; + } + + $post_type = llms_filter_input( INPUT_GET, 'post_type' ); + if ( 'llms_order' !== $post_type ) { + 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 ) { + return; + } + + wp_safe_redirect( admin_url( 'edit.php?post_type=llms_order&page=llms-orders-transactions' ) ); + exit; + } + + /** + * Register submenu pages under the Orders CPT menu. + * + * @since [version] + * + * @return void + */ + public function register_pages() { + + $parent_slug = 'edit.php?post_type=llms_order'; + + // 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' ), + __( 'Orders & Transactions', 'lifterlms' ), + $capability, + 'llms-orders-transactions', + array( $this, 'render_orders_transactions_page' ) + ); + + $this->page_hooks[] = add_submenu_page( + $parent_slug, + __( 'Subscriptions', 'lifterlms' ), + __( 'Subscriptions', 'lifterlms' ), + $capability, + 'llms-subscriptions', + array( $this, 'render_subscriptions_page' ) + ); + } + + /** + * 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 ) { + return array_merge( $pages, $this->page_hooks ); + } + + /** + * Render the Orders & Transactions page. + * + * @since [version] + * + * @return void + */ + public function render_orders_transactions_page() { + $table = new LLMS_Table_Orders_Transactions(); + $table->get_results(); + echo '
'; + // 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 '
'; + } + + /** + * Render the Subscriptions page. + * + * @since [version] + * + * @return void + */ + public function render_subscriptions_page() { + $table = new LLMS_Table_Subscriptions(); + $table->get_results(); + echo '
'; + // Hidden heading gives admin notices a home; the table renders its own visible title. + echo '

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

'; + $table->output_table_html(); + 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). + * + * 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..f98a18cb48 --- /dev/null +++ b/includes/admin/reporting/tables/llms.table.orders.transactions.php @@ -0,0 +1,1367 @@ +filter_get_data( $this->get_order_row_data( $key, $data ), $key, $data ); + } + + $order = $this->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 = $this->get_order_url( $order->get( 'id' ), $txn_id ); + $value .= ' | ' . esc_html__( 'View Order', 'lifterlms' ) . ''; + } + $value .= '
'; + break; + + case 'order': + if ( $order ) { + $value = $this->get_order_link( $order, $data->get( 'id' ) ); + } + break; + + case 'customer': + if ( $order ) { + $value = $this->get_customer_html( $order ); + } + break; + + case 'product': + if ( $order ) { + $value = $this->get_product_html( $order ); + } + break; + + case 'amount': + $amount = $data->get( 'amount' ); + $value = wp_kses( llms_price( $amount ), LLMS_ALLOWED_HTML_PRICES ); + break; + + case 'status': + $value = $this->get_status_html( $data->get( 'status' ) ); + break; + + case 'payment_type': + $value = $this->get_payment_type_label( $data->get( 'payment_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 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': + $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': + 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, $txn_id = 0 ) { + $order_id = $order->get( 'id' ); + $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. + * + * @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 ) . ''; + } + + /** + * 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. + * + * @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. + * + * @since [version] + * + * @param string $key The column id / key. + * @param LLMS_Transaction $data Transaction object. + * @return mixed + */ + 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 ) { + + 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': + return $this->get_payment_type_label( $data->get( 'payment_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 ); + } + } + + /** + * 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': + $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': + 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. + * + * @since [version] + * + * @return string + */ + public function get_table_search_form_placeholder() { + return apply_filters( 'llms_table_get_' . $this->id . '_search_placeholder', __( 'Search by order or transaction 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(); + ?> +
+
+ + +
+
+ + +
+
+ + +
+
+ '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. + * + * @since [version] + * + * @param array $args Array of query args. + * @return void + */ + public function get_results( $args = array() ) { + + if ( ! current_user_can( 'view_lifterlms_reports' ) ) { + return; + } + + $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' => array( 'llms_transaction', 'llms_order' ), + 'posts_per_page' => $this->get_per_page(), + 'paged' => $this->get_current_page(), + 'order' => $this->get_order(), + 'post_status' => 'any', + ); + + // 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 'product': + case 'amount': + $this->sort_mode = $this->get_orderby(); + break; + case 'date': + default: + $query_args['orderby'] = 'date'; + break; + } + + // 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 ) { + $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). + 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] ), + ), + ); + } + + $search = $this->get_search(); + + if ( $search || $this->coupon_filter ) { + + // 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 ); + if ( empty( $search_post_ids ) ) { + $this->tbody_data = array(); + return; + } + } + + // 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 ), + $this->get_orders_without_transactions( $coupon_order_ids ) + ); + } + + // 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; + } + + $post_in = array_map( 'absint', (array) $post_in ); + + if ( empty( $post_in ) ) { + $this->tbody_data = array(); + return; + } + + $query_args['post__in'] = $post_in; + + } 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 { + + // 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 ) { + add_filter( 'posts_clauses', array( $this, 'mixed_orderby_clauses' ), 10, 2 ); + } + + $query = new WP_Query( $query_args ); + + 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() ); + + $rows = array(); + foreach ( $query->posts as $post ) { + $obj = llms_get_post( $post ); + if ( $obj instanceof LLMS_Transaction || $obj instanceof LLMS_Order ) { + $rows[] = $obj; + } + } + + $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, 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, 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. + * + * @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; + } + + /** + * 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. + * @return int[] Array of matching post IDs. + */ + private function get_search_post_ids( $term ) { + + 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 ) ), + $this->get_orders_without_transactions( 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 ), + $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. + * + * @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 ]; + } + + /** + * Modify the query clauses to sort the mixed transaction/order result set. + * + * 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] + * + * @param array $clauses Array of SQL clauses. + * @param WP_Query $query The WP_Query instance (passed by reference). + * @return array + */ + public function mixed_orderby_clauses( $clauses, $query ) { + + global $wpdb; + + $order = ( 'ASC' === strtoupper( $this->get_order() ) ) ? 'ASC' : 'DESC'; + + // 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; + } + + /** + * 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, self::CACHE_GROUP ); + + 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 IN ( 'llms_transaction', 'llms_order' ) + AND post_status NOT IN ( 'auto-draft', 'trash' ) + ORDER BY post_date DESC" + ); + wp_cache_set( $cache_key, $months, self::CACHE_GROUP, HOUR_IN_SECONDS ); + } + + return $months; + } + + /** + * 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['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']; + } + } + + /** + * 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 ), + 'status' => $this->status_filter, + 'date' => $this->date_filter, + 'coupon' => $this->coupon_filter, + ); + } + + /** + * Define the structure of the table. + * + * @since [version] + * + * @return array + */ + protected function set_columns() { + return array( + 'transaction_id' => array( + 'exportable' => true, + 'sortable' => false, + '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' => true, + '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..8dd41cae54 --- /dev/null +++ b/includes/admin/reporting/tables/llms.table.subscriptions.php @@ -0,0 +1,638 @@ +get( 'id' ); + $url = esc_url( admin_url( 'post.php?post=' . $order_id . '&action=edit' ) ); + $value = '#' . $order_id . ''; + break; + + case 'customer': + $value = $this->get_customer_html( $data ); + 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' ) . ' ' . get_option( 'time_format' ) ); + break; + + default: + $value = ''; + } + + 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. + * + * @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 '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' ) ); + + 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(), + 'post_status' => 'any', + 'meta_query' => array( + array( + 'key' => '_llms_order_type', + 'value' => 'recurring', + ), + ), + ); + + // Map the sortable column to valid WP_Query ordering arguments. + switch ( $this->get_orderby() ) { + 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'; + 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(); + } + + // 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' => true, + '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/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-loader.php b/includes/class-llms-loader.php index e8d7fafe7e..4418ee1ae1 100644 --- a/includes/class-llms-loader.php +++ b/includes/class-llms-loader.php @@ -374,6 +374,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/includes/class.llms.student.dashboard.php b/includes/class.llms.student.dashboard.php index aa9aad3acb..a654f9bb38 100644 --- a/includes/class.llms.student.dashboard.php +++ b/includes/class.llms.student.dashboard.php @@ -30,9 +30,66 @@ 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 $cache = array(); + + $user_id = get_current_user_id(); + + if ( isset( $cache[ $user_id ] ) ) { + return $cache[ $user_id ]; + } + + $has_subscriptions = false; + + 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; + } + /** * Add endpoints to the LLMS_Query class to be automatically registered * @@ -213,6 +270,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 +437,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/controllers/class.llms.controller.orders.php b/includes/controllers/class.llms.controller.orders.php index 28e58a0895..e1dee65328 100644 --- a/includes/controllers/class.llms.controller.orders.php +++ b/includes/controllers/class.llms.controller.orders.php @@ -50,6 +50,14 @@ 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' ) ); + + // 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 ); @@ -237,6 +245,80 @@ 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(); + } + } + + /** + * 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/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 0646aac13a..284dfe67db 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/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/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/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/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' ) ) ); ?> +
+ + 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' ) ); ?> + + – + + + +
+ + + + +
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 ) : ?> + + + + +
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' ) ); ?> + + + 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-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 ) { 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'] ); + } + +} 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 ) ); } /**