Below is a differential between the unpatched vulnerable code and the patched update, for reference.
--- a/recurio/includes/Admin.php
+++ b/recurio/includes/Admin.php
@@ -102,7 +102,8 @@
if ( false === $has_sub ) {
global $wpdb;
$has_sub = $wpdb->get_var( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
- "SELECT post_id FROM {$wpdb->postmeta} WHERE meta_key = '_recurio_subscription_enabled' AND meta_value = 'yes' LIMIT 1"
+ // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Table name is safe, no user input
+ $wpdb->prepare( "SELECT post_id FROM {$wpdb->postmeta} WHERE meta_key = %s AND meta_value = %s LIMIT %d", '_recurio_subscription_enabled', 'yes', 1 )
);
set_transient( 'recurio_has_subscription_products', $has_sub ?: '0', 12 * HOUR_IN_SECONDS );
}
--- a/recurio/includes/Assets.php
+++ b/recurio/includes/Assets.php
@@ -23,14 +23,8 @@
/** Base URL for assets/admin/ */
private $admin_assets_url;
- /** Whether the Vite dev server appears to be running */
- private $is_dev;
-
- const DEV_SERVER = 'http://localhost:5173';
-
private function __construct() {
$this->admin_assets_url = RECURIO_PLUGIN_URL . 'assets/admin';
- $this->is_dev = $this->is_vite_running();
if ( is_admin() ) {
add_action( 'admin_init', [ $this, 'register_assets' ] );
@@ -47,23 +41,6 @@
* @return array
*/
private function get_scripts() {
- if ( $this->is_dev ) {
- return array(
- 'recurio-vite-client' => array(
- 'src' => self::DEV_SERVER . '/@vite/client',
- 'deps' => array(),
- 'version' => null,
- 'in_footer' => true,
- ),
- 'recurio-vue-app' => array(
- 'src' => self::DEV_SERVER . '/src/main.js',
- 'deps' => array( 'recurio-vite-client' ),
- 'version' => null,
- 'in_footer' => true,
- ),
- );
- }
-
return array(
'recurio-element-plus' => array(
'src' => $this->admin_assets_url . '/js/element-plus.js',
@@ -92,16 +69,6 @@
* @return array
*/
private function get_styles() {
- if ( $this->is_dev ) {
- return array(
- 'recurio-admin' => array(
- 'src' => $this->admin_assets_url . '/css/admin.css',
- 'deps' => array(),
- 'version' => RECURIO_VERSION,
- ),
- );
- }
-
return array(
'recurio-vue-app-style' => array(
'src' => $this->admin_assets_url . '/css/main.css',
@@ -224,7 +191,7 @@
* Add type="module" to Vue / Vite script tags.
*/
public function add_type_module( $tag, $handle, $src ) {
- $module_handles = array( 'recurio-vue-app', 'recurio-vite-client' );
+ $module_handles = array( 'recurio-vue-app', 'recurio-vite-client','recurio-vendor','recurio-element-plus' );
if ( in_array( $handle, $module_handles, true ) ) {
$tag = str_replace( '<script ', '<script type="module" ', $tag );
}
@@ -232,23 +199,6 @@
}
/**
- * Check whether the Vite dev server is running on localhost:5173.
- */
- private function is_vite_running() {
- if ( ! function_exists( 'curl_init' ) ) {
- return false;
- }
- $handle = curl_init( self::DEV_SERVER );
- curl_setopt( $handle, CURLOPT_RETURNTRANSFER, true );
- curl_setopt( $handle, CURLOPT_NOBODY, true );
- curl_setopt( $handle, CURLOPT_TIMEOUT, 1 );
- curl_exec( $handle );
- $error = curl_errno( $handle );
- curl_close( $handle );
- return ! $error;
- }
-
- /**
* Translations passed to the Vue app via recurioData.i18n.
*/
private function get_translations() {
--- a/recurio/includes/api/ChangeLog.php
+++ b/recurio/includes/api/ChangeLog.php
@@ -342,6 +342,7 @@
}
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- One-time migration.
+ // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Table name is safe, no user input; no dynamic values in this query
$rows = $wpdb->get_results("SELECT user_id, version FROM `{$table}`", ARRAY_A);
if (is_array($rows)) {
@@ -363,6 +364,7 @@
}
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.SchemaChange -- One-time migration.
+ // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Table name is safe, no user input; DDL has no value to prepare
$wpdb->query("DROP TABLE IF EXISTS `{$table}`");
}
--- a/recurio/includes/api/ImportExport.php
+++ b/recurio/includes/api/ImportExport.php
@@ -167,15 +167,16 @@
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
$subscriptions = $wpdb->get_results(
+ // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Table names are safe, no user input
"SELECT
- s.*,
- u.display_name AS customer_name,
- u.user_email AS customer_email,
- p.post_title AS product_name
- FROM {$wpdb->prefix}recurio_subscriptions s
- LEFT JOIN {$wpdb->users} u ON s.customer_id = u.ID
- LEFT JOIN {$wpdb->posts} p ON s.product_id = p.ID
- ORDER BY s.created_at DESC"
+ s.*,
+ u.display_name AS customer_name,
+ u.user_email AS customer_email,
+ p.post_title AS product_name
+ FROM {$wpdb->prefix}recurio_subscriptions s
+ LEFT JOIN {$wpdb->users} u ON s.customer_id = u.ID
+ LEFT JOIN {$wpdb->posts} p ON s.product_id = p.ID
+ ORDER BY s.created_at DESC"
);
$csv_lines = array();
@@ -226,18 +227,22 @@
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
$customers = $wpdb->get_results(
- "SELECT
- u.ID,
- u.display_name AS name,
- u.user_email AS email,
- u.user_registered AS join_date,
- COUNT(DISTINCT s.id) AS total_subscriptions,
- COUNT(DISTINCT CASE WHEN s.status = 'active' THEN s.id END) AS active_subscriptions,
- COALESCE(SUM(s.billing_amount), 0) AS total_revenue
- FROM {$wpdb->users} u
- LEFT JOIN {$wpdb->prefix}recurio_subscriptions s ON u.ID = s.customer_id
- GROUP BY u.ID
- ORDER BY total_revenue DESC"
+ // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Table names are safe, no user input
+ $wpdb->prepare(
+ "SELECT
+ u.ID,
+ u.display_name AS name,
+ u.user_email AS email,
+ u.user_registered AS join_date,
+ COUNT(DISTINCT s.id) AS total_subscriptions,
+ COUNT(DISTINCT CASE WHEN s.status = %s THEN s.id END) AS active_subscriptions,
+ COALESCE(SUM(s.billing_amount), 0) AS total_revenue
+ FROM {$wpdb->users} u
+ LEFT JOIN {$wpdb->prefix}recurio_subscriptions s ON u.ID = s.customer_id
+ GROUP BY u.ID
+ ORDER BY total_revenue DESC",
+ 'active'
+ )
);
$csv_lines = array();
@@ -288,16 +293,17 @@
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
$revenue_data = $wpdb->get_results(
+ // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Table names are safe, no user input
"SELECT
- r.*,
- s.customer_id,
- u.display_name AS customer_name,
- p.post_title AS product_name
- FROM {$wpdb->prefix}recurio_subscription_revenue r
- LEFT JOIN {$wpdb->prefix}recurio_subscriptions s ON r.subscription_id = s.id
- LEFT JOIN {$wpdb->users} u ON s.customer_id = u.ID
- LEFT JOIN {$wpdb->posts} p ON s.product_id = p.ID
- ORDER BY r.created_at DESC"
+ r.*,
+ s.customer_id,
+ u.display_name AS customer_name,
+ p.post_title AS product_name
+ FROM {$wpdb->prefix}recurio_subscription_revenue r
+ LEFT JOIN {$wpdb->prefix}recurio_subscriptions s ON r.subscription_id = s.id
+ LEFT JOIN {$wpdb->users} u ON s.customer_id = u.ID
+ LEFT JOIN {$wpdb->posts} p ON s.product_id = p.ID
+ ORDER BY r.created_at DESC"
);
// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fopen
@@ -532,7 +538,7 @@
} else {
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
$count = $wpdb->get_var(
- "SELECT COUNT(*) FROM {$wpdb->posts} WHERE post_type = 'shop_subscription'"
+ $wpdb->prepare( "SELECT COUNT(*) FROM {$wpdb->posts} WHERE post_type = %s", 'shop_subscription' )
);
}
--- a/recurio/includes/api/Plans.php
+++ b/recurio/includes/api/Plans.php
@@ -215,24 +215,22 @@
$where_sql = implode( ' AND ', $where );
- // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
+ // $where_sql is a raw placeholder template (built above); $values matches its
+ // placeholder count exactly at runtime. Table name is $wpdb->prefix-derived, not
+ // user input. The static analyzer can't trace a runtime-built placeholder string:
+ // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare, WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber
$total = (int) $wpdb->get_var(
- $values
- // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
- ? $wpdb->prepare( "SELECT COUNT(*) FROM {$table} WHERE {$where_sql}", ...$values )
- // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
- : "SELECT COUNT(*) FROM {$table} WHERE {$where_sql}"
+ $wpdb->prepare( "SELECT COUNT(*) FROM {$table} WHERE {$where_sql}", $values )
);
- // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
$rows = $wpdb->get_results(
- $values
- // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
- ? $wpdb->prepare( "SELECT * FROM {$table} WHERE {$where_sql} ORDER BY created_at DESC LIMIT %d OFFSET %d", ...array_merge( $values, array( $per_page, $offset ) ) )
- // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
- : $wpdb->prepare( "SELECT * FROM {$table} WHERE {$where_sql} ORDER BY created_at DESC LIMIT %d OFFSET %d", $per_page, $offset ),
+ $wpdb->prepare(
+ "SELECT * FROM {$table} WHERE {$where_sql} ORDER BY created_at DESC LIMIT %d OFFSET %d",
+ array_merge( $values, array( $per_page, $offset ) )
+ ),
ARRAY_A
);
+ // phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare, WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber
$plans = array_map( array( $this, 'enrich_row' ), $rows );
@@ -571,7 +569,11 @@
$id_placeholders = implode( ',', array_fill( 0, count( $product_ids ), '%d' ) );
- // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared
+ // $id_placeholders is a dynamically-generated %d list (one per product ID); args are
+ // spread from $product_ids and match the placeholder count exactly at runtime. Table
+ // names are $wpdb->prefix-derived, not user input. The static analyzer can't trace a
+ // runtime-built placeholder string:
+ // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare, WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber
$stats = $wpdb->get_row(
$wpdb->prepare(
"SELECT
@@ -587,7 +589,6 @@
ARRAY_A
);
- // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared
$revenue_total = (float) $wpdb->get_var(
$wpdb->prepare(
"SELECT SUM(amount) FROM {$revenue_table}
@@ -597,6 +598,7 @@
...$product_ids
)
);
+ // phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare, WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber
$total = max( 1, (int) $stats['total_subscriptions'] );
@@ -925,7 +927,8 @@
global $wpdb;
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
$rows = $wpdb->get_results(
- "SELECT id, settings FROM {$wpdb->prefix}recurio_plans WHERE status = 'active'",
+ // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Table name is safe, no user input
+ $wpdb->prepare( "SELECT id, settings FROM {$wpdb->prefix}recurio_plans WHERE status = %s", 'active' ),
ARRAY_A
);
--- a/recurio/includes/api/Subscriptions.php
+++ b/recurio/includes/api/Subscriptions.php
@@ -544,8 +544,12 @@
// phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- All placeholders collected above; single prepare() call below.
$where_clause = implode( ' AND ', $where_sql );
- // Get total count.
- // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared
+ // Get total count and subscriptions.
+ // $where_clause is a raw placeholder template (built above from hardcoded %s/%d
+ // fragments); args are spread from $where_args and match the placeholder count
+ // exactly at runtime. Table names are $wpdb->prefix-derived, not user input.
+ // The static analyzer can't trace a runtime-built placeholder string, hence:
+ // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare, WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber
$total = $wpdb->get_var(
$wpdb->prepare(
"SELECT COUNT(*) FROM {$wpdb->prefix}recurio_subscriptions s LEFT JOIN {$wpdb->users} u ON s.customer_id = u.ID WHERE {$where_clause}",
@@ -553,8 +557,6 @@
)
);
- // Get subscriptions.
- // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared
$subscriptions = $wpdb->get_results(
$wpdb->prepare(
"SELECT s.*, u.display_name as customer_name, u.user_email as customer_email
@@ -566,6 +568,7 @@
...array_merge( $where_args, array( $per_page, $offset ) )
)
);
+ // phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare, WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber
// Format subscriptions.
foreach ( $subscriptions as &$subscription ) {
@@ -1003,7 +1006,8 @@
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery -- Direct query needed for REST API analytics
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.NoCaching -- Caching not appropriate for real-time analytics
$stats['average_ltv'] = $wpdb->get_var(
- "SELECT AVG(customer_ltv) FROM {$wpdb->prefix}recurio_subscriptions WHERE customer_ltv > 0"
+ // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Table name is safe, no user input
+ $wpdb->prepare( "SELECT AVG(customer_ltv) FROM {$wpdb->prefix}recurio_subscriptions WHERE customer_ltv > %d", 0 )
) ?: 0;
// Growth rate (last 30 days)
@@ -1035,17 +1039,19 @@
$segment = isset( $params['segment'] ) ? sanitize_text_field( $params['segment'] ) : '';
$subscription_status = isset( $params['subscription_status'] ) ? sanitize_text_field( $params['subscription_status'] ) : '';
- // Build WHERE clause
- $where = 'WHERE 1=1';
+ // Build WHERE clause -- kept as a raw placeholder template (never pre-prepared)
+ // so it can be safely embedded into more than one $wpdb->prepare() call below
+ // without double-processing an already-substituted LIKE wildcard.
+ $where = 'WHERE 1=1';
+ $where_args = array();
if ( $search ) {
- $where .= $wpdb->prepare(
- ' AND (u.display_name LIKE %s OR u.user_email LIKE %s)',
- '%' . $wpdb->esc_like( $search ) . '%',
- '%' . $wpdb->esc_like( $search ) . '%'
- );
+ $where .= ' AND (u.display_name LIKE %s OR u.user_email LIKE %s)';
+ $like_value = '%' . $wpdb->esc_like( $search ) . '%';
+ $where_args[] = $like_value;
+ $where_args[] = $like_value;
}
- // Build HAVING clause for filters
+ // Build HAVING clause for filters -- fully hardcoded per enum branch, no dynamic values.
$having = '';
if ( $subscription_status === 'active' ) {
$having = ' HAVING active_subscriptions > 0';
@@ -1058,7 +1064,9 @@
// Get customers with subscription data
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery -- Direct query needed for REST API
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.NoCaching -- Caching not appropriate for real-time customer data
- // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- WHERE and HAVING clauses safely constructed with placeholders
+ // WHERE/HAVING are raw placeholder templates (built above), resolved by this single
+ // prepare() call; the static analyzer can't trace a runtime-built placeholder string.
+ // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare, WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber
$customers = $wpdb->get_results(
$wpdb->prepare(
"SELECT
@@ -1078,24 +1086,27 @@
END as segment
FROM {$wpdb->users} u
LEFT JOIN {$wpdb->prefix}recurio_subscriptions s ON u.ID = s.customer_id
- {$where} /* phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared */
+ {$where}
GROUP BY u.ID
- {$having} /* phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared */
+ {$having}
ORDER BY total_revenue DESC
LIMIT %d OFFSET %d",
- $per_page,
- $offset
+ array_merge( $where_args, array( $per_page, $offset ) )
)
);
- // Get total count
- $total_query = "SELECT COUNT(DISTINCT u.ID) FROM {$wpdb->users} u
- LEFT JOIN {$wpdb->prefix}recurio_subscriptions s ON u.ID = s.customer_id
- {$where}";
+ // Get total count -- reuses the same raw WHERE template + args, prepared independently.
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery -- Direct query needed for REST API customer count
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.NoCaching -- Caching not appropriate for real-time customer data
- // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- Query is safely constructed with prepared WHERE clause
- $total = $wpdb->get_var( $total_query );
+ $total = $wpdb->get_var(
+ $wpdb->prepare(
+ "SELECT COUNT(DISTINCT u.ID) FROM {$wpdb->users} u
+ LEFT JOIN {$wpdb->prefix}recurio_subscriptions s ON u.ID = s.customer_id
+ {$where}",
+ $where_args
+ )
+ );
+ // phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare, WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber
// Add segment filtering if needed
if ( $segment ) {
@@ -1179,6 +1190,7 @@
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery -- Direct query needed for REST API customer statistics
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.NoCaching -- Caching not appropriate for real-time customer data
$total_customers = $wpdb->get_var(
+ // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Table names are safe, no user input
"SELECT COUNT(DISTINCT u.ID)
FROM {$wpdb->users} u
LEFT JOIN {$wpdb->prefix}recurio_subscriptions s ON u.ID = s.customer_id"
@@ -1188,29 +1200,38 @@
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery -- Direct query needed for REST API customer statistics
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.NoCaching -- Caching not appropriate for real-time customer data
$active_customers = $wpdb->get_var(
- "SELECT COUNT(DISTINCT customer_id)
+ // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Table name is safe, no user input
+ $wpdb->prepare(
+ "SELECT COUNT(DISTINCT customer_id)
FROM {$wpdb->prefix}recurio_subscriptions
- WHERE status = 'active'"
+ WHERE status = %s",
+ 'active'
+ )
);
// Inactive customers (customers with subscriptions but none active)
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery -- Direct query needed for REST API customer statistics
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.NoCaching -- Caching not appropriate for real-time customer data
$inactive_customers = $wpdb->get_var(
- "SELECT COUNT(DISTINCT u.ID)
+ // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Table names are safe, no user input
+ $wpdb->prepare(
+ "SELECT COUNT(DISTINCT u.ID)
FROM {$wpdb->users} u
INNER JOIN {$wpdb->prefix}recurio_subscriptions s ON u.ID = s.customer_id
WHERE u.ID NOT IN (
SELECT DISTINCT customer_id
FROM {$wpdb->prefix}recurio_subscriptions
- WHERE status = 'active'
- )"
+ WHERE status = %s
+ )",
+ 'active'
+ )
);
// Average customer value
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery -- Direct query needed for REST API customer statistics
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.NoCaching -- Caching not appropriate for real-time customer data
$avg_customer_value = $wpdb->get_var(
+ // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Table name is safe, no user input
"SELECT AVG(customer_revenue) FROM (
SELECT SUM(billing_amount) as customer_revenue
FROM {$wpdb->prefix}recurio_subscriptions
@@ -1544,19 +1565,23 @@
// Calculate retention rate
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery -- Direct query needed for REST API analytics
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.NoCaching -- Caching not appropriate for real-time analytics data
+ // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Table name is safe, no user input
$total_customers = $wpdb->get_var( "SELECT COUNT(DISTINCT customer_id) FROM {$wpdb->prefix}recurio_subscriptions" );
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery -- Direct query needed for REST API analytics
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.NoCaching -- Caching not appropriate for real-time analytics data
- $active_customers = $wpdb->get_var( "SELECT COUNT(DISTINCT customer_id) FROM {$wpdb->prefix}recurio_subscriptions WHERE status = 'active'" );
+ // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Table name is safe, no user input
+ $active_customers = $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(DISTINCT customer_id) FROM {$wpdb->prefix}recurio_subscriptions WHERE status = %s", 'active' ) );
$retention_rate = $total_customers > 0 ? round( ( $active_customers / $total_customers ) * 100, 1 ) : 0;
// Calculate conversion rate: active subscriptions / total unique customers who tried (including cancelled)
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery -- Direct query needed for REST API analytics
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.NoCaching -- Caching not appropriate for real-time analytics data
+ // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Table name is safe, no user input
$total_unique_customers = $wpdb->get_var( "SELECT COUNT(DISTINCT customer_id) FROM {$wpdb->prefix}recurio_subscriptions" );
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery -- Direct query needed for REST API analytics
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.NoCaching -- Caching not appropriate for real-time analytics data
- $active_subscriptions_count = $wpdb->get_var( "SELECT COUNT(*) FROM {$wpdb->prefix}recurio_subscriptions WHERE status = 'active'" );
+ // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Table name is safe, no user input
+ $active_subscriptions_count = $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(*) FROM {$wpdb->prefix}recurio_subscriptions WHERE status = %s", 'active' ) );
$conversion_rate = $total_unique_customers > 0 ? round( ( $active_subscriptions_count / $total_unique_customers ) * 100, 1 ) : 0;
// Calculate conversion trend (compare current month vs last month)
@@ -1595,12 +1620,14 @@
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery -- Direct query needed for REST API analytics
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.NoCaching -- Caching not appropriate for real-time analytics data
$avg_ltv_analytics = $wpdb->get_var(
- "SELECT AVG(customer_lifetime_value) FROM {$wpdb->prefix}recurio_customer_analytics WHERE customer_lifetime_value > 0"
+ // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Table name is safe, no user input
+ $wpdb->prepare( "SELECT AVG(customer_lifetime_value) FROM {$wpdb->prefix}recurio_customer_analytics WHERE customer_lifetime_value > %d", 0 )
);
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery -- Direct query needed for REST API analytics
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.NoCaching -- Caching not appropriate for real-time analytics data
$avg_ltv_subscriptions = $wpdb->get_var(
- "SELECT AVG(customer_ltv) FROM {$wpdb->prefix}recurio_subscriptions WHERE customer_ltv > 0"
+ // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Table name is safe, no user input
+ $wpdb->prepare( "SELECT AVG(customer_ltv) FROM {$wpdb->prefix}recurio_subscriptions WHERE customer_ltv > %d", 0 )
);
$avg_ltv = $avg_ltv_analytics ?: ( $avg_ltv_subscriptions ?: 0 );
@@ -1706,7 +1733,8 @@
'pausedSubscriptions' => $stats['paused'],
'cancelledSubscriptions' => $stats['cancelled'],
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Direct query needed for analytics
- 'expiredSubscriptions' => $wpdb->get_var( "SELECT COUNT(*) FROM {$wpdb->prefix}recurio_subscriptions WHERE status = 'expired'" ),
+ // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Table name is safe, no user input
+ 'expiredSubscriptions' => $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(*) FROM {$wpdb->prefix}recurio_subscriptions WHERE status = %s", 'expired' ) ),
'mrr' => $stats['mrr'],
'churnRate' => $stats['churn_rate'] ?? 0,
'growthRate' => $growth_rate,
@@ -1725,14 +1753,17 @@
'paused' => $stats['paused'],
'cancelled' => $stats['cancelled'],
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Direct query needed for analytics
- 'pending' => $wpdb->get_var( "SELECT COUNT(*) FROM {$wpdb->prefix}recurio_subscriptions WHERE status = 'pending'" ),
+ // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Table name is safe, no user input
+ 'pending' => $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(*) FROM {$wpdb->prefix}recurio_subscriptions WHERE status = %s", 'pending' ) ),
);
// Get product performance (top 10 products)
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery -- Direct query needed for REST API analytics
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.NoCaching -- Caching not appropriate for real-time analytics data
$product_performance = $wpdb->get_results(
- "SELECT
+ // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Table names are safe, no user input
+ $wpdb->prepare(
+ "SELECT
COALESCE(p.post_title, 'Unknown Product') as product,
COUNT(s.id) as subscriptions,
COALESCE(SUM(s.billing_amount), 0) as revenue,
@@ -1743,7 +1774,9 @@
WHERE s.product_id IS NOT NULL
GROUP BY s.product_id
ORDER BY revenue DESC
- LIMIT 10"
+ LIMIT %d",
+ 10
+ )
);
// Calculate churn rate for each product
@@ -1944,17 +1977,22 @@
// Get top churn reasons (if stored in metadata)
$churn_reasons = $wpdb->get_results(
- "SELECT
- CASE
+ // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Table name is safe, no user input
+ $wpdb->prepare(
+ "SELECT
+ CASE
WHEN cancellation_reason IS NULL OR cancellation_reason = '' THEN 'Not specified'
ELSE cancellation_reason
END as reason,
COUNT(*) as count
- FROM {$wpdb->prefix}recurio_subscriptions
- WHERE status = 'cancelled'
+ FROM {$wpdb->prefix}recurio_subscriptions
+ WHERE status = %s
GROUP BY cancellation_reason
ORDER BY count DESC
- LIMIT 5"
+ LIMIT %d",
+ 'cancelled',
+ 5
+ )
);
$total_cancellations = array_sum( array_column( $churn_reasons, 'count' ) );
@@ -1998,8 +2036,12 @@
private function calculate_revenue_forecast( $wpdb ) {
// Get current MRR first
$current_mrr = $wpdb->get_var(
- "SELECT SUM(billing_amount) FROM {$wpdb->prefix}recurio_subscriptions
- WHERE status = 'active'"
+ // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Table name is safe, no user input
+ $wpdb->prepare(
+ "SELECT SUM(billing_amount) FROM {$wpdb->prefix}recurio_subscriptions
+ WHERE status = %s",
+ 'active'
+ )
) ?: 0;
// Get historical revenue data for last 12 months
@@ -2190,20 +2232,25 @@
// Get product revenue breakdown
$revenue['productRevenue'] = $wpdb->get_results(
- "SELECT
+ // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Table names are safe, no user input
+ $wpdb->prepare(
+ "SELECT
p.post_title as product,
SUM(s.billing_amount) as revenue,
COUNT(s.id) as count
FROM {$wpdb->prefix}recurio_subscriptions s
LEFT JOIN {$wpdb->posts} p ON s.product_id = p.ID
- WHERE s.status = 'active'
+ WHERE s.status = %s
GROUP BY s.product_id
- ORDER BY revenue DESC"
+ ORDER BY revenue DESC",
+ 'active'
+ )
);
// Get payment methods distribution from revenue table
$payment_methods = $wpdb->get_results(
- "SELECT
+ // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Table name is safe, no user input
+ "SELECT
payment_method,
SUM(amount) as total,
COUNT(*) as count
@@ -2415,15 +2462,23 @@
$start_date = isset( $params['start_date'] ) ? sanitize_text_field( $params['start_date'] ) : '';
$end_date = isset( $params['end_date'] ) ? sanitize_text_field( $params['end_date'] ) : '';
- // Build WHERE clause
- $where = 'WHERE 1=1';
+ // Build WHERE clause -- kept as a raw placeholder template (never pre-prepared)
+ // so it can be safely embedded into more than one $wpdb->prepare() call below.
+ $where = 'WHERE 1=1';
+ $where_args = array();
if ( $start_date && $end_date ) {
- $where .= $wpdb->prepare( ' AND r.created_at BETWEEN %s AND %s', $start_date, $end_date );
+ $where .= ' AND r.created_at BETWEEN %s AND %s';
+ $where_args[] = $start_date;
+ $where_args[] = $end_date;
}
+ // $where is a raw placeholder template (built above); args are merged and match the
+ // placeholder count exactly at runtime. Table names are $wpdb->prefix-derived, not
+ // user input. The static analyzer can't trace a runtime-built placeholder string:
+ // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare, WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber
$transactions = $wpdb->get_results(
$wpdb->prepare(
- "SELECT
+ "SELECT
r.id,
r.subscription_id,
r.amount,
@@ -2440,13 +2495,13 @@
LEFT JOIN {$wpdb->prefix}recurio_subscriptions s ON r.subscription_id = s.id
LEFT JOIN {$wpdb->users} u ON s.customer_id = u.ID
LEFT JOIN {$wpdb->posts} p ON s.product_id = p.ID
- {$where} /* phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared */
+ {$where}
ORDER BY r.created_at DESC
LIMIT %d OFFSET %d",
- $per_page,
- $offset
+ array_merge( $where_args, array( $per_page, $offset ) )
)
);
+ // phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare, WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber
// Format transactions for display
foreach ( $transactions as &$transaction ) {
@@ -2459,11 +2514,14 @@
$transaction->method = $transaction->payment_method ?: 'Credit Card';
}
+ // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare -- Table name is safe, no user input; WHERE is a raw placeholder template resolved by this prepare() call
$total = $wpdb->get_var(
$wpdb->prepare(
- "SELECT COUNT(*) FROM {$wpdb->prefix}recurio_subscription_revenue r {$where}"
+ "SELECT COUNT(*) FROM {$wpdb->prefix}recurio_subscription_revenue r {$where}",
+ $where_args
)
);
+ // phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare
return new WP_REST_Response(
array(
@@ -2490,10 +2548,9 @@
// Get active goals from database
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery -- Direct query needed for REST API
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.NoCaching -- Caching not appropriate for real-time goal data
- // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Table name safely constructed
- // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- Simple query with safe table name
$goals = $wpdb->get_results(
- "SELECT * FROM $table_name WHERE status = 'active' ORDER BY end_date ASC", /* phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared */
+ // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Table name is safe, no user input
+ $wpdb->prepare( "SELECT * FROM $table_name WHERE status = %s ORDER BY end_date ASC", 'active' ),
ARRAY_A
);
@@ -2648,8 +2705,8 @@
// Return the newly created goal
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery -- Direct query needed for REST API
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.NoCaching -- Caching not appropriate for real-time goal data
- // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Table name safely constructed
$new_goal = $wpdb->get_row(
+ // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Table name safely constructed
$wpdb->prepare(
"SELECT * FROM $table_name WHERE id = %d", /* phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared */
$new_goal_id
@@ -2698,8 +2755,8 @@
// Check if goal exists
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery -- Direct query needed for REST API
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.NoCaching -- Caching not appropriate for real-time goal data
- // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Table name safely constructed
$existing = $wpdb->get_row(
+ // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Table name safely constructed
$wpdb->prepare(
"SELECT * FROM $table_name WHERE id = %d", /* phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared */
$goal_id
@@ -2757,8 +2814,8 @@
// Get updated goal
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery -- Direct query needed for REST API
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.NoCaching -- Caching not appropriate for real-time goal data
- // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Table name safely constructed
$updated_goal = $wpdb->get_row(
+ // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Table name safely constructed
$wpdb->prepare(
"SELECT * FROM $table_name WHERE id = %d", /* phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared */
$goal_id
@@ -2807,8 +2864,8 @@
// Check if goal exists
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery -- Direct query needed for REST API
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.NoCaching -- Caching not appropriate for real-time goal data
- // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Table name safely constructed
$existing = $wpdb->get_row(
+ // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Table name safely constructed
$wpdb->prepare(
"SELECT * FROM $table_name WHERE id = %d", /* phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared */
$goal_id
@@ -2844,8 +2901,12 @@
// Get recent activity count
$recent_activity = $wpdb->get_var(
- "SELECT COUNT(*) FROM {$wpdb->prefix}recurio_subscription_events
- WHERE created_at >= DATE_SUB(NOW(), INTERVAL 24 HOUR)"
+ // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Table name is safe, no user input
+ $wpdb->prepare(
+ "SELECT COUNT(*) FROM {$wpdb->prefix}recurio_subscription_events
+ WHERE created_at >= DATE_SUB(NOW(), INTERVAL %d HOUR)",
+ 24
+ )
);
// Get today's revenue
@@ -2879,6 +2940,7 @@
'today' => floatval( $today_revenue ),
),
'customers' => array(
+ // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Table name is safe, no user input
'total' => $wpdb->get_var( "SELECT COUNT(DISTINCT customer_id) FROM {$wpdb->prefix}recurio_subscriptions" ),
'new_today' => $wpdb->get_var(
$wpdb->prepare(
@@ -2921,7 +2983,9 @@
global $wpdb;
$activities = $wpdb->get_results(
- "SELECT
+ // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Table names are safe, no user input
+ $wpdb->prepare(
+ "SELECT
e.*,
s.customer_id,
u.display_name as customer_name,
@@ -2931,7 +2995,9 @@
LEFT JOIN {$wpdb->users} u ON s.customer_id = u.ID
LEFT JOIN {$wpdb->posts} p ON s.product_id = p.ID
ORDER BY e.created_at DESC
- LIMIT 20"
+ LIMIT %d",
+ 20
+ )
);
return new WP_REST_Response( $activities, 200 );
--- a/recurio/includes/base/base.php
+++ b/recurio/includes/base/base.php
@@ -72,7 +72,7 @@
?>
<div class="notice notice-error is-dismissible">
- <p><?php echo $message; ?><?php if( !empty($button['url']) ): ?><a class="button" style="margin-left: 5px;" href="<?php echo esc_url($button['url']); ?>"><?php echo esc_html($button['text']); ?></a><?php endif; ?></p>
+ <p><?php echo wp_kses_post( $message ); ?><?php if( !empty($button['url']) ): ?><a class="button" style="margin-left: 5px;" href="<?php echo esc_url($button['url']); ?>"><?php echo esc_html($button['text']); ?></a><?php endif; ?></p>
</div>
<?php
}
--- a/recurio/includes/core/class-billing-manager.php
+++ b/recurio/includes/core/class-billing-manager.php
@@ -341,7 +341,7 @@
);
} else {
/* translators: %s: WooCommerce order status */
- $error_message = sprintf( __( 'Payment failed - order status: %s', 'recurio' ), $order->get_status() );
+ $error_message = sprintf( __( 'Payment failed - order status: %s', 'recurio' ), $order->get_status() );
return new WP_Error( 'payment_failed', $error_message );
}
} catch ( Exception $e ) {
@@ -375,7 +375,7 @@
} elseif ( isset( $payment_result['message'] ) ) {
$error_message = $payment_result['message'];
} elseif ( isset( $payment_result['error'] ) ) {
- $error_message = is_string( $payment_result['error'] ) ? $payment_result['error'] : print_r( $payment_result['error'], true );
+ $error_message = is_string( $payment_result['error'] ) ? $payment_result['error'] : wp_json_encode( $payment_result['error'] );
}
$order->update_status( 'failed', $error_message );
--- a/recurio/includes/core/class-changelog-manager.php
+++ b/recurio/includes/core/class-changelog-manager.php
@@ -0,0 +1,260 @@
+<?php
+/**
+ * Changelog Manager Class
+ * Handles version updates, notifications, and changelog display.
+ * Read state is stored in user meta (no custom table), same idea as Woolentor settings changelog API.
+ *
+ * @package Recurio
+ * @since 1.0.0
+ */
+
+if ( ! defined( 'ABSPATH' ) ) {
+ exit;
+}
+
+class Recurio_Changelog_Manager {
+
+ /**
+ * User meta key for last changelog version the user has read through.
+ */
+ const USER_META_READ_VERSION = 'recurio_changelog_read';
+
+ private static $instance = null;
+
+ public static function get_instance() {
+ if ( null === self::$instance ) {
+ self::$instance = new self();
+ }
+ return self::$instance;
+ }
+
+ private function __construct() {
+ add_action( 'plugins_loaded', array( $this, 'maybe_migrate_legacy_changelog_table' ), 20 );
+ add_action( 'admin_init', array( $this, 'check_for_updates' ) );
+ }
+
+ /**
+ * One-time move from recurio_changelog_views table to user meta, then drop table.
+ *
+ * @return void
+ */
+ public function maybe_migrate_legacy_changelog_table() {
+ global $wpdb;
+
+ $legacy = $wpdb->prefix . 'recurio_changelog_views';
+
+ // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Migration-only schema check.
+ $table_exists = $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $legacy ) );
+ if ( $legacy !== $table_exists ) {
+ return;
+ }
+
+ // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Migration-only full table read.
+ // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Table name is safe, no user input; no dynamic values in this query
+ $rows = $wpdb->get_results( "SELECT user_id, version FROM {$legacy}", ARRAY_A );
+ if ( ! empty( $rows ) ) {
+ $by_user = array();
+ foreach ( $rows as $row ) {
+ $uid = isset( $row['user_id'] ) ? (int) $row['user_id'] : 0;
+ $ver = isset( $row['version'] ) ? (string) $row['version'] : '';
+ if ( $uid < 1 || '' === $ver ) {
+ continue;
+ }
+ if ( ! isset( $by_user[ $uid ] ) || version_compare( $by_user[ $uid ], $ver, '<' ) ) {
+ $by_user[ $uid ] = $ver;
+ }
+ }
+ foreach ( $by_user as $uid => $ver ) {
+ $existing = get_user_meta( $uid, self::USER_META_READ_VERSION, true );
+ if ( ! $existing || version_compare( (string) $existing, $ver, '<' ) ) {
+ update_user_meta( $uid, self::USER_META_READ_VERSION, $ver );
+ }
+ }
+ }
+
+ // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Migration: drop replaced table.
+ // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Table name is safe, no user input; DDL has no value to prepare
+ $wpdb->query( "DROP TABLE IF EXISTS `{$legacy}`" );
+ }
+
+ /**
+ * Last changelog version string stored for the user (empty if never marked read).
+ *
+ * @param int $user_id User ID.
+ * @return string
+ */
+ private function get_user_last_read( $user_id ) {
+ $v = get_user_meta( $user_id, self::USER_META_READ_VERSION, true );
+ return $v ? (string) $v : '';
+ }
+
+ /**
+ * Get changelog data
+ */
+ public function get_changelog_data() {
+ return array(
+ array(
+ 'version' => '1.0.2',
+ 'date' => '2026-04-15',
+ 'changes' => array(
+ 'Fixes' => array(
+ esc_html__( 'Offer text showing issue in product page.', 'recurio' ),
+ esc_html__( 'Checkout page order summary showing issue with subscription product.', 'recurio' ),
+ esc_html__( 'Subscribe status showing issue with non subscription product.', 'recurio' ),
+ esc_html__( 'Email template worng billing periods showing issue with custom billing type.', 'recurio' ),
+ ),
+ 'Compatibility' => array(
+ esc_html__( 'Latest WordPress and WooCommerce version', 'recurio' ),
+ ),
+ ),
+ ),
+ array(
+ 'version' => '1.0.0',
+ 'date' => '2025-09-18',
+ 'changes' => array(
+ 'New Features' => array(
+ esc_html__( 'Initial release', 'recurio' ),
+ ),
+ 'Fixes' => array(),
+ 'Improvements' => array(),
+ 'Compatibility' => array(
+ esc_html__( 'Latest WordPress and WooCommerce version', 'recurio' ),
+ ),
+ ),
+ ),
+ );
+ }
+
+ /**
+ * Get latest version
+ */
+ public function get_latest_version() {
+ $changelog = $this->get_changelog_data();
+ return ! empty( $changelog[0]['version'] ) ? $changelog[0]['version'] : '1.0.0';
+ }
+
+ /**
+ * Mark changelog as read through a version (or latest if no version).
+ *
+ * @param string|null $version Version string or null = latest.
+ * @param int|null $user_id User ID; defaults to current user.
+ * @return bool
+ */
+ public function mark_as_viewed( $version = null, $user_id = null ) {
+ if ( ! $user_id ) {
+ $user_id = get_current_user_id();
+ }
+
+ if ( ! $user_id ) {
+ return false;
+ }
+
+ $target = $version ? sanitize_text_field( $version ) : $this->get_latest_version();
+ $current = $this->get_user_last_read( $user_id );
+
+ if ( $current && version_compare( $target, $current, '<' ) ) {
+ return true;
+ }
+
+ update_user_meta( $user_id, self::USER_META_READ_VERSION, $target );
+ return true;
+ }
+
+ /**
+ * Changelog entries newer than the user's last-read pointer.
+ *
+ * @param int|null $user_id User ID.
+ * @return array<int, array<string, mixed>>
+ */
+ public function get_unread_versions( $user_id = null ) {
+ if ( ! $user_id ) {
+ $user_id = get_current_user_id();
+ }
+
+ if ( ! $user_id ) {
+ return array();
+ }
+
+ $changelog = $this->get_changelog_data();
+ $last_read = $this->get_user_last_read( $user_id );
+ $unread_logs = array();
+
+ foreach ( $changelog as $log ) {
+ if ( ! $last_read || version_compare( $last_read, $log['version'], '<' ) ) {
+ $unread_logs[] = $log;
+ }
+ }
+
+ return $unread_logs;
+ }
+
+ /**
+ * Get unread count for user
+ */
+ public function get_unread_count( $user_id = null ) {
+ return count( $this->get_unread_versions( $user_id ) );
+ }
+
+ /**
+ * Check if user has unread updates
+ */
+ public function has_unread_updates( $user_id = null ) {
+ return $this->get_unread_count( $user_id ) > 0;
+ }
+
+ /**
+ * Check for plugin updates and mark new versions
+ */
+ public function check_for_updates() {
+ $last_checked_version = get_option( 'recurio_last_changelog_version', '1.0.0' );
+ $latest_version = $this->get_latest_version();
+
+ if ( version_compare( $latest_version, $last_checked_version, '>' ) ) {
+ update_option( 'recurio_last_changelog_version', $latest_version );
+
+ do_action( 'recurio_new_version_available', $latest_version );
+ }
+ }
+
+ /**
+ * Get formatted changelog for display
+ */
+ public function get_formatted_changelog( $limit = null ) {
+ $changelog = $this->get_changelog_data();
+ $user_id = get_current_user_id();
+
+ if ( $limit ) {
+ $changelog = array_slice( $changelog, 0, $limit );
+ }
+
+ $unread_versions = $this->get_unread_versions( $user_id );
+ $unread_version_numbers = array_column( $unread_versions, 'version' );
+
+ foreach ( $changelog as &$log ) {
+ $log['is_new'] = in_array( $log['version'], $unread_version_numbers, true );
+ }
+
+ return $changelog;
+ }
+
+ /**
+ * Reset read state for a user (show all entries as unread again).
+ *
+ * @param int|null $user_id User ID.
+ * @return bool
+ */
+ public function clear_user_views( $user_id = null ) {
+ if ( ! $user_id ) {
+ $user_id = get_current_user_id();
+ }
+
+ if ( ! $user_id ) {
+ return false;
+ }
+
+ return (bool) delete_user_meta( $user_id, self::USER_META_READ_VERSION );
+ }
+}
+
+// Initialize the changelog manager
+Recurio_Changelog_Manager::get_instance();
--- a/recurio/includes/core/class-email-notifications.php
+++ b/recurio/includes/core/class-email-notifications.php
@@ -521,14 +521,13 @@
* Get email template
*/
private function get_email_template( $template_name, $variables = array() ) {
- // Extract variables for use in template
- extract( $variables );
-
// Start output buffering
ob_start();
- // Load template file if it exists, otherwise use default
- $template_file = RECURIO_PLUGIN_DIR . 'templates/emails/' . $template_name . '.php';
+ // Load template file if it exists, otherwise use default.
+ // Custom template files receive $variables directly (no extract()) --
+ // reference fields as $variables['key'] rather than bare variable names.
+ $template_file = RECURIO_PLUGIN_DIR . 'templates/emails/' . basename( $template_name ) . '.php';
if ( file_exists( $template_file ) ) {
include $template_file;
} else {
--- a/recurio/includes/core/class-subscription-engine.php
+++ b/recurio/includes/core/class-subscription-engine.php
@@ -15,6 +15,50 @@
private static $instance = null;
private $table_name;
+ /**
+ * Column whitelist for update_subscription(). Prevents attacker-controlled
+ * array keys from reaching $wpdb->update(), whose identifier quoting does
+ * not escape backticks embedded in column names.
+ */
+ private static $updatable_columns = array(
+ 'wc_subscription_id',
+ 'customer_id',
+ 'product_id',
+ 'status',
+ 'billing_period',
+ 'billing_interval',
+ 'billing_amount',
+ 'payment_method',
+ 'payment_token_id',
+ 'billing_address',
+ 'shipping_address',
+ 'shipping_amount',
+ 'shipping_method',
+ 'trial_end_date',
+ 'next_payment_date',
+ 'pause_start_date',
+ 'pause_end_date',
+ 'cancellation_date',
+ 'cancellation_reason',
+ 'failed_payment_count',
+ 'renewal_count',
+ 'skip_count',
+ 'max_renewals',
+ 'payment_type',
+ 'max_payments',
+ 'access_timing',
+ 'access_duration_value',
+ 'access_duration_unit',
+ 'access_end_date',
+ 'switched_from_id',
+ 'switched_to_id',
+ 'switch_type',
+ 'churn_risk_score',
+ 'customer_ltv',
+ 'subscription_metadata',
+ 'updated_at',
+ );
+
public static function get_instance() {
if ( null === self::$instance ) {
self::$instance = new self();
@@ -294,6 +338,16 @@
$data['subscription_metadata'] = json_encode( $data['subscription_metadata'] );
}
+ // Restrict to known columns so array keys can never reach $wpdb->update()
+ $data = array_intersect_key( $data, array_flip( self::$updatable_columns ) );
+
+ if ( empty( $data ) ) {
+ return new WP_Error(
+ 'no_valid_fields',
+ __( 'No valid fields supplied for subscription update.', 'recurio' )
+ );
+ }
+
// Log the data being updated for debugging
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery -- Direct query needed for subscription management
@@ -952,6 +1006,7 @@
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.NoCaching -- Caching not appropriate for real-time revenue goals
$active_goals = $wpdb->get_results(
$wpdb->prepare(
+ // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Table name is safe, no user input
"SELECT id, current_amount FROM {$table_name}
WHERE status = 'active'
AND %s BETWEEN start_date AND end_date",
@@ -1032,46 +1087,50 @@
global $wpdb;
$stats = array();
+ $table = $wpdb->prefix . 'recurio_subscriptions';
// Total subscriptions
- // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- Table name is safe, no user input
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery -- Direct query needed for subscription management
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.NoCaching -- Caching not appropriate for real-time statistics
$stats['total'] = $wpdb->get_var(
- "SELECT COUNT(*) FROM {$wpdb->prefix}recurio_subscriptions"
+ // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Table name is safe, no user input; no dynamic values in this query.
+ "SELECT COUNT(*) FROM {$table}"
);
// Active subscriptions
- // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- Table name is safe, no user input
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery -- Direct query needed for subscription management
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.NoCaching -- Caching not appropriate for real-time statistics
$stats['active'] = $wpdb->get_var(
- "SELECT COUNT(*) FROM {$wpdb->prefix}recurio_subscriptions WHERE status = 'active'"
+ // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Table name is safe, no user input
+ $wpdb->prepare( "SELECT COUNT(*) FROM {$table} WHERE status = %s", 'active' )
);
// Paused subscriptions
- // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- Table name is safe, no user input
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery -- Direct query needed for subscription management
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.NoCaching -- Caching not appropriate for real-time statistics
$stats['paused'] = $wpdb->get_var(
- "SELECT COUNT(*) FROM {$wpdb->prefix}recurio_subscriptions WHERE status = 'paused'"
+ // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Table name is safe, no user input
+ $wpdb->prepare( "SELECT COUNT(*) FROM {$table} WHERE status = %s", 'paused' )
);
// Cancelled subscriptions
- // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- Table name is safe, no user input
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery -- Direct query needed for subscription management
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.NoCaching -- Caching not appropriate for real-time statistics
$stats['cancelled'] = $wpdb->get_var(
- "SELECT COUNT(*) FROM {$wpdb->prefix}recurio_subscriptions WHERE status = 'cancelled'"
+ // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Table name is safe, no user input
+ $wpdb->prepare( "SELECT COUNT(*) FROM {$table} WHERE status = %s", 'cancelled' )
);
// Monthly recurring revenue (MRR)
- // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Table name is safe, no user input
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery -- Direct query needed for subscription management
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.NoCaching -- Caching not appropriate for real-time statistics
$stats['mrr'] = $wpdb->get_var(
- "SELECT SUM(billing_amount) FROM {$wpdb->prefix}recurio_subscriptions
- WHERE status = 'active' AND billing_period = 'month'"
+ $wpdb->prepare(
+ // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Table name is safe, no user input
+ "SELECT SUM(billing_amount) FROM {$table} WHERE status = %s AND billing_period = %s",
+ 'active',
+ 'month'
+ )
);
// Annual recurring revenue (ARR)
@@ -1091,8 +1150,16 @@
}
$subscription_id = isset( $_POST['subscription_id'] ) ? intval( $_POST['subscription_id'] ) : 0;
- // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Data is sanitized in update_subscription method
- $data = isset( $_POST['data'] ) ? wp_unslash( $_POST['data'] ) : array();
+ // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Keys are validated below; values are sanitized/whitelisted in update_subscription().
+ $data = isset( $_POST['data'] ) && is_array( $_POST['data'] ) ? wp_unslash( $_POST['data'] ) : array();
+
+ // Reject any field name that isn't a plain column identifier before it goes
+ // anywhere near a query -- see update_subscription() for the authoritative
+ foreach ( array_keys( $data ) as $key ) {
+ if ( ! is_string( $key ) || ! preg_match( '/^[a-zA-Z0-9_]+$/', $key ) ) {
+ unset( $data[ $key ] );
+ }
+ }
$result = $this->update_subscription( $subscription_id, $data );
--- a/recurio/includes/frontend/Customer_Portal.php
+++ b/recurio/includes/frontend/Customer_Portal.php
@@ -502,7 +502,7 @@
"{$wpdb->prefix}recurio_subscriptions",
$update_data,
array( 'id' => $subscription_id ),
- array( '%s' ),
+ array_fill( 0, count( $update_data ), '%s' ),
array( '%d' )
);
@@ -623,8 +623,8 @@
wp_send_json_success(
array(
- /* translators: %s: localized new next payment date */
'message' => isset( $result['new_next_payment'] )
+ /* translators: %s: localized new next payment date */
? sprintf( __( 'Your next billing date is now %s.', 'recurio' ), date_i18n( get_option( 'date_format' ), strtotime( $result['new_next_payment'] ) ) )
: __( 'Billing cycle skipped successfully.', 'recurio' ),
'new_next_payment' => $result['new_next_payment'] ?? '',
--- a/recurio/includes/install/class-db-migrations.php
+++ b/recurio/includes/install/class-db-migrations.php
@@ -69,35 +69,42 @@
}
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- One-time migration
+ // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Table name is safe, no user input; DDL has no value to prepare
$columns = $wpdb->get_col( "SHOW COLUMNS FROM {$table_name}" );
if ( ! in_array( 'access_duration_value', $columns, true ) ) {
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.SchemaChange -- One-time ALTER
+ // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Table name is safe, no user input; DDL has no value to prepare
$wpdb->query( "ALTER TABLE {$table_name} ADD COLUMN access_duration_value INT DEFAULT 1 AFTER access_timing" );
}
if ( ! in_array( 'access_duration_unit', $columns, true ) ) {
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.SchemaChange -- One-time ALTER
+ // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Table name is safe, no user input; DDL has no value to prepare
$wpdb->query( "ALTER TABLE {$table_name} ADD COLUMN access_duration_unit VARCHAR(20) DEFAULT 'month' AFTER access_duration_value" );
}
if ( ! in_array( 'access_end_date', $columns, true ) ) {
//