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(
+ '',
+ 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 .= '';
+ 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 .= '';
+ 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();
+ ?>
+
+
+
+
+
+
+
+
+ status_filter, $status ); ?>>
+ label : $status );
+ ?>
+
+
+
+
+
+
+
+
+
+
+ get_available_months() as $month ) :
+ $value = sprintf( '%04d%02d', $month->year, $month->month );
+ $label = date_i18n( 'F Y', mktime( 0, 0, 0, $month->month, 1, $month->year ) );
+ ?>
+ date_filter, $value ); ?>>
+
+
+
+
+
+
+
+
+
+
+
+ get_coupons() as $coupon ) : ?>
+ coupon_filter, $coupon->ID ); ?>>
+ post_title ); ?>
+
+
+
+
+
+ '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();
+ ?>
+
+
+
+
+
+
+
+ $label ) : ?>
+ >
+
+
+
+
+
+
+ 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 @@
+
+
+>
+
+
+
+
+ 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( 'gateway_transaction_id' ) ) : ?>
+
+
+ get( 'gateway_transaction_id' ) ); ?>
+
+
+
+
+
+
+
+
+
+
+ #get( 'id' ) ); ?>
+
+
+
+ get( 'product_title' ) ); ?>
+
+
+
+
+
+
+
+
+
+ get_customer_name() ); ?>
+
+
+
+ get( 'billing_email' ) ); ?>
+
+ get( 'billing_address_1' ) ) : ?>
+
+
+
+ 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 @@
+
+
+>
+
+
+
+
+ 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_customer_name() ); ?>
+
+
+
+ get( 'billing_email' ) ); ?>
+
+ get( 'billing_address_1' ) ) : ?>
+
+
+
+ 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 ) );
}
/**