diff --git a/includes/admin/class-admin-notices.php b/includes/admin/class-admin-notices.php index 31c691a..54cf98c 100755 --- a/includes/admin/class-admin-notices.php +++ b/includes/admin/class-admin-notices.php @@ -5,7 +5,7 @@ declare(strict_types=1); * Displays admin notices for onboarding, errors, and budget warnings. * * Triggered by: PRAutoBlogger::register_admin_hooks() on `admin_notices`. - * Dependencies: PRAutoBlogger_Cost_Tracker (for budget warnings). + * Dependencies: PRAutoBlogger_Cost_Reporter (for budget warnings). * * @see class-prautoblogger.php — Registers the hook. */ @@ -51,8 +51,8 @@ class PRAutoBlogger_Admin_Notices { * @return void */ private function check_budget_notice(): void { - $cost_tracker = new PRAutoBlogger_Cost_Tracker(); - $utilization = $cost_tracker->get_budget_utilization(); + $cost_reporter = new PRAutoBlogger_Cost_Reporter(); + $utilization = $cost_reporter->get_budget_utilization(); if ( $utilization >= 100.0 ) { printf( diff --git a/includes/admin/class-dashboard-widget.php b/includes/admin/class-dashboard-widget.php index 2d78921..65346b2 100755 --- a/includes/admin/class-dashboard-widget.php +++ b/includes/admin/class-dashboard-widget.php @@ -8,7 +8,7 @@ declare(strict_types=1); * and budget utilization. * * Triggered by: Could be registered via wp_dashboard_setup hook. - * Dependencies: PRAutoBlogger_Cost_Tracker. + * Dependencies: PRAutoBlogger_Cost_Reporter. * * @see class-prautoblogger.php — Can register this on wp_dashboard_setup. */ @@ -37,10 +37,10 @@ class PRAutoBlogger_Dashboard_Widget { * @return void */ public function render_widget(): void { - $cost_tracker = new PRAutoBlogger_Cost_Tracker(); - $monthly_spend = $cost_tracker->get_monthly_spend(); + $cost_reporter = new PRAutoBlogger_Cost_Reporter(); + $monthly_spend = $cost_reporter->get_monthly_spend(); $budget = (float) get_option( 'prautoblogger_monthly_budget_usd', 50.00 ); - $utilization = $cost_tracker->get_budget_utilization(); + $utilization = $cost_reporter->get_budget_utilization(); $next_run = wp_next_scheduled( 'prautoblogger_daily_generation' ); diff --git a/includes/core/class-cost-reporter.php b/includes/core/class-cost-reporter.php new file mode 100644 index 0000000..fb5a13f --- /dev/null +++ b/includes/core/class-cost-reporter.php @@ -0,0 +1,141 @@ +prefix . 'prautoblogger_generation_log'; + + $first_of_month = gmdate( 'Y-m-01 00:00:00' ); + + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery + $result = $wpdb->get_var( + $wpdb->prepare( + "SELECT COALESCE(SUM(estimated_cost), 0) FROM {$table} WHERE created_at >= %s AND response_status = 'success'", + $first_of_month + ) + ); + + return (float) $result; + } + + /** + * Get daily spend for the last N days (for the metrics dashboard chart). + * + * @param int $days Number of days to look back. + * + * @return array Associative array of date => total_cost. + */ + public function get_daily_spend( int $days = 30 ): array { + global $wpdb; + if ( null === $wpdb ) { + return []; + } + + $table = $wpdb->prefix . 'prautoblogger_generation_log'; + + $start_date = gmdate( 'Y-m-d', time() - ( $days * DAY_IN_SECONDS ) ); + + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery + $results = $wpdb->get_results( + $wpdb->prepare( + "SELECT DATE(created_at) as day, SUM(estimated_cost) as total_cost + FROM {$table} + WHERE created_at >= %s AND response_status = 'success' + GROUP BY DATE(created_at) + ORDER BY day ASC", + $start_date . ' 00:00:00' + ), + ARRAY_A + ); + + $daily = []; + foreach ( ( $results ?: [] ) as $row ) { + $daily[ $row['day'] ] = (float) $row['total_cost']; + } + + return $daily; + } + + /** + * Get spend breakdown by pipeline stage for a given period. + * + * @param string $start_date Start date (Y-m-d). + * @param string $end_date End date (Y-m-d). + * + * @return array Breakdown by stage. + */ + public function get_spend_by_stage( string $start_date, string $end_date ): array { + global $wpdb; + if ( null === $wpdb ) { + return []; + } + + $table = $wpdb->prefix . 'prautoblogger_generation_log'; + + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery + $results = $wpdb->get_results( + $wpdb->prepare( + "SELECT stage, + SUM(estimated_cost) as total_cost, + SUM(prompt_tokens + completion_tokens) as total_tokens, + COUNT(*) as call_count + FROM {$table} + WHERE created_at BETWEEN %s AND %s AND response_status = 'success' + GROUP BY stage", + $start_date . ' 00:00:00', + $end_date . ' 23:59:59' + ), + ARRAY_A + ); + + $breakdown = []; + foreach ( ( $results ?: [] ) as $row ) { + $breakdown[ $row['stage'] ] = [ + 'cost' => (float) $row['total_cost'], + 'tokens' => (int) $row['total_tokens'], + 'calls' => (int) $row['call_count'], + ]; + } + + return $breakdown; + } + + /** + * Get budget utilization percentage for the current month. + * + * @return float Percentage (0-100+). Can exceed 100 if overspent. + */ + public function get_budget_utilization(): float { + $budget = (float) get_option( 'prautoblogger_monthly_budget_usd', 50.00 ); + if ( $budget <= 0 ) { + return 0.0; + } + return ( $this->get_monthly_spend() / $budget ) * 100.0; + } +} diff --git a/includes/core/class-cost-tracker.php b/includes/core/class-cost-tracker.php index de6915a..fca9ae5 100755 --- a/includes/core/class-cost-tracker.php +++ b/includes/core/class-cost-tracker.php @@ -2,8 +2,7 @@ declare(strict_types=1); /** - * Tracks all API costs, enforces monthly budget limits, and provides - * cost reporting data for the admin dashboard. + * Tracks all API costs and enforces monthly budget limits. * * Every LLM API call in the plugin is logged through this class. The budget * enforcement is a hard stop — if the monthly budget is exceeded, no further @@ -16,7 +15,8 @@ declare(strict_types=1); * @see core/class-content-analyzer.php — Calls log_api_call() after analysis. * @see core/class-content-generator.php — Calls log_api_call() after each stage. * @see core/class-chief-editor.php — Calls log_api_call() after review. - * @see admin/class-metrics-page.php — Displays cost data from this class. + * @see core/class-cost-reporter.php — Extracted reporting methods (read-only queries). + * @see admin/class-metrics-page.php — Displays cost data via Cost_Reporter. * @see ARCHITECTURE.md — prab_generation_log table schema. */ class PRAutoBlogger_Cost_Tracker { @@ -127,7 +127,8 @@ class PRAutoBlogger_Cost_Tracker { return false; } - $monthly_spend = $this->get_monthly_spend(); + $reporter = new PRAutoBlogger_Cost_Reporter(); + $monthly_spend = $reporter->get_monthly_spend(); // Round to 4 decimal places (0.01 cent precision) to avoid // floating-point representation artifacts triggering false positives. @@ -189,32 +190,11 @@ class PRAutoBlogger_Cost_Tracker { return false; } - $projected = $this->get_monthly_spend() + $estimated_cost_usd; + $reporter = new PRAutoBlogger_Cost_Reporter(); + $projected = $reporter->get_monthly_spend() + $estimated_cost_usd; return round( $projected, 4 ) >= round( $budget, 4 ); } - /** - * Get total estimated spend for the current calendar month. - * - * @return float Total USD spend this month. - */ - public function get_monthly_spend(): float { - global $wpdb; - $table = $wpdb->prefix . 'prautoblogger_generation_log'; - - $first_of_month = gmdate( 'Y-m-01 00:00:00' ); - - // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery - $result = $wpdb->get_var( - $wpdb->prepare( - "SELECT COALESCE(SUM(estimated_cost), 0) FROM {$table} WHERE created_at >= %s AND response_status = 'success'", - $first_of_month - ) - ); - - return (float) $result; - } - /** * Get the cost accumulated during the current pipeline run. * @@ -223,91 +203,4 @@ class PRAutoBlogger_Cost_Tracker { public function get_current_run_cost(): float { return $this->current_run_cost; } - - /** - * Get daily spend for the last N days (for the metrics dashboard chart). - * - * @param int $days Number of days to look back. - * - * @return array Associative array of date => total_cost. - */ - public function get_daily_spend( int $days = 30 ): array { - global $wpdb; - $table = $wpdb->prefix . 'prautoblogger_generation_log'; - - $start_date = gmdate( 'Y-m-d', time() - ( $days * DAY_IN_SECONDS ) ); - - // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery - $results = $wpdb->get_results( - $wpdb->prepare( - "SELECT DATE(created_at) as day, SUM(estimated_cost) as total_cost - FROM {$table} - WHERE created_at >= %s AND response_status = 'success' - GROUP BY DATE(created_at) - ORDER BY day ASC", - $start_date . ' 00:00:00' - ), - ARRAY_A - ); - - $daily = []; - foreach ( ( $results ?: [] ) as $row ) { - $daily[ $row['day'] ] = (float) $row['total_cost']; - } - - return $daily; - } - - /** - * Get spend breakdown by pipeline stage for a given period. - * - * @param string $start_date Start date (Y-m-d). - * @param string $end_date End date (Y-m-d). - * - * @return array Breakdown by stage. - */ - public function get_spend_by_stage( string $start_date, string $end_date ): array { - global $wpdb; - $table = $wpdb->prefix . 'prautoblogger_generation_log'; - - // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery - $results = $wpdb->get_results( - $wpdb->prepare( - "SELECT stage, - SUM(estimated_cost) as total_cost, - SUM(prompt_tokens + completion_tokens) as total_tokens, - COUNT(*) as call_count - FROM {$table} - WHERE created_at BETWEEN %s AND %s AND response_status = 'success' - GROUP BY stage", - $start_date . ' 00:00:00', - $end_date . ' 23:59:59' - ), - ARRAY_A - ); - - $breakdown = []; - foreach ( ( $results ?: [] ) as $row ) { - $breakdown[ $row['stage'] ] = [ - 'cost' => (float) $row['total_cost'], - 'tokens' => (int) $row['total_tokens'], - 'calls' => (int) $row['call_count'], - ]; - } - - return $breakdown; - } - - /** - * Get budget utilization percentage for the current month. - * - * @return float Percentage (0-100+). Can exceed 100 if overspent. - */ - public function get_budget_utilization(): float { - $budget = (float) get_option( 'prautoblogger_monthly_budget_usd', 50.00 ); - if ( $budget <= 0 ) { - return 0.0; - } - return ( $this->get_monthly_spend() / $budget ) * 100.0; - } } diff --git a/templates/admin/metrics-page.php b/templates/admin/metrics-page.php index d580b27..9cc9351 100755 --- a/templates/admin/metrics-page.php +++ b/templates/admin/metrics-page.php @@ -9,15 +9,15 @@ if ( ! defined( 'ABSPATH' ) ) { exit; } -$cost_tracker = new PRAutoBlogger_Cost_Tracker(); -$monthly_spend = $cost_tracker->get_monthly_spend(); +$cost_reporter = new PRAutoBlogger_Cost_Reporter(); +$monthly_spend = $cost_reporter->get_monthly_spend(); $budget = (float) get_option( 'prautoblogger_monthly_budget_usd', 50.00 ); -$utilization = $cost_tracker->get_budget_utilization(); -$daily_spend = $cost_tracker->get_daily_spend( 30 ); +$utilization = $cost_reporter->get_budget_utilization(); +$daily_spend = $cost_reporter->get_daily_spend( 30 ); $first_of_month = gmdate( 'Y-m-01' ); $today = gmdate( 'Y-m-d' ); -$stage_breakdown = $cost_tracker->get_spend_by_stage( $first_of_month, $today ); +$stage_breakdown = $cost_reporter->get_spend_by_stage( $first_of_month, $today ); ?>

diff --git a/templates/admin/settings-page.php b/templates/admin/settings-page.php index 879fd35..62fa2ce 100755 --- a/templates/admin/settings-page.php +++ b/templates/admin/settings-page.php @@ -14,8 +14,8 @@ if ( ! defined( 'ABSPATH' ) ) { } $active_tab = isset( $_GET['tab'] ) ? sanitize_key( $_GET['tab'] ) : 'prautoblogger_api'; -$cost_tracker = new PRAutoBlogger_Cost_Tracker(); -$monthly_spend = $cost_tracker->get_monthly_spend(); +$cost_reporter = new PRAutoBlogger_Cost_Reporter(); +$monthly_spend = $cost_reporter->get_monthly_spend(); $budget = (float) get_option( 'prautoblogger_monthly_budget_usd', 50.00 ); $utilization = $budget > 0 ? ( $monthly_spend / $budget ) * 100.0 : 0; $next_run = wp_next_scheduled( 'prautoblogger_daily_generation' ); diff --git a/tests/unit/Core/CostReporterTest.php b/tests/unit/Core/CostReporterTest.php new file mode 100644 index 0000000..a613758 --- /dev/null +++ b/tests/unit/Core/CostReporterTest.php @@ -0,0 +1,93 @@ +wpdb = $this->create_mock_wpdb(); + $GLOBALS['wpdb'] = $this->wpdb; + + // CostReporter methods call get_option for budget settings. + $this->stub_get_option( [ + 'prautoblogger_monthly_budget_usd' => '50.00', + ] ); + + // Stub current_time used in date-based queries. + $this->stub_current_time( '2026-04-12 10:00:00' ); + } + + protected function tearDown(): void { + unset( $GLOBALS['wpdb'] ); + parent::tearDown(); + } + + /** + * Test get_monthly_spend returns float. + */ + public function test_get_monthly_spend_returns_float(): void { + $this->wpdb->method( 'prepare' )->willReturn( 'prepared' ); + $this->wpdb->method( 'get_var' )->willReturn( '10.50' ); + + $reporter = new \PRAutoBlogger_Cost_Reporter(); + $spend = $reporter->get_monthly_spend(); + + $this->assertIsFloat( $spend ); + $this->assertGreaterThanOrEqual( 0.0, $spend ); + } + + /** + * Test get_daily_spend returns array. + */ + public function test_get_daily_spend_returns_array(): void { + $this->wpdb->method( 'prepare' )->willReturn( 'prepared' ); + $this->wpdb->method( 'get_results' )->willReturn( [] ); + + $reporter = new \PRAutoBlogger_Cost_Reporter(); + $daily = $reporter->get_daily_spend( 30 ); + + $this->assertIsArray( $daily ); + } + + /** + * Test get_spend_by_stage returns array. + */ + public function test_get_spend_by_stage_returns_array(): void { + $this->wpdb->method( 'prepare' )->willReturn( 'prepared' ); + $this->wpdb->method( 'get_results' )->willReturn( [] ); + + $reporter = new \PRAutoBlogger_Cost_Reporter(); + $spend = $reporter->get_spend_by_stage( '2026-04-01', '2026-04-30' ); + + $this->assertIsArray( $spend ); + } + + /** + * Test get_budget_utilization returns float between 0 and 100+. + */ + public function test_get_budget_utilization_returns_float(): void { + $reporter = new \PRAutoBlogger_Cost_Reporter(); + $utilization = $reporter->get_budget_utilization(); + + $this->assertIsFloat( $utilization ); + $this->assertGreaterThanOrEqual( 0.0, $utilization ); + } +} diff --git a/tests/unit/Core/CostTrackerTest.php b/tests/unit/Core/CostTrackerTest.php index d67300a..f5278e6 100644 --- a/tests/unit/Core/CostTrackerTest.php +++ b/tests/unit/Core/CostTrackerTest.php @@ -68,20 +68,6 @@ class CostTrackerTest extends BaseTestCase { $this->assertFalse( $tracker->is_budget_exceeded() ); } - /** - * Test get_monthly_spend returns float. - */ - public function test_get_monthly_spend_returns_float(): void { - $this->wpdb->method( 'prepare' )->willReturn( 'prepared' ); - $this->wpdb->method( 'get_var' )->willReturn( '10.50' ); - - $tracker = new \PRAutoBlogger_Cost_Tracker(); - $spend = $tracker->get_monthly_spend(); - - $this->assertIsFloat( $spend ); - $this->assertGreaterThanOrEqual( 0.0, $spend ); - } - /** * Test get_current_run_cost returns float. */ @@ -98,43 +84,6 @@ class CostTrackerTest extends BaseTestCase { $this->assertGreaterThanOrEqual( 0.0, $cost ); } - /** - * Test get_daily_spend returns array. - */ - public function test_get_daily_spend_returns_array(): void { - $this->wpdb->method( 'prepare' )->willReturn( 'prepared' ); - $this->wpdb->method( 'get_results' )->willReturn( [] ); - - $tracker = new \PRAutoBlogger_Cost_Tracker(); - $daily = $tracker->get_daily_spend( 30 ); - - $this->assertIsArray( $daily ); - } - - /** - * Test get_spend_by_stage returns array. - */ - public function test_get_spend_by_stage_returns_array(): void { - $this->wpdb->method( 'prepare' )->willReturn( 'prepared' ); - $this->wpdb->method( 'get_results' )->willReturn( [] ); - - $tracker = new \PRAutoBlogger_Cost_Tracker(); - $spend = $tracker->get_spend_by_stage( '2026-04-01', '2026-04-30' ); - - $this->assertIsArray( $spend ); - } - - /** - * Test get_budget_utilization returns float between 0 and 1. - */ - public function test_get_budget_utilization_returns_float(): void { - $tracker = new \PRAutoBlogger_Cost_Tracker(); - $utilization = $tracker->get_budget_utilization(); - - $this->assertIsFloat( $utilization ); - $this->assertGreaterThanOrEqual( 0.0, $utilization ); - } - /** * Test log_api_call with mock database. */