'subscription',
'plural' => 'subscriptions',
'ajax' => false,
)
);
}
public function get_columns(): array {
return array(
'cb' => '',
'subscription' => __( 'Subscription', 'submex' ),
'customer' => __( 'Customer', 'submex' ),
'status' => __( 'Status', 'submex' ),
'next_payment' => __( 'Next payment', 'submex' ),
'total' => __( 'Recurring total', 'submex' ),
'gateway' => __( 'Payment method', 'submex' ),
);
}
/**
* @param Subscription $item
*/
public function column_cb( $item ): string {
return $item instanceof Subscription
? sprintf( '', (int) $item->get_id() )
: '';
}
/**
* @return array
*/
public function get_sortable_columns(): array {
return array(
'subscription' => array( 'ID', false ),
'next_payment' => array( 'next_payment', false ),
'total' => array( 'total', false ),
);
}
/**
* @return array
*/
public function get_bulk_actions(): array {
return array(
'cancel' => __( 'Cancel', 'submex' ),
'hold' => __( 'Put on hold', 'submex' ),
'resume' => __( 'Reactivate', 'submex' ),
);
}
public function prepare_items(): void {
$this->process_bulk_action();
$per_page = 20;
// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only list filters.
$paged = max( 1, absint( wp_unslash( $_GET['paged'] ?? 1 ) ) );
$status = isset( $_GET['status'] ) ? sanitize_text_field( wp_unslash( $_GET['status'] ) ) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended
$search = isset( $_REQUEST['s'] ) ? sanitize_text_field( wp_unslash( $_REQUEST['s'] ) ) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended
// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only list filters.
$orderby = isset( $_GET['orderby'] ) ? sanitize_key( wp_unslash( $_GET['orderby'] ) ) : 'date';
// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only list filters.
$order = 'asc' === strtolower( sanitize_key( wp_unslash( $_GET['order'] ?? 'desc' ) ) ) ? 'ASC' : 'DESC';
$args = array(
'limit' => $per_page,
'paged' => $paged,
'orderby' => $this->orderby_for( $orderby ),
'order' => $order,
);
if ( '' !== $status ) {
$args['status'] = $status;
}
if ( '' !== $search ) {
// Matches the customer's name, email and the subscription id, which is what
// somebody typing into this box is holding.
$args['s'] = $search;
}
$this->items = array_filter( Subscription_Query::get( $args ), static fn( $s ) => $s instanceof Subscription );
$this->_column_headers = array( $this->get_columns(), array(), $this->get_sortable_columns(), 'subscription' );
$total = count(
Subscription_Query::ids(
array_merge(
$args,
array(
'limit' => -1,
'paged' => 1,
)
)
)
);
$this->set_pagination_args(
array(
'total_items' => $total,
'per_page' => $per_page,
)
);
}
/**
* @param Subscription $item
*/
public function column_default( $item, $column_name ): string {
return match ( $column_name ) {
'subscription' => $this->subscription_cell( $item ) . $this->row_actions_for( $item ),
'customer' => esc_html( trim( $item->get_billing_first_name() . ' ' . $item->get_billing_last_name() ) ?: $item->get_billing_email() ?: '—' ),
// The pill carries colour, but the label is always readable text inside it:
// a merchant scanning for problems must not have to decode a swatch.
'status' => sprintf(
'%s%s',
esc_attr( (string) $item->get_status() ),
esc_html( Status_Presenter::for( $item )['label'] ),
$this->pending_line( $item )
),
'next_payment' => $item->get_next_payment()
? esc_html( date_i18n( (string) get_option( 'date_format' ), strtotime( $item->get_next_payment() . ' UTC' ) ) )
: '—',
'total' => wp_kses_post( $item->get_formatted_order_total() ),
'gateway' => esc_html( $item->get_payment_method_title() ?: $item->get_payment_method() ?: '—' ),
default => '',
};
}
private function row_actions_for( Subscription $item ): string {
$view = add_query_arg(
array(
'page' => Menu::LIST_SLUG,
'subscription' => $item->get_id(),
),
admin_url( 'admin.php' )
);
$actions = array(
'view' => sprintf( '%s', esc_url( $view ), esc_html__( 'View', 'submex' ) ),
);
$status = $item->get_status_enum();
// Renewing would only resume the charge already waiting on the payment provider.
if ( $status && $status->is_billable() && ! Status_Presenter::pending_charge( $item ) ) {
$actions['renew'] = sprintf(
'%s',
esc_url(
wp_nonce_url(
add_query_arg(
array(
'action' => 'submex_process_renewal',
'subscription' => $item->get_id(),
),
admin_url( 'admin-post.php' )
),
'submex_process_renewal_' . $item->get_id()
)
),
esc_html__( 'Renew now', 'submex' )
);
}
if ( $item->get_parent_order_id() ) {
$actions['order'] = sprintf(
'%s',
esc_url( (string) $this->get_edit_order_url( $item->get_parent_order_id() ) ),
esc_html__( 'Parent order', 'submex' )
);
}
return $this->row_actions( $actions );
}
private function pending_line( Subscription $item ): string {
$pending = Status_Presenter::pending_charge( $item );
if ( ! $pending ) {
return '';
}
return sprintf(
'
%s',
sprintf(
/* translators: 1: renewal order number, linked, 2: how long ago it was submitted, such as "3 days" */
esc_html__( 'Renewal order %1$s submitted %2$s ago', 'submex' ),
sprintf( '#%s', esc_url( $pending['order']->get_edit_order_url() ), esc_html( $pending['order']->get_order_number() ) ),
esc_html( human_time_diff( $pending['since'] ) )
)
);
}
/**
* wc_get_orders understands date and ID; anything of ours is order meta.
*/
private function orderby_for( string $orderby ): string {
return match ( $orderby ) {
'ID' => 'ID',
'next_payment' => 'meta_value',
'total' => 'total',
default => 'date',
};
}
private function process_bulk_action(): void {
$action = $this->current_action();
if ( ! $action || ! current_user_can( Menu::CAPABILITY ) ) {
return;
}
check_admin_referer( 'bulk-' . $this->_args['plural'] );
// intval with a floor, not absint: a posted -4 must be discarded, not turned into 4.
// phpcs:ignore WordPress.Security.NonceVerification.Missing -- checked immediately above.
$ids = array_filter( array_map( 'intval', (array) ( $_REQUEST['subscription'] ?? array() ) ), static fn( int $id ): bool => $id > 0 );
if ( ! $ids ) {
return;
}
$target = match ( $action ) {
'cancel' => Subscription_Status::Cancelled,
'hold' => Subscription_Status::OnHold,
'resume' => Subscription_Status::Active,
default => null,
};
if ( ! $target ) {
return;
}
$changed = 0;
foreach ( $ids as $id ) {
$subscription = wc_get_order( $id );
if ( ! $subscription instanceof Subscription ) {
continue;
}
$from = $subscription->get_status_enum();
// Skipped rather than forced: the state machine decides what is legal, and a
// bulk action must not be a way around it.
if ( ! $from || ! $from->can_transition_to( $target ) ) {
continue;
}
$subscription->transition_to( $target, __( 'Changed in bulk from the subscriptions list.', 'submex' ) );
$subscription->save();
++$changed;
}
wp_safe_redirect(
add_query_arg(
array(
'page' => Menu::LIST_SLUG,
'submex_changed' => $changed,
'submex_asked' => count( $ids ),
),
admin_url( 'admin.php' )
)
);
exit;
}
/**
* A parent order lives on the WooCommerce orders screen, wherever HPOS puts it.
*/
private function get_edit_order_url( int $order_id ): string {
$order = wc_get_order( $order_id );
return $order ? (string) $order->get_edit_order_url() : '';
}
private function subscription_cell( Subscription $item ): string {
$url = add_query_arg(
array(
'page' => Menu::LIST_SLUG,
'subscription' => $item->get_id(),
),
admin_url( 'admin.php' )
);
return sprintf(
'#%d
%s',
esc_url( $url ),
$item->get_id(),
esc_html( Status_Presenter::title( $item ) )
);
}
public function no_items(): void {
esc_html_e( 'No subscriptions yet. Tick "Subscription" on a product to start selling one.', 'submex' );
}
/**
* Status filter links across the top, with counts.
*/
protected function get_views(): array {
$current = isset( $_GET['status'] ) ? sanitize_text_field( wp_unslash( $_GET['status'] ) ) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended
$base = add_query_arg( array( 'page' => Menu::LIST_SLUG ), admin_url( 'admin.php' ) );
$views = array(
'all' => sprintf(
'%s',
esc_url( $base ),
'' === $current ? ' class="current"' : '',
esc_html__( 'All', 'submex' )
),
);
foreach ( Subscription_Status::cases() as $status ) {
$count = count(
Subscription_Query::ids(
array(
'status' => $status->value,
'limit' => -1,
)
)
);
if ( 0 === $count ) {
continue;
}
$views[ $status->value ] = sprintf(
'%s (%d)',
esc_url( add_query_arg( 'status', $status->value, $base ) ),
$current === $status->value ? ' class="current"' : '',
esc_html( $status->label() ),
$count
);
}
return $views;
}
}