view = new View(__DIR__ . '/views'); } public function adminIndex(): Response { if ($guard = $this->guard()) { return $guard; } $db = Database::get(); if (!($db instanceof PDO)) { return new Response($this->view->render('admin/index.php', [ 'title' => 'Sales Reports', 'tab' => 'overview', 'filters' => $this->filters(), 'overview' => [], 'artist_rows' => [], 'track_rows' => [], 'artist_options' => [], 'tables_ready' => false, 'currency' => Settings::get('store_currency', 'GBP'), ])); } ApiLayer::ensureSchema($db); $filters = $this->filters(); $tab = $filters['tab']; return new Response($this->view->render('admin/index.php', [ 'title' => 'Sales Reports', 'tab' => $tab, 'filters' => $filters, 'overview' => $this->overviewPayload($db, $filters), 'artist_rows' => $tab === 'artists' ? $this->artistRows($db, $filters) : [], 'track_rows' => $tab === 'tracks' ? $this->trackRows($db, $filters) : [], 'artist_options' => $this->artistOptions($db), 'tables_ready' => $this->tablesReady($db), 'currency' => strtoupper(trim((string)Settings::get('store_currency', 'GBP'))), ])); } public function adminExport(): Response { if ($guard = $this->guard()) { return $guard; } $db = Database::get(); if (!($db instanceof PDO)) { return new Response('Database unavailable', 500); } ApiLayer::ensureSchema($db); $filters = $this->filters(); $type = strtolower(trim((string)($_GET['type'] ?? 'artists'))); if (!in_array($type, ['artists', 'tracks'], true)) { $type = 'artists'; } $rows = $type === 'tracks' ? $this->trackRows($db, $filters) : $this->artistRows($db, $filters); $stream = fopen('php://temp', 'w+'); if ($stream === false) { return new Response('Unable to create export', 500); } if ($type === 'tracks') { fputcsv($stream, ['Artist', 'Track', 'Release', 'Catalog', 'Units Sold', 'Units Refunded', 'Gross Revenue', 'Refunded Revenue', 'Net Revenue', 'PayPal Fees', 'Net After Fees', 'Downloads']); foreach ($rows as $row) { fputcsv($stream, [ (string)($row['artist_name'] ?? ''), (string)($row['track_display'] ?? ''), (string)($row['release_title'] ?? ''), (string)($row['catalog_no'] ?? ''), (int)($row['units_sold'] ?? 0), (int)($row['units_refunded'] ?? 0), number_format((float)($row['gross_revenue'] ?? 0), 2, '.', ''), number_format((float)($row['refunded_revenue'] ?? 0), 2, '.', ''), number_format((float)($row['net_revenue'] ?? 0), 2, '.', ''), number_format((float)($row['payment_fees'] ?? 0), 2, '.', ''), number_format((float)($row['net_after_fees'] ?? 0), 2, '.', ''), (int)($row['download_count'] ?? 0), ]); } } else { fputcsv($stream, ['Artist', 'Paid Orders', 'Units Sold', 'Units Refunded', 'Gross Revenue', 'Refunded Revenue', 'Net Revenue', 'PayPal Fees', 'Net After Fees', 'Releases', 'Tracks']); foreach ($rows as $row) { fputcsv($stream, [ (string)($row['artist_name'] ?? ''), (int)($row['paid_orders'] ?? 0), (int)($row['units_sold'] ?? 0), (int)($row['units_refunded'] ?? 0), number_format((float)($row['gross_revenue'] ?? 0), 2, '.', ''), number_format((float)($row['refunded_revenue'] ?? 0), 2, '.', ''), number_format((float)($row['net_revenue'] ?? 0), 2, '.', ''), number_format((float)($row['payment_fees'] ?? 0), 2, '.', ''), number_format((float)($row['net_after_fees'] ?? 0), 2, '.', ''), (int)($row['release_count'] ?? 0), (int)($row['track_count'] ?? 0), ]); } } rewind($stream); $csv = stream_get_contents($stream); fclose($stream); return new Response((string)$csv, 200, [ 'Content-Type' => 'text/csv; charset=UTF-8', 'Content-Disposition' => 'attachment; filename="sales-report-' . $type . '-' . gmdate('Ymd-His') . '.csv"', ]); } private function guard(): ?Response { if (!Auth::check()) { return new Response('', 302, ['Location' => '/admin/login']); } if (!Auth::hasRole(['admin', 'manager'])) { return new Response('', 302, ['Location' => '/admin']); } return null; } private function filters(): array { $tab = strtolower(trim((string)($_GET['tab'] ?? 'overview'))); if (!in_array($tab, ['overview', 'artists', 'tracks'], true)) { $tab = 'overview'; } return [ 'tab' => $tab, 'from' => trim((string)($_GET['from'] ?? '')), 'to' => trim((string)($_GET['to'] ?? '')), 'q' => trim((string)($_GET['q'] ?? '')), 'artist_id' => max(0, (int)($_GET['artist_id'] ?? 0)), ]; } private function tablesReady(PDO $db): bool { try { $probe = $db->query("SHOW TABLES LIKE 'ac_store_order_item_allocations'"); return (bool)($probe && $probe->fetch(PDO::FETCH_NUM)); } catch (Throwable $e) { return false; } } private function overviewPayload(PDO $db, array $filters): array { $stats = [ 'gross_revenue' => 0.0, 'refunded_revenue' => 0.0, 'net_revenue' => 0.0, 'payment_fees' => 0.0, 'net_after_fees' => 0.0, 'paid_orders' => 0, 'refunded_orders' => 0, 'units_sold' => 0, 'units_refunded' => 0, 'top_artists' => [], 'top_tracks' => [], ]; try { [$whereSql, $params] = $this->dateWhere($filters, 'o'); $stmt = $db->prepare(" SELECT COALESCE(SUM(CASE WHEN o.status = 'paid' THEN o.total ELSE 0 END), 0) AS gross_revenue, COALESCE(SUM(CASE WHEN o.status = 'refunded' THEN o.total ELSE 0 END), 0) AS refunded_revenue, COALESCE(SUM(CASE WHEN o.status = 'paid' THEN COALESCE(o.payment_fee, 0) ELSE 0 END), 0) AS payment_fees, COUNT(DISTINCT CASE WHEN o.status = 'paid' THEN o.id END) AS paid_orders, COUNT(DISTINCT CASE WHEN o.status = 'refunded' THEN o.id END) AS refunded_orders FROM ac_store_orders o {$whereSql} "); $stmt->execute($params); $row = $stmt->fetch(PDO::FETCH_ASSOC) ?: []; $stats['gross_revenue'] = (float)($row['gross_revenue'] ?? 0); $stats['refunded_revenue'] = (float)($row['refunded_revenue'] ?? 0); $stats['payment_fees'] = (float)($row['payment_fees'] ?? 0); $stats['net_revenue'] = $stats['gross_revenue'] - $stats['refunded_revenue']; $stats['net_after_fees'] = $stats['net_revenue'] - $stats['payment_fees']; $stats['paid_orders'] = (int)($row['paid_orders'] ?? 0); $stats['refunded_orders'] = (int)($row['refunded_orders'] ?? 0); } catch (Throwable $e) { } try { [$whereSql, $params] = $this->dateWhere($filters, 'o'); $stmt = $db->prepare(" SELECT COALESCE(SUM(CASE WHEN o.status = 'paid' THEN a.qty ELSE 0 END), 0) AS units_sold, COALESCE(SUM(CASE WHEN o.status = 'refunded' THEN a.qty ELSE 0 END), 0) AS units_refunded FROM ac_store_order_item_allocations a JOIN ac_store_orders o ON o.id = a.order_id {$whereSql} "); $stmt->execute($params); $row = $stmt->fetch(PDO::FETCH_ASSOC) ?: []; $stats['units_sold'] = (int)($row['units_sold'] ?? 0); $stats['units_refunded'] = (int)($row['units_refunded'] ?? 0); } catch (Throwable $e) { } $topFilters = $filters; $topFilters['limit'] = 5; $stats['top_artists'] = $this->artistRows($db, $topFilters); $stats['top_tracks'] = $this->trackRows($db, $topFilters); return $stats; } private function artistRows(PDO $db, array $filters): array { [$whereSql, $params] = $this->dateWhere($filters, 'o'); if (!empty($filters['artist_id'])) { $whereSql .= ($whereSql === '' ? ' WHERE ' : ' AND ') . 'alloc.artist_id = :artist_id'; $params[':artist_id'] = (int)$filters['artist_id']; } $q = trim((string)($filters['q'] ?? '')); if ($q !== '') { $whereSql .= ($whereSql === '' ? ' WHERE ' : ' AND ') . " (ar.name LIKE :q OR r.artist_name LIKE :q OR alloc.title_snapshot LIKE :q)"; $params[':q'] = '%' . $q . '%'; } $limitSql = !empty($filters['limit']) ? ' LIMIT ' . max(1, (int)$filters['limit']) : ''; try { $stmt = $db->prepare(" SELECT COALESCE(alloc.artist_id, 0) AS artist_id, COALESCE(NULLIF(MAX(ar.name), ''), NULLIF(MAX(r.artist_name), ''), 'Unknown Artist') AS artist_name, COUNT(DISTINCT CASE WHEN o.status = 'paid' THEN alloc.order_id END) AS paid_orders, COALESCE(SUM(CASE WHEN o.status = 'paid' THEN alloc.qty ELSE 0 END), 0) AS units_sold, COALESCE(SUM(CASE WHEN o.status = 'refunded' THEN alloc.qty ELSE 0 END), 0) AS units_refunded, COALESCE(SUM(CASE WHEN o.status = 'paid' THEN alloc.gross_amount ELSE 0 END), 0) AS gross_revenue, COALESCE(SUM(CASE WHEN o.status = 'refunded' THEN alloc.gross_amount ELSE 0 END), 0) AS refunded_revenue, COALESCE(SUM(CASE WHEN o.status = 'paid' AND COALESCE(NULLIF(o.payment_gross, 0), NULLIF(o.total, 0), 0) > 0 THEN COALESCE(o.payment_fee, 0) * (alloc.gross_amount / COALESCE(NULLIF(o.payment_gross, 0), NULLIF(o.total, 0))) ELSE 0 END), 0) AS payment_fees, COUNT(DISTINCT CASE WHEN o.status = 'paid' AND alloc.release_id IS NOT NULL THEN alloc.release_id END) AS release_count, COUNT(DISTINCT CASE WHEN o.status = 'paid' AND alloc.track_id IS NOT NULL THEN alloc.track_id END) AS track_count FROM ac_store_order_item_allocations alloc JOIN ac_store_orders o ON o.id = alloc.order_id LEFT JOIN ac_artists ar ON ar.id = alloc.artist_id LEFT JOIN ac_releases r ON r.id = alloc.release_id {$whereSql} GROUP BY COALESCE(alloc.artist_id, 0) ORDER BY ((COALESCE(SUM(CASE WHEN o.status = 'paid' THEN alloc.gross_amount ELSE 0 END), 0) - COALESCE(SUM(CASE WHEN o.status = 'refunded' THEN alloc.gross_amount ELSE 0 END), 0)) - COALESCE(SUM(CASE WHEN o.status = 'paid' AND COALESCE(NULLIF(o.payment_gross, 0), NULLIF(o.total, 0), 0) > 0 THEN COALESCE(o.payment_fee, 0) * (alloc.gross_amount / COALESCE(NULLIF(o.payment_gross, 0), NULLIF(o.total, 0))) ELSE 0 END), 0)) DESC, COALESCE(SUM(CASE WHEN o.status = 'paid' THEN alloc.gross_amount ELSE 0 END), 0) DESC, artist_name ASC {$limitSql} "); $stmt->execute($params); $rows = array_values(array_filter($stmt->fetchAll(PDO::FETCH_ASSOC) ?: [], static function (array $row): bool { return (float)($row['gross_revenue'] ?? 0) > 0 || (float)($row['refunded_revenue'] ?? 0) > 0; })); foreach ($rows as &$row) { $row['payment_fees'] = (float)($row['payment_fees'] ?? 0); $row['net_revenue'] = (float)($row['gross_revenue'] ?? 0) - (float)($row['refunded_revenue'] ?? 0); $row['net_after_fees'] = $row['net_revenue'] - $row['payment_fees']; } unset($row); return $rows; } catch (Throwable $e) { return []; } } private function trackRows(PDO $db, array $filters): array { [$whereSql, $params] = $this->dateWhere($filters, 'o'); if (!empty($filters['artist_id'])) { $whereSql .= ($whereSql === '' ? ' WHERE ' : ' AND ') . 'COALESCE(alloc.artist_id, r.artist_id) = :artist_id'; $params[':artist_id'] = (int)$filters['artist_id']; } $q = trim((string)($filters['q'] ?? '')); if ($q !== '') { $whereSql .= ($whereSql === '' ? ' WHERE ' : ' AND ') . "(t.title LIKE :q OR t.mix_name LIKE :q OR ar.name LIKE :q OR r.title LIKE :q OR r.catalog_no LIKE :q)"; $params[':q'] = '%' . $q . '%'; } $limitSql = !empty($filters['limit']) ? ' LIMIT ' . max(1, (int)$filters['limit']) : ''; try { $stmt = $db->prepare(" SELECT alloc.id, alloc.order_id, alloc.track_id, alloc.release_id, alloc.artist_id, alloc.qty, alloc.gross_amount, o.status, COALESCE(o.payment_fee, 0) AS payment_fee, COALESCE(o.payment_gross, 0) AS payment_gross, COALESCE(o.total, 0) AS order_total, COALESCE(NULLIF(MAX(t.title), ''), MAX(alloc.title_snapshot), 'Track') AS track_title, COALESCE(NULLIF(MAX(t.mix_name), ''), '') AS mix_name, COALESCE(NULLIF(MAX(ar.name), ''), NULLIF(MAX(r.artist_name), ''), 'Unknown Artist') AS artist_name, COALESCE(NULLIF(MAX(r.title), ''), 'Release') AS release_title, COALESCE(NULLIF(MAX(r.catalog_no), ''), '') AS catalog_no FROM ac_store_order_item_allocations alloc JOIN ac_store_orders o ON o.id = alloc.order_id LEFT JOIN ac_release_tracks t ON t.id = alloc.track_id LEFT JOIN ac_releases r ON r.id = COALESCE(alloc.release_id, t.release_id) LEFT JOIN ac_artists ar ON ar.id = COALESCE(alloc.artist_id, r.artist_id) {$whereSql} GROUP BY alloc.id ORDER BY alloc.id DESC "); $stmt->execute($params); $allocations = $stmt->fetchAll(PDO::FETCH_ASSOC) ?: []; $rows = []; foreach ($allocations as $alloc) { $releaseId = (int)($alloc['release_id'] ?? 0); $trackId = (int)($alloc['track_id'] ?? 0); if ($trackId > 0) { $targets = [[ 'track_id' => $trackId, 'artist_name' => (string)($alloc['artist_name'] ?? 'Unknown Artist'), 'release_title' => (string)($alloc['release_title'] ?? 'Release'), 'catalog_no' => (string)($alloc['catalog_no'] ?? ''), 'track_title' => (string)($alloc['track_title'] ?? 'Track'), 'mix_name' => (string)($alloc['mix_name'] ?? ''), 'weight' => 1.0, ]]; } elseif ($releaseId > 0) { $targets = $this->releaseTracks($db, $releaseId, (string)($alloc['artist_name'] ?? 'Unknown Artist'), (string)($alloc['release_title'] ?? 'Release'), (string)($alloc['catalog_no'] ?? '')); if (!$targets) { continue; } } else { continue; } $totalWeight = 0.0; foreach ($targets as $target) { $totalWeight += max(0.0, (float)($target['weight'] ?? 0)); } if ($totalWeight <= 0) { $totalWeight = (float)count($targets); foreach ($targets as &$target) { $target['weight'] = 1.0; } unset($target); } foreach ($targets as $target) { $key = (int)($target['track_id'] ?? 0); if ($key <= 0) { continue; } if (!isset($rows[$key])) { $trackTitle = trim((string)($target['track_title'] ?? 'Track')); $mixName = trim((string)($target['mix_name'] ?? '')); $rows[$key] = [ 'track_id' => $key, 'artist_name' => (string)($target['artist_name'] ?? 'Unknown Artist'), 'release_title' => (string)($target['release_title'] ?? 'Release'), 'catalog_no' => (string)($target['catalog_no'] ?? ''), 'track_title' => $trackTitle, 'mix_name' => $mixName, 'track_display' => $mixName !== '' ? $trackTitle . ' (' . $mixName . ')' : $trackTitle, 'units_sold' => 0, 'units_refunded' => 0, 'gross_revenue' => 0.0, 'refunded_revenue' => 0.0, 'download_count' => $this->trackDownloadCount($db, $key), 'payment_fees' => 0.0, 'net_after_fees' => 0.0, ]; } $share = max(0.0, (float)($target['weight'] ?? 0)) / $totalWeight; $amount = (float)($alloc['gross_amount'] ?? 0) * $share; $qty = (int)($alloc['qty'] ?? 0); $feeBase = (float)($alloc['payment_gross'] ?? 0); if ($feeBase <= 0) { $feeBase = (float)($alloc['order_total'] ?? 0); } $feeShare = ((string)($alloc['status'] ?? '') === 'paid' && $feeBase > 0) ? ((float)($alloc['payment_fee'] ?? 0) * (((float)($alloc['gross_amount'] ?? 0)) / $feeBase) * $share) : 0.0; if ((string)($alloc['status'] ?? '') === 'paid') { $rows[$key]['units_sold'] += $qty; $rows[$key]['gross_revenue'] += $amount; $rows[$key]['payment_fees'] += $feeShare; } elseif ((string)($alloc['status'] ?? '') === 'refunded') { $rows[$key]['units_refunded'] += $qty; $rows[$key]['refunded_revenue'] += $amount; } } } $rows = array_values(array_filter($rows, static function (array $row): bool { return (float)($row['gross_revenue'] ?? 0) > 0 || (float)($row['refunded_revenue'] ?? 0) > 0; })); foreach ($rows as &$row) { $row['payment_fees'] = (float)($row['payment_fees'] ?? 0); $row['net_revenue'] = (float)($row['gross_revenue'] ?? 0) - (float)($row['refunded_revenue'] ?? 0); $row['net_after_fees'] = $row['net_revenue'] - $row['payment_fees']; } unset($row); usort($rows, static function (array $a, array $b): int { $cmp = ((float)($b['net_revenue'] ?? 0)) <=> ((float)($a['net_revenue'] ?? 0)); if ($cmp !== 0) { return $cmp; } $cmp = ((float)($b['net_after_fees'] ?? 0)) <=> ((float)($a['net_after_fees'] ?? 0)); if ($cmp !== 0) { return $cmp; } return strcasecmp((string)($a['track_display'] ?? ''), (string)($b['track_display'] ?? '')); }); if (!empty($filters['limit'])) { $rows = array_slice($rows, 0, max(1, (int)$filters['limit'])); } return $rows; } catch (Throwable $e) { return []; } } private function releaseTracks(PDO $db, int $releaseId, string $artistName, string $releaseTitle, string $catalogNo): array { if ($releaseId <= 0) { return []; } if (isset($this->releaseTrackCache[$releaseId])) { return $this->releaseTrackCache[$releaseId]; } try { $stmt = $db->prepare(" SELECT t.id, t.title, COALESCE(t.mix_name, '') AS mix_name, COALESCE(sp.track_price, 0) AS track_price FROM ac_release_tracks t LEFT JOIN ac_store_track_products sp ON sp.release_track_id = t.id AND sp.is_enabled = 1 WHERE t.release_id = :release_id ORDER BY t.track_no ASC, t.id ASC "); $stmt->execute([':release_id' => $releaseId]); $tracks = $stmt->fetchAll(PDO::FETCH_ASSOC) ?: []; $targets = []; foreach ($tracks as $track) { $targets[] = [ 'track_id' => (int)($track['id'] ?? 0), 'artist_name' => $artistName, 'release_title' => $releaseTitle, 'catalog_no' => $catalogNo, 'track_title' => (string)($track['title'] ?? 'Track'), 'mix_name' => (string)($track['mix_name'] ?? ''), 'weight' => max(0.0, (float)($track['track_price'] ?? 0)), ]; } $this->releaseTrackCache[$releaseId] = $targets; return $targets; } catch (Throwable $e) { return []; } } private function trackDownloadCount(PDO $db, int $trackId): int { if ($trackId <= 0) { return 0; } if (isset($this->trackDownloadCountCache[$trackId])) { return $this->trackDownloadCountCache[$trackId]; } try { $stmt = $db->prepare(" SELECT COUNT(e.id) AS download_count FROM ac_store_files f LEFT JOIN ac_store_download_events e ON e.file_id = f.id WHERE f.scope_type = 'track' AND f.scope_id = :track_id "); $stmt->execute([':track_id' => $trackId]); $count = (int)(($stmt->fetch(PDO::FETCH_ASSOC) ?: [])['download_count'] ?? 0); $this->trackDownloadCountCache[$trackId] = $count; return $count; } catch (Throwable $e) { return 0; } } private function artistOptions(PDO $db): array { try { $stmt = $db->query("SELECT id, name FROM ac_artists WHERE name IS NOT NULL AND name <> '' ORDER BY name ASC"); return $stmt ? ($stmt->fetchAll(PDO::FETCH_ASSOC) ?: []) : []; } catch (Throwable $e) { return []; } } private function dateWhere(array $filters, string $alias): array { $where = []; $params = []; $from = trim((string)($filters['from'] ?? '')); $to = trim((string)($filters['to'] ?? '')); if ($from !== '' && preg_match('/^\d{4}-\d{2}-\d{2}$/', $from)) { $where[] = $alias . '.created_at >= :from_date'; $params[':from_date'] = $from . ' 00:00:00'; } if ($to !== '' && preg_match('/^\d{4}-\d{2}-\d{2}$/', $to)) { $where[] = $alias . '.created_at <= :to_date'; $params[':to_date'] = $to . ' 23:59:59'; } return [$where ? ' WHERE ' . implode(' AND ', $where) : '', $params]; } }